Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 40 additions & 4 deletions frontend/app/api/pools/deposit/route.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { NextRequest, NextResponse } from "next/server"
import { supabase } from "@/lib/supabase"
import { writeLimiter } from "@/lib/rate-limit"
import { normalizeDecimals, humanToBaseUnits, trimHumanAmount } from "@/lib/deposit-token"

const HORIZON_URL =
process.env.NEXT_PUBLIC_STELLAR_HORIZON_URL || "https://horizon-testnet.stellar.org"
Expand All @@ -10,20 +11,28 @@ const HORIZON_URL =
* complete in Supabase. Prevents off-chain activity rows for deposits that
* never landed on-chain (e.g. lost confirmations, dropped transactions).
*
* POST { poolId, userAddress, txHash, amount? }
* POST { poolId, userAddress, txHash, amount?, tokenSymbol?, tokenDecimals? }
* - 200 { verified: true } — tx confirmed on-chain (and logged, unless the
* hash was already recorded).
* - 200 { verified: true, alreadyLogged: true } — previously recorded.
* - 422 { verified: false } — tx not found on Horizon or failed on-chain.
* - 502 — Horizon unreachable (caller should not mark the deposit complete).
*
* Multi-token (SEP-41) support: when `tokenSymbol`/`tokenDecimals` are
* supplied, the activity row records a `token_amount` (the human deposit
* amount denominated in that token) alongside the existing numeric `amount`.
* `tokenDecimals` defaults to 7 (native XLM / SAC-wrapped assets); callers
* pass a different value for custom tokens. `tokenAmount` is interpreted as a
* human amount and converted to base units only for validation — the stored
* value stays in human units for display consistency with `token_amount`.
*/
export async function POST(req: NextRequest) {
try {
const limited = writeLimiter(req)
if (limited) return limited

const body = await req.json()
const { poolId, userAddress, txHash, amount } = body
const { poolId, userAddress, txHash, amount, tokenSymbol, tokenDecimals, tokenAmount } = body

if (!poolId || !userAddress || !txHash) {
return NextResponse.json(
Expand Down Expand Up @@ -77,15 +86,42 @@ export async function POST(req: NextRequest) {
return NextResponse.json({ verified: true, alreadyLogged: true })
}

// 3. Mark the deposit complete in Supabase.
// 3. Resolve the per-token amount so the activity row can be broken out
// by currency (issue #255). `tokenAmount` is a human number; we keep
// it in human units for `token_amount`, but validate it converts to a
// whole number of base units at the given decimals (rejecting values
// with more precision than the asset supports).
const decimals = normalizeDecimals(tokenDecimals)
const tokenAmountHuman =
tokenAmount !== undefined && tokenAmount !== null
? trimHumanAmount(String(tokenAmount))
: undefined
let tokenBaseUnits: bigint | null = null
if (tokenAmountHuman !== undefined) {
tokenBaseUnits = humanToBaseUnits(tokenAmountHuman, decimals)
if (tokenBaseUnits === null) {
return NextResponse.json(
{
error: `tokenAmount has more precision than ${tokenSymbol ?? "asset"} supports (max ${decimals} decimals)`,
},
{ status: 400 }
)
}
}

// 4. Mark the deposit complete in Supabase.
const { error } = await supabase.from("pool_activity").insert([
{
pool_id: poolId,
activity_type: "deposit",
user_address: userAddress.toLowerCase(),
amount: typeof amount === "number" ? amount : null,
token_amount: tokenAmountHuman !== undefined ? Number(tokenAmountHuman) : null,
tx_hash: txHash,
description: "Deposit transaction",
description:
tokenSymbol && tokenSymbol !== "XLM"
? `Deposit transaction (${tokenSymbol})`
: "Deposit transaction",
},
])

Expand Down
12 changes: 10 additions & 2 deletions frontend/components/group/group-actions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -110,18 +110,26 @@ async function logActivity(
* Record a deposit in Supabase only after the transaction hash is verified
* as successful on Horizon (see app/api/pools/deposit/route.ts). Verification
* is retried briefly since Horizon may lag a freshly confirmed ledger.
*
* `tokenSymbol`/`tokenAmount` are optional and, when present, let the activity
* row be broken out by settlement currency (multi-token / SEP-41 deposits —
* see lib/deposit-token.ts).
*/
async function verifyAndLogDeposit(
poolId: string,
userAddress: string,
txHash: string,
amount: string | null
amount: string | null,
tokenSymbol?: string,
tokenAmount?: string | null
) {
const payload = {
poolId,
userAddress,
txHash,
amount: amount ? parseFloat(amount) : null,
tokenSymbol,
tokenAmount: tokenAmount ?? amount ?? null,
}
for (let attempt = 0; attempt < 3; attempt++) {
try {
Expand Down Expand Up @@ -419,7 +427,7 @@ export function GroupActions({

if (txHash) {
updateTxHash(txHash)
await verifyAndLogDeposit(groupId, address, txHash, depositAmount || null)
await verifyAndLogDeposit(groupId, address, txHash, depositAmount || null, tokenSymbol)
void triggerPushNotification(
groupId,
"event_deposit",
Expand Down
125 changes: 125 additions & 0 deletions frontend/lib/deposit-token.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
// Unit tests for the pure multi-token deposit logic: decimals conversion,
// token selection, balance checks and fee maths.
import { test } from "node:test"
import assert from "node:assert"
import {
baseUnitsToHuman,
checkSufficientBalance,
computeDepositFee,
humanToBaseUnits,
normalizeDecimals,
trimHumanAmount,
validateTokenSelection,
type DepositToken,
} from "./deposit-token"

const XLM: DepositToken = { contractAddress: "native", symbol: "XLM", decimals: 7 }
const USDC: DepositToken = {
contractAddress: "CBIELTK6YBZJU5UP2WWQEUCYKLPU6AUNZ2BQ4WWFEIE3USCIHMXQDAMA",
symbol: "USDC",
decimals: 7,
}

// ── Decimals & unit conversion ───────────────────────────────────────────────

test("normalizeDecimals - falls back to 7 for invalid values", () => {
assert.equal(normalizeDecimals(7), 7)
assert.equal(normalizeDecimals(0), 0)
assert.equal(normalizeDecimals(undefined), 7)
assert.equal(normalizeDecimals(null), 7)
assert.equal(normalizeDecimals(-1), 7)
assert.equal(normalizeDecimals(3.5), 7)
assert.equal(normalizeDecimals(Number.NaN), 7)
})

test("humanToBaseUnits - converts human amount to exact base units", () => {
assert.equal(humanToBaseUnits("1", 7), 10000000n)
assert.equal(humanToBaseUnits("1.5", 7), 15000000n)
assert.equal(humanToBaseUnits("0.07", 7), 700000n)
assert.equal(humanToBaseUnits("100", 0), 100n)
assert.equal(humanToBaseUnits(1.5, 7), 15000000n)
})

test("humanToBaseUnits - rejects invalid input", () => {
assert.equal(humanToBaseUnits("", 7), null)
assert.equal(humanToBaseUnits("abc", 7), null)
assert.equal(humanToBaseUnits("-1", 7), null)
assert.equal(humanToBaseUnits("1.2.3", 7), null)
assert.equal(humanToBaseUnits("0.12345678", 7), null) // too many decimals
assert.equal(humanToBaseUnits("1e5", 7), null)
})

test("baseUnitsToHuman - is the inverse of humanToBaseUnits", () => {
assert.equal(baseUnitsToHuman(15000000n, 7), "1.5000000")
assert.equal(baseUnitsToHuman(700000n, 7), "0.0700000")
assert.equal(baseUnitsToHuman(100n, 0), "100")
assert.equal(baseUnitsToHuman(1n, 7), "0.0000001")
assert.equal(baseUnitsToHuman(0n, 7), "0.0000000")
})

test("trimHumanAmount - trims trailing zeros in display", () => {
assert.equal(trimHumanAmount("1.5000000"), "1.5")
assert.equal(trimHumanAmount("1.0000000"), "1")
assert.equal(trimHumanAmount("0.0700000"), "0.07")
assert.equal(trimHumanAmount("100"), "100")
})

// ── Token selection ──────────────────────────────────────────────────────────

test("validateTokenSelection - accepts a supported token", () => {
const r = validateTokenSelection(XLM, [XLM, USDC])
assert.equal(r.ok, true)
if (r.ok) assert.equal(r.token.symbol, "XLM")
})

test("validateTokenSelection - accepts native when supported list has native", () => {
const r = validateTokenSelection({ ...XLM }, [
{ contractAddress: "native", symbol: "XLM", decimals: 7 },
])
assert.equal(r.ok, true)
})

test("validateTokenSelection - rejects an unsupported token", () => {
const r = validateTokenSelection(USDC, [XLM])
assert.equal(r.ok, false)
if (!r.ok) assert.match(r.reason, /not supported/)
})

test("validateTokenSelection - unrestricted pool accepts any token", () => {
const r = validateTokenSelection(USDC, [])
assert.equal(r.ok, true)
})

// ── Balance checks ───────────────────────────────────────────────────────────

test("checkSufficientBalance - ok when amount fits balance", () => {
const r = checkSufficientBalance("1.5", 20000000n, 7)
assert.equal(r.ok, true)
if (r.ok) assert.equal(r.baseUnits, 15000000n)
})

test("checkSufficientBalance - rejects insufficient balance", () => {
const r = checkSufficientBalance("3", 20000000n, 7)
assert.equal(r.ok, false)
if (!r.ok) assert.match(r.reason, /Insufficient/)
})

test("checkSufficientBalance - rejects zero and invalid amounts", () => {
assert.equal(checkSufficientBalance("0", 20000000n, 7).ok, false)
assert.equal(checkSufficientBalance("abc", 20000000n, 7).ok, false)
})

// ── Fee maths ────────────────────────────────────────────────────────────────

test("computeDepositFee - computes fee and net in human units", () => {
// 100 XLM @ 10000 stroops, 2% treasury + 1% relayer = 3% → fee 3, net 97
const r = computeDepositFee("100", 200, 100, 7)
assert.equal(r.feeHuman, 3)
assert.equal(r.netHuman, 97)
})

test("computeDepositFee - zero fees pass through", () => {
const r = computeDepositFee("50", 0, 0, 7)
assert.equal(r.feeHuman, 0)
assert.equal(r.netHuman, 50)
})
Loading
Loading