diff --git a/frontend/app/api/pools/deposit/route.ts b/frontend/app/api/pools/deposit/route.ts index 4fe425d..651c66c 100644 --- a/frontend/app/api/pools/deposit/route.ts +++ b/frontend/app/api/pools/deposit/route.ts @@ -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" @@ -10,12 +11,20 @@ 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 { @@ -23,7 +32,7 @@ export async function POST(req: NextRequest) { 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( @@ -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", }, ]) diff --git a/frontend/components/group/group-actions.tsx b/frontend/components/group/group-actions.tsx index 2103bc6..d292296 100644 --- a/frontend/components/group/group-actions.tsx +++ b/frontend/components/group/group-actions.tsx @@ -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 { @@ -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", diff --git a/frontend/lib/deposit-token.test.ts b/frontend/lib/deposit-token.test.ts new file mode 100644 index 0000000..bb6df85 --- /dev/null +++ b/frontend/lib/deposit-token.test.ts @@ -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) +}) diff --git a/frontend/lib/deposit-token.ts b/frontend/lib/deposit-token.ts new file mode 100644 index 0000000..3b65d5d --- /dev/null +++ b/frontend/lib/deposit-token.ts @@ -0,0 +1,213 @@ +/** + * Pure logic behind end-to-end multi-token (SEP-41) deposits. + * + * A pool may accept deposits in more than one settlement token (see the + * contract's `set_supported_tokens` / `get_supported_tokens`). The deposit + * flow — UI token picker, balance/trustline checks, and the deposit API route — + * all share the same amount maths, so it lives here as a dependency-free, + * unit-testable module (mirroring `deposit-calendar.ts` / `batch-deposit.ts`). + * + * The two things every caller needs: + * 1. the right decimals for the chosen asset (7 for native XLM, or the + * SEP-41 contract's decimals otherwise), and + * 2. converting between the *human* unit shown in the UI and the *base* unit + * (stroops / raw integer) the contract and Horizon deal in — always the + * reverse of what a naive `amount * 10 ** decimals` would assume. + */ + +// ── Types ──────────────────────────────────────────────────────────────────── + +/** A token the pool's deposit flow can accept, with display + maths metadata. */ +export interface DepositToken { + /** "native" for XLM, or a C… SAC/token contract id. */ + contractAddress: string + /** e.g. "XLM" or "USDC". */ + symbol: string + /** Stellar asset decimals (7 for native XLM and for SAC-wrapped tokens). */ + decimals: number +} + +/** Result of validating that a chosen token is actually acceptable. */ +export type TokenSelectionResult = { ok: true; token: DepositToken } | { ok: false; reason: string } + +/** Result of validating a user-entered human amount against an on-chain balance. */ +export type BalanceCheckResult = { ok: true; baseUnits: bigint } | { ok: false; reason: string } + +// ── Constants ──────────────────────────────────────────────────────────────── + +/** Native XLM uses 7 decimals (stroops) on Stellar. */ +export const NATIVE_DECIMALS = 7 + +/** The contract caps how many SEP-41 trustlines an account can hold. */ +export const MAX_TRUSTLINES = 7 + +/** Maximum digits of precision we allow in a human amount input (>= 0). */ +const MAX_AMOUNT_DIGITS = 20 + +// ── Decimals & unit conversion ─────────────────────────────────────────────── + +/** Guard a decimals value: must be a safe, non-negative integer (Stellar caps + * asset decimals at 7, but custom tokens may be lower). Falls back to native. */ +export function normalizeDecimals(decimals: number | null | undefined): number { + if ( + typeof decimals !== "number" || + !Number.isFinite(decimals) || + decimals < 0 || + Math.floor(decimals) !== decimals + ) { + return NATIVE_DECIMALS + } + return decimals +} + +/** + * Convert a human amount (e.g. "1.5") into base units for a token with the + * given decimals, i.e. the integer the contract/Horizon expect. Returns + * `null` when the input is not a finite, non-negative decimal number, so + * callers can show a validation error instead of minting a bogus value. + * + * Safe against floating-point error (e.g. `humanToBaseUnits("0.07", 7)` + * must be exactly `7000000`, not `6999999`). + */ +export function humanToBaseUnits(amount: number | string, decimals: number): bigint | null { + const normalized = normalizeDecimals(decimals) + const raw = typeof amount === "number" ? String(amount) : amount + const trimmed = raw.trim() + if (trimmed === "") return null + if (!/^\d+(\.\d+)?$/.test(trimmed)) return null + + const [intPart, fracPart = ""] = trimmed.split(".") + if (intPart.length > MAX_AMOUNT_DIGITS) return null + if (fracPart.length > normalized) return null + + // Left-pad the fractional part to the token's decimals, then join into one + // integer string. "1.5" @ 7 decimals → "1" + "5000000" → "15000000". + const paddedFrac = fracPart.padEnd(normalized, "0") + const joined = `${intPart}${paddedFrac}` + try { + return BigInt(joined) + } catch { + return null + } +} + +/** + * Convert base units (stroops) back into a human amount string for a token + * with the given decimals. Inverse of `humanToBaseUnits`. e.g. + * `baseUnitsToHuman(7000000n, 7)` → "0.7000000". Trailing zeros beyond the + * integer part are preserved to match the token's precision so callers can + * format consistently with the repo's number conventions. + */ +export function baseUnitsToHuman(baseUnits: bigint, decimals: number): string { + const normalized = normalizeDecimals(decimals) + const negative = baseUnits < 0n + const abs = negative ? -baseUnits : baseUnits + const str = abs.toString() + if (normalized === 0) return negative ? `-${str}` : str + + if (str.length <= normalized) { + const padded = str.padStart(normalized + 1, "0") + const int = padded.slice(0, -normalized) + const frac = padded.slice(-normalized) + return `${negative ? "-" : ""}${int}.${frac}` + } + const int = str.slice(0, -normalized) + const frac = str.slice(-normalized) + return `${negative ? "-" : ""}${int}.${frac}` +} + +/** + * Trim trailing zeros from a base-units-derived human string so display + * reads "1.5" instead of "1.5000000" (keeping at least the integer part). + */ +export function trimHumanAmount(human: string): string { + if (!human.includes(".")) return human + const [int, frac] = human.split(".") + const trimmedFrac = frac.replace(/0+$/, "") + return trimmedFrac === "" ? int : `${int}.${trimmedFrac}` +} + +// ── Token selection ────────────────────────────────────────────────────────── + +/** + * Resolve a chosen token against the pool's allowed set (the contract's + * `get_supported_tokens`, mapped to `DepositToken`s). A token is acceptable + * when its contract address matches a supported one (with `"native"` treated + * as the native XLM SAC). Empty supported list = unrestricted (pool only ever + * holds its single `initialize()` token). + */ +export function validateTokenSelection( + candidate: DepositToken, + supported: DepositToken[] +): TokenSelectionResult { + const address = candidate.contractAddress + const isNative = address === "native" + const exact = supported.find( + (s) => s.contractAddress === address || (isNative && s.contractAddress === "native") + ) + + if (exact) return { ok: true, token: exact } + // Unrestricted pools: any single candidate is accepted as-is. + if (supported.length === 0) return { ok: true, token: candidate } + + return { + ok: false, + reason: `Token ${candidate.symbol} is not supported by this pool`, + } +} + +// ── Balance & trustline checks ─────────────────────────────────────────────── + +/** + * Validate a human deposit amount against an on-chain base-units balance + * (from `fetchTokenBalance`). The `balance` is already in base units; we + * convert the user's human input to base units for a like-for-like comparison + * so a 7-decimals token compares correctly even at low values. + */ +export function checkSufficientBalance( + humanAmount: number | string, + balanceBaseUnits: bigint, + decimals: number +): BalanceCheckResult { + const amountBase = humanToBaseUnits(humanAmount, decimals) + if (amountBase === null) { + return { ok: false, reason: "Enter a valid amount" } + } + if (amountBase <= 0n) { + return { ok: false, reason: "Amount must be greater than zero" } + } + if (amountBase > balanceBaseUnits) { + return { ok: false, reason: "Insufficient balance for this deposit" } + } + return { ok: true, baseUnits: amountBase } +} + +// ── Fee maths ──────────────────────────────────────────────────────────────── + +/** + * Compute the treasury + relayer fee on a human deposit amount, in the + * *same* human unit as `humanAmount`. The contract applies these basis points + * to the settlement token, so we compute on the human number and return the + * human fee amount (with the token's decimals preserved for display). + * + * @param humanAmount deposit amount in human units + * @param treasuryFeeBps treasury fee in basis points (10000 = 100%) + * @param relayerFeeBps relayer fee in basis points (10000 = 100%) + * @param decimals token decimals (for rounding) + * @returns `{ feeHuman, netHuman }` where netHuman = amount − fee + */ +export function computeDepositFee( + humanAmount: number | string, + treasuryFeeBps: number, + relayerFeeBps: number, + decimals: number +): { feeHuman: number; netHuman: number } { + const base = humanToBaseUnits(humanAmount, decimals) ?? 0n + const totalBps = (treasuryFeeBps || 0) + (relayerFeeBps || 0) + const feeBase = (base * BigInt(totalBps)) / 10000n + const netBase = base - feeBase + return { + feeHuman: Number(baseUnitsToHuman(feeBase, decimals)), + netHuman: Number(baseUnitsToHuman(netBase, decimals)), + } +} diff --git a/frontend/package.json b/frontend/package.json index 53b3a35..cf69a2d 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -12,7 +12,7 @@ "format": "prettier --write .", "format:check": "prettier --check .", "start": "next start", - "test:unit": "tsx --test lib/csv-export.test.ts lib/activity-query.test.ts lib/soroban-event-mapping.test.ts lib/analytics.test.ts lib/batch-deposit.test.ts lib/deposit-calendar.test.ts lib/ical-export.test.ts lib/pool-health.test.ts lib/form-validation.test.ts lib/member-filters.test.ts lib/contract-version.test.ts lib/error-reporting.test.ts lib/pending-transactions.test.ts hooks/use-keyboard-shortcuts.test.ts app/api/admin/audit-log/route.test.ts app/api/admin/actions/route.test.ts app/api/errors/route.test.ts components/error-boundary.test.ts app/api/notifications/route.test.ts app/api/user-profile/route.test.ts app/api/notifications/digest-preferences/route.test.ts app/api/notifications/digest/unsubscribe/route.test.ts app/api/cron/send-digests/route.test.ts app/api/portfolio/summary/route.test.ts app/api/templates/route.test.ts", + "test:unit": "tsx --test lib/csv-export.test.ts lib/activity-query.test.ts lib/soroban-event-mapping.test.ts lib/analytics.test.ts lib/batch-deposit.test.ts lib/deposit-calendar.test.ts lib/deposit-token.test.ts lib/ical-export.test.ts lib/pool-health.test.ts lib/form-validation.test.ts lib/member-filters.test.ts lib/contract-version.test.ts lib/error-reporting.test.ts lib/pending-transactions.test.ts hooks/use-keyboard-shortcuts.test.ts app/api/admin/audit-log/route.test.ts app/api/admin/actions/route.test.ts app/api/errors/route.test.ts components/error-boundary.test.ts app/api/notifications/route.test.ts app/api/user-profile/route.test.ts app/api/notifications/digest-preferences/route.test.ts app/api/notifications/digest/unsubscribe/route.test.ts app/api/cron/send-digests/route.test.ts app/api/portfolio/summary/route.test.ts app/api/templates/route.test.ts", "test:components": "vitest run", "test:components:coverage": "vitest run --coverage", "test:e2e": "playwright test",