From ef0b3a68e94d880de776259dc387448eebef1cbb Mon Sep 17 00:00:00 2001 From: devjaja Date: Thu, 27 Aug 2026 19:05:42 +0100 Subject: [PATCH] feat: multi-token (SEP-41) deposit support for pools (Closes #255) - Add pure-logic deposit-token module (human<->base-units per asset decimals, token-selection validation, balance checks, fee math) with full unit test coverage. - Extend POST /api/pools/deposit to accept token selection, validate the token amount against the asset's precision, and record token_amount + computed fee alongside the numeric amount. - Add POST /api/pools/tokens admin route to persist a pool's supported-token allowlist (admin-only), and a SupportedTokensSettings admin UI that writes the allowlist on-chain via set_supported_tokens and saves it to Supabase. - Add useSetSupportedTokens/fetchSupportedTokens contract hooks and register set_supported_tokens as a tracked pending-transaction type. - Introduce a DepositTokenPicker on the deposit panel that lists supported assets, shows per-token balances, and validates selection. - Add pools.supported_tokens column via migration and update the typed Supabase client. --- frontend/app/api/pools/deposit/route.ts | 62 +++++- frontend/app/api/pools/tokens/route.ts | 84 ++++++++ .../components/group/deposit-token-picker.tsx | 132 ++++++++++++ frontend/components/group/group-actions.tsx | 66 +++++- .../group/supported-tokens-settings.tsx | 203 ++++++++++++++++++ frontend/hooks/useJointSaveContracts.ts | 66 ++++++ frontend/lib/deposit-token.test.ts | 168 +++++++++++++++ frontend/lib/deposit-token.ts | 157 ++++++++++++++ frontend/lib/pending-transactions.test.ts | 18 ++ frontend/lib/pending-transactions.ts | 14 +- frontend/lib/supabase.ts | 3 + frontend/lib/tx-retry.ts | 10 +- frontend/messages/en.json | 2 + frontend/messages/es.json | 2 + frontend/package.json | 2 +- .../20260827000000_multi_token_deposits.sql | 12 ++ 16 files changed, 990 insertions(+), 11 deletions(-) create mode 100644 frontend/app/api/pools/tokens/route.ts create mode 100644 frontend/components/group/deposit-token-picker.tsx create mode 100644 frontend/components/group/supported-tokens-settings.tsx create mode 100644 frontend/lib/deposit-token.test.ts create mode 100644 frontend/lib/deposit-token.ts create mode 100644 supabase/migrations/20260827000000_multi_token_deposits.sql diff --git a/frontend/app/api/pools/deposit/route.ts b/frontend/app/api/pools/deposit/route.ts index 4fe425d..479d3ac 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 { humanToBaseUnits } from "@/lib/deposit-token" const HORIZON_URL = process.env.NEXT_PUBLIC_STELLAR_HORIZON_URL || "https://horizon-testnet.stellar.org" @@ -10,11 +11,13 @@ 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?, + * tokenAmount?, treasuryFeeBps?, relayerFeeBps? } * - 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. + * - 422 { verified: false } — tx not found on Horizon, failed on-chain, or + * the token amount exceeds the asset's supported precision. * - 502 — Horizon unreachable (caller should not mark the deposit complete). */ export async function POST(req: NextRequest) { @@ -23,7 +26,17 @@ 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, + treasuryFeeBps, + relayerFeeBps, + } = body if (!poolId || !userAddress || !txHash) { return NextResponse.json( @@ -66,6 +79,24 @@ export async function POST(req: NextRequest) { ) } + // 1b. When a token-denominated amount is supplied, validate it against + // the asset's supported precision before persisting it. + let tokenAmountBase: bigint | null = null + if (tokenAmount != null && tokenSymbol) { + const decimals = typeof tokenDecimals === "number" ? tokenDecimals : 7 + try { + tokenAmountBase = humanToBaseUnits(String(tokenAmount), decimals) + } catch { + return NextResponse.json( + { + verified: false, + error: `Amount exceeds the ${decimals}-decimal precision for ${tokenSymbol}`, + }, + { status: 422 } + ) + } + } + // 2. Idempotency: never record the same tx hash twice. const { data: existing } = await supabase .from("pool_activity") @@ -77,15 +108,36 @@ export async function POST(req: NextRequest) { return NextResponse.json({ verified: true, alreadyLogged: true }) } - // 3. Mark the deposit complete in Supabase. + // 3. Mark the deposit complete in Supabase. `amount` always carries the + // numeric value (settlement workspace), while `token_amount` keeps the + // token-denominated amount so history can be broken out by currency. + const feeBps = + (typeof treasuryFeeBps === "number" ? treasuryFeeBps : 0) + + (typeof relayerFeeBps === "number" ? relayerFeeBps : 0) + + // Fee (if any) computed in the token's own unit, in base units (stroops). + let feeBase: bigint | null = null + let tokenAmountValue: number | null = null + if (tokenAmountBase != null) { + tokenAmountValue = Number(tokenAmountBase) + if (feeBps > 0) { + feeBase = (tokenAmountBase * BigInt(feeBps) + 5000n) / 10000n + } + } + 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: tokenAmountValue, tx_hash: txHash, - description: "Deposit transaction", + fee_charged: feeBase != null ? Number(feeBase) : null, + description: + tokenSymbol && tokenSymbol !== "XLM" + ? `Deposit transaction (${tokenSymbol})` + : "Deposit transaction", }, ]) diff --git a/frontend/app/api/pools/tokens/route.ts b/frontend/app/api/pools/tokens/route.ts new file mode 100644 index 0000000..7a90684 --- /dev/null +++ b/frontend/app/api/pools/tokens/route.ts @@ -0,0 +1,84 @@ +import { NextRequest, NextResponse } from "next/server" +import { supabase } from "@/lib/supabase" +import { writeLimiter } from "@/lib/rate-limit" + +const isValidTokenId = (id: string) => id === "native" || /^C[A-Z2-7]{55}$/.test(id) + +/** + * POST /api/pools/tokens + * Persist a pool's supported-token allowlist in Supabase so the deposit UI + * knows which SEP-41 assets to offer. Mirrors the contract's + * `set_supported_tokens` (replace semantics); the on-chain call is made + * separately by the wallet (via `useSetSupportedTokens`). + * + * Admin-only: the caller must be the pool creator. When the on-chain tx has + * already been broadcast, pass `txHash` so the row is marked atomically. + * + * Body: { poolId, callerAddress, supportedTokens: string[], txHash? } + */ +export async function POST(req: NextRequest) { + try { + const limited = writeLimiter(req) + if (limited) return limited + + const body = await req.json() + const { poolId, callerAddress, supportedTokens, txHash } = body + + if (!poolId || !callerAddress || !Array.isArray(supportedTokens)) { + return NextResponse.json( + { error: "Missing required fields: poolId, callerAddress, supportedTokens" }, + { status: 400 } + ) + } + + // Validate every token id is "native" or a well-formed C… contract id. + const invalid = supportedTokens.find((t) => typeof t !== "string" || !isValidTokenId(t)) + if (invalid) { + return NextResponse.json({ error: `Invalid token id: ${String(invalid)}` }, { status: 400 }) + } + + const { data: pool, error: poolErr } = await supabase + .from("pools") + .select("id, creator_address") + .eq("id", poolId) + .single() + + if (poolErr || !pool) { + return NextResponse.json({ error: "Pool not found" }, { status: 404 }) + } + + const isCreator = pool.creator_address.toLowerCase() === callerAddress.toLowerCase() + if (!isCreator) { + return NextResponse.json({ error: "Forbidden" }, { status: 403 }) + } + + const { error: updateErr } = await supabase + .from("pools") + .update({ supported_tokens: supportedTokens }) + .eq("id", poolId) + + if (updateErr) { + console.error("Failed to persist supported tokens:", updateErr) + return NextResponse.json({ error: "Failed to save supported tokens" }, { status: 500 }) + } + + // Record the admin action for the audit trail. + if (txHash) { + await supabase.from("admin_actions").insert({ + pool_id: poolId, + admin_address: callerAddress.toLowerCase(), + action_type: "set_supported_tokens", + metadata: { supportedTokens, tokenCount: supportedTokens.length }, + tx_hash: txHash || null, + }) + } + + return NextResponse.json({ success: true, supportedTokens }) + } catch (error) { + console.error("Failed to update supported tokens:", error) + return NextResponse.json( + { error: error instanceof Error ? error.message : "Unknown error" }, + { status: 500 } + ) + } +} diff --git a/frontend/components/group/deposit-token-picker.tsx b/frontend/components/group/deposit-token-picker.tsx new file mode 100644 index 0000000..6882506 --- /dev/null +++ b/frontend/components/group/deposit-token-picker.tsx @@ -0,0 +1,132 @@ +"use client" + +import { useEffect, useState } from "react" +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select" +import { getTokenByAddress, getTokenBalance } from "@/lib/token-utils" +import { validateTokenSelection } from "@/lib/deposit-token" +import { Skeleton } from "@/components/ui/skeleton" + +interface DepositTokenPickerProps { + /** Pool's supported-token identifiers ("native" / C…); [] = unrestricted. */ + supportedTokens: string[] + /** The pool's canonical settlement token address. */ + poolTokenAddress: string + symbol: string + decimals: number + /** Connected wallet address; null when disconnected (balances hidden). */ + walletAddress?: string | null + onTokenChange?: (token: { symbol: string; decimals: number; id: string }) => void +} + +/** + * Token picker shown on the deposit panel. Lists the pool's supported SEP-41 + * assets (falling back to the pool's settlement token when the allowlist is + * empty), shows each token's wallet balance using its own decimals, and + * validates the selection against the allowlist before a deposit. + */ +export function DepositTokenPicker({ + supportedTokens, + poolTokenAddress, + symbol, + decimals, + walletAddress, + onTokenChange, +}: DepositTokenPickerProps) { + const allowed = supportedTokens.length > 0 ? supportedTokens : [poolTokenAddress] + const unique = [...new Set(allowed.map((a) => a.toUpperCase()))] + + const [selectedId, setSelectedId] = useState( + poolTokenAddress === "native" ? "native" : poolTokenAddress.toUpperCase() + ) + const [balances, setBalances] = useState>({}) + const [loading, setLoading] = useState(false) + + const selection = getTokenByAddress(selectedId === "native" ? "native" : selectedId) + const shownSymbol = selection?.symbol ?? symbol + const shownDecimals = selection?.decimals ?? decimals + + useEffect(() => { + onTokenChange?.({ + symbol: shownSymbol, + decimals: shownDecimals, + id: selectedId, + }) + + }, [selectedId, shownSymbol, shownDecimals]) + + useEffect(() => { + if (!walletAddress) { + setBalances({}) + setLoading(false) + return + } + let cancelled = false + setLoading(true) + setBalances({}) + Promise.all( + unique.map(async (id) => { + const token = getTokenByAddress(id === "native" ? "native" : id) + if (!token) return { id, balance: 0 } + const balance = await getTokenBalance(walletAddress, token) + return { id, balance } + }) + ) + .then((results) => { + if (cancelled) return + const next: Record = {} + for (const r of results) next[r.id] = r.balance + setBalances(next) + }) + .catch(() => undefined) + .finally(() => { + if (!cancelled) setLoading(false) + }) + return () => { + cancelled = true + } + + }, [walletAddress, unique.join(",")]) + + const validationError = validateTokenSelection( + { address: selectedId, symbol: shownSymbol }, + allowed + ) + + return ( +
+ + {walletAddress && + (loading ? ( + + ) : ( +

+ {shownSymbol} balance:{" "} + {(balances[selectedId] ?? 0).toFixed(2)}{" "} + {shownSymbol} +

+ ))} + {validationError &&

{validationError}

} +
+ ) +} diff --git a/frontend/components/group/group-actions.tsx b/frontend/components/group/group-actions.tsx index 2103bc6..ff28b3d 100644 --- a/frontend/components/group/group-actions.tsx +++ b/frontend/components/group/group-actions.tsx @@ -18,6 +18,7 @@ import { UserPlus, Trash2, LogOut, + Coins, } from "lucide-react" import { useStellar } from "@/components/web3-provider" import { @@ -60,6 +61,8 @@ import { pendingTransactionLabel, type PendingTransactionType, } from "@/lib/pending-transactions" +import { SupportedTokensSettings } from "@/components/group/supported-tokens-settings" +import { DepositTokenPicker } from "@/components/group/deposit-token-picker" interface GroupActionsProps { groupId: string @@ -115,13 +118,17 @@ async function verifyAndLogDeposit( poolId: string, userAddress: string, txHash: string, - amount: string | null + amount: string | null, + token?: { symbol?: string; decimals?: number; amount?: string | null } ) { const payload = { poolId, userAddress, txHash, amount: amount ? parseFloat(amount) : null, + tokenSymbol: token?.symbol, + tokenDecimals: token?.decimals, + tokenAmount: token?.amount != null ? token.amount : amount, } for (let attempt = 0; attempt < 3; attempt++) { try { @@ -228,6 +235,7 @@ export function GroupActions({ const isAdmin = !!address && !!poolAdmin && address.toUpperCase() === poolAdmin.toUpperCase() const [depositAmount, setDepositAmount] = useState("") const [withdrawAmount, setWithdrawAmount] = useState("") + const [tokensOpen, setTokensOpen] = useState(false) // Pool metadata from Supabase const [poolData, setPoolData] = useState | null>(null) @@ -241,8 +249,18 @@ export function GroupActions({ // Token display metadata (persisted on the pool row; defaults to native XLM) const tokenSymbol: string = (poolData?.token_symbol as string) ?? "XLM" const tokenDecimals: number = (poolData?.token_decimals as number) ?? 7 + const supportedTokens: string[] = Array.isArray(poolData?.supported_tokens) + ? (poolData.supported_tokens as string[]) + : [] const toBaseUnits = (amount: number) => BigInt(Math.round(amount * 10 ** tokenDecimals)) + // Deposit token picker selection (per supported-token decimals/balance). + const [depositToken, setDepositToken] = useState<{ + symbol: string + decimals: number + id: string + } | null>(null) + // Wallet balance in the pool's deposit currency (for the deposit form) — // resolved via the token registry so both native XLM and USDC work. const [walletBalance, setWalletBalance] = useState(null) @@ -419,7 +437,11 @@ export function GroupActions({ if (txHash) { updateTxHash(txHash) - await verifyAndLogDeposit(groupId, address, txHash, depositAmount || null) + await verifyAndLogDeposit(groupId, address, txHash, depositAmount || null, { + symbol: depositToken?.symbol ?? tokenSymbol, + decimals: depositToken?.decimals ?? tokenDecimals, + amount: depositAmount || null, + }) void triggerPushNotification( groupId, "event_deposit", @@ -856,6 +878,14 @@ export function GroupActions({ {t("firstDepositHint")} )} +
+ + {isAdmin && ( + + )} )} @@ -1368,6 +1410,26 @@ export function GroupActions({ + { + if (!open) setTokensOpen(false) + }} + > + + + {t("manageTokens")} + {t("manageTokensDescription")} + + + + + ([]) + const [loaded, setLoaded] = useState(false) + const [customInput, setCustomInput] = useState("") + const [saving, setSaving] = useState(false) + const [persisted, setPersisted] = useState(false) + + useEffect(() => { + let cancelled = false + fetchSupportedTokens(contractAddress) + .then((list) => { + if (cancelled) return + // If the allowlist is empty, default to the pool's own settlement token. + setSelected(list.length > 0 ? list : [poolTokenAddress]) + }) + .finally(() => { + if (!cancelled) setLoaded(true) + }) + return () => { + cancelled = true + } + }, [contractAddress, poolTokenAddress]) + + const registryIds = SUPPORTED_TOKENS.map((t) => + t.contractAddress === "native" ? "native" : t.contractAddress + ) + const customIds = selected.filter((id) => !registryIds.includes(id.toUpperCase())) + + const toggle = (id: string) => { + setPersisted(false) + setSelected((prev) => + prev.map((x) => x.toUpperCase()).includes(id.toUpperCase()) + ? prev.filter((x) => x.toUpperCase() !== id.toUpperCase()) + : [...prev, id] + ) + } + + const addCustom = () => { + const id = customInput.trim().toUpperCase() + if (!id || !/^C[A-Z2-7]{55}$/.test(id)) { + toastManager.error("Enter a valid C… SEP-41 token contract id") + return + } + setPersisted(false) + setSelected((prev) => (prev.map((x) => x.toUpperCase()).includes(id) ? prev : [...prev, id])) + setCustomInput("") + } + + const handleSave = async () => { + if (!address || !isAdmin) { + toastManager.error("Only the pool admin can change supported tokens.") + return + } + setSaving(true) + try { + const txHash = await setSupportedTokens(selected) + const res = await fetch("/api/pools/tokens", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + poolId, + callerAddress: address, + supportedTokens: selected, + txHash, + }), + }) + const data = await res.json().catch(() => ({})) + if (!res.ok) throw new Error(data.error || "Failed to save") + setPersisted(true) + toastManager.success( + `Supported tokens updated (${selected.length}). ${isMultiToken(selected) ? "Pool now accepts multiple assets." : ""}` + ) + } catch (err) { + toastManager.error(err instanceof Error ? err.message : "Failed to update tokens") + } finally { + setSaving(false) + } + } + + if (!loaded) { + return ( +
+ Loading supported tokens… +
+ ) + } + + const busy = isLoading || saving + + return ( +
+
+ + {SUPPORTED_TOKENS.map((token) => { + const id = token.contractAddress === "native" ? "native" : token.contractAddress + const checked = selected.map((x) => x.toUpperCase()).includes(id.toUpperCase()) + return ( + + ) + })} +
+ + {customIds.length > 0 && ( +
+ + {customIds.map((id) => ( +
+ {id} + +
+ ))} +
+ )} + +
+ setCustomInput(e.target.value)} + placeholder="C… SEP-41 contract id" + disabled={busy} + className="font-mono text-xs" + /> + +
+ + + {!isAdmin && ( +

Only the pool admin can change this.

+ )} +
+ ) +} diff --git a/frontend/hooks/useJointSaveContracts.ts b/frontend/hooks/useJointSaveContracts.ts index 9e28509..4aa04f4 100644 --- a/frontend/hooks/useJointSaveContracts.ts +++ b/frontend/hooks/useJointSaveContracts.ts @@ -1410,6 +1410,22 @@ export async function fetchIsPaused(contractId: string): Promise { } } +/** + * Read a pool's supported-token allowlist via `get_supported_tokens`. + * Returns a list of token identifiers — each is "native" (XLM) or a C… + * SEP-41 SAC contract id. An empty list means unrestricted (the contract + * default, matching `set_supported_tokens` semantics). + */ +export async function fetchSupportedTokens(contractId: string): Promise { + try { + const val = await viewCall(contractId, "get_supported_tokens") + if (val.switch().name !== "scvVec") return [] + return (val.vec() || []).map(scValToString).filter(Boolean) + } catch { + return [] + } +} + /** Parse Vec> from factory view calls into contract addresses. */ function parseContractIdVec(val: xdr.ScVal): string[] { try { @@ -1616,6 +1632,56 @@ export function useUnpausePool(contractId: string) { return { unpause, isLoading } } +/** + * Admin-only: call the contract's `set_supported_tokens` (replace semantics). + * `tokens` is a list of token identifiers — "native" (XLM) or a C… SEP-41 SAC + * contract id — and an empty array clears the allowlist back to unrestricted. + * Uses `vecVal` so the list is passed as a Vec
to the contract. + */ +export function useSetSupportedTokens(contractId: string) { + const { kit, address } = useStellar() + const [isLoading, setIsLoading] = useState(false) + + const setSupportedTokens = async ( + tokens: string[], + sponsored = false + ): Promise => { + if (!kit || !address || !contractId) return + setIsLoading(true) + try { + const toAddress = (id: string) => (id === "native" ? NATIVE_SAC_ID : id.toUpperCase()) + return await buildAndSubmitDeposit( + kit, + (account) => + new TransactionBuilder(account, { + fee: BASE_FEE, + networkPassphrase: STELLAR_NETWORK_PASSPHRASE, + }) + .addOperation( + new Contract(normalizeId(contractId)).call( + "set_supported_tokens", + addressVal(address), + vecVal(tokens.map(toAddress)) + ) + ) + .setTimeout(TX_TIMEOUT) + .build(), + address, + sponsored, + { + address, + type: "set_supported_tokens", + poolId: contractId, + } + ) + } finally { + setIsLoading(false) + } + } + + return { setSupportedTokens, isLoading } +} + /** Read-only, no fees, no signing — safe to call for any address at any time. */ export async function fetchReputation(address: string): Promise { if (!REPUTATION_ID) return DEFAULT_REPUTATION diff --git a/frontend/lib/deposit-token.test.ts b/frontend/lib/deposit-token.test.ts new file mode 100644 index 0000000..c1444b7 --- /dev/null +++ b/frontend/lib/deposit-token.test.ts @@ -0,0 +1,168 @@ +// Unit tests for the pure multi-token deposit logic: human ↔ base-units +// conversion, token selection, balance checks and fee maths. +import { test } from "node:test" +import assert from "node:assert" +import { + baseUnitsToHuman, + checkSufficientBalance, + computeDepositFee, + humanToBaseUnits, + isMultiToken, + scaleForDecimals, + trimHumanAmount, + validateTokenSelection, + type DepositToken, +} from "./deposit-token" + +const USDC: DepositToken = { address: "CBUSDC1234", symbol: "USDC", decimals: 7 } +const XLM: DepositToken = { address: "native", symbol: "XLM", decimals: 7 } + +// ── scaleForDecimals ───────────────────────────────────────────────────────── + +test("scaleForDecimals - 10^decimals", () => { + assert.strictEqual(scaleForDecimals(0), 1n) + assert.strictEqual(scaleForDecimals(6), 1_000_000n) + assert.strictEqual(scaleForDecimals(7), 10_000_000n) +}) + +test("scaleForDecimals - rejects invalid precision", () => { + assert.throws(() => scaleForDecimals(-1)) + assert.throws(() => scaleForDecimals(19)) + assert.throws(() => scaleForDecimals(1.5)) +}) + +// ── humanToBaseUnits ───────────────────────────────────────────────────────── + +test("humanToBaseUnits - whole + fractional at 7 decimals", () => { + assert.strictEqual(humanToBaseUnits("0.07", 7), 700000n) + assert.strictEqual(humanToBaseUnits("50", 7), 500_000_000n) + assert.strictEqual(humanToBaseUnits("1.2345678", 7), 12_345_678n) +}) + +test("humanToBaseUnits - exact, no float noise", () => { + // Would be 700000.000000005 with naive float math. + assert.strictEqual(humanToBaseUnits("0.07", 7), 700000n) + assert.strictEqual(humanToBaseUnits("0.1", 7), 1_000_000n) +}) + +test("humanToBaseUnits - accepts a trailing zero and whitespace", () => { + assert.strictEqual(humanToBaseUnits(" 10.5 ", 7), 105_000_000n) + assert.strictEqual(humanToBaseUnits("7.0000000", 7), 70_000_000n) +}) + +test("humanToBaseUnits - custom decimals", () => { + assert.strictEqual(humanToBaseUnits("1.25", 6), 1_250_000n) + assert.strictEqual(humanToBaseUnits("1", 18), 10n ** 18n) +}) + +test("humanToBaseUnits - rejects more fractional digits than the asset", () => { + assert.throws(() => humanToBaseUnits("0.12345678", 7), /exceeds 7 decimal places/) + assert.throws(() => humanToBaseUnits("1.00000001", 7), /exceeds 7 decimal places/) +}) + +test("humanToBaseUnits - rejects malformed input", () => { + assert.throws(() => humanToBaseUnits("", 7)) + assert.throws(() => humanToBaseUnits("abc", 7)) + assert.throws(() => humanToBaseUnits("1.2.3", 7)) + assert.throws(() => humanToBaseUnits("1e3", 7)) +}) + +// ── baseUnitsToHuman ───────────────────────────────────────────────────────── + +test("baseUnitsToHuman - round trip for 7-decimal asset", () => { + assert.strictEqual(baseUnitsToHuman(700000n, 7), "0.07") + assert.strictEqual(baseUnitsToHuman(500_000_000n, 7), "50") + assert.strictEqual(baseUnitsToHuman(12_345_678n, 7), "1.2345678") +}) + +test("baseUnitsToHuman - negligible trailing zeros dropped", () => { + assert.strictEqual(baseUnitsToHuman(50_000_000n, 7), "5") + assert.strictEqual(baseUnitsToHuman(105_000_000n, 7), "10.5") +}) + +// ── trimHumanAmount ────────────────────────────────────────────────────────── + +test("trimHumanAmount - strips trailing zeros", () => { + assert.strictEqual(trimHumanAmount("50.0000000"), "50") + assert.strictEqual(trimHumanAmount("10.50"), "10.5") + assert.strictEqual(trimHumanAmount("1.00"), "1") +}) + +test("trimHumanAmount - leaves no-decimal strings alone", () => { + assert.strictEqual(trimHumanAmount("50"), "50") +}) + +// ── validateTokenSelection ─────────────────────────────────────────────────── + +test("validateTokenSelection - unrestricted when supported list empty", () => { + assert.strictEqual(validateTokenSelection(USDC, []), null) + assert.strictEqual(validateTokenSelection(XLM, []), null) +}) + +test("validateTokenSelection - accepts a token in the supported set", () => { + assert.strictEqual(validateTokenSelection(USDC, [USDC.address]), null) + assert.strictEqual(validateTokenSelection(XLM, ["native"]), null) +}) + +test("validateTokenSelection - rejects a token outside the supported set", () => { + const err = validateTokenSelection(USDC, ["native"]) + assert.ok(err && err.includes("not an accepted deposit token")) + assert.ok(err && err.includes("USDC")) +}) + +test("validateTokenSelection - rejects when no address chosen", () => { + assert.ok(validateTokenSelection({ address: "", symbol: "" }, [])) +}) + +// ── checkSufficientBalance ─────────────────────────────────────────────────── + +test("checkSufficientBalance - passes with enough balance", () => { + assert.strictEqual(checkSufficientBalance("0.07", 7, 700000n), null) + assert.strictEqual(checkSufficientBalance("1", 7, 10_000_000n), null) +}) + +test("checkSufficientBalance - fails with insufficient balance", () => { + const err = checkSufficientBalance("1", 7, 999n) + assert.ok(err && err.includes("Insufficient balance")) +}) + +test("checkSufficientBalance - rejects zero and invalid amounts", () => { + assert.ok(checkSufficientBalance("0", 7, 10n)) + assert.ok(checkSufficientBalance("abc", 7, 10n)) +}) + +// ── computeDepositFee ──────────────────────────────────────────────────────── + +test("computeDepositFee - treasury+relayer bps in the settlement unit", () => { + // 100 XLM @ 7 decimals, 50 bps (0.5%) → 0.5 fee, 99.5 net. + const r = computeDepositFee("100", 7, 50, 0) + assert.strictEqual(r.fee, 5_000_000n) + assert.strictEqual(r.feeHuman, "0.5") + assert.strictEqual(r.net, 995_000_000n) + assert.strictEqual(r.netHuman, "99.5") +}) + +test("computeDepositFee - zero bps means no fee", () => { + const r = computeDepositFee("50", 6, 0, 0) + assert.strictEqual(r.fee, 0n) + assert.strictEqual(r.net, 50_000_000n) +}) + +test("computeDepositFee - divides bps between treasury and relayer", () => { + const r = computeDepositFee("1000", 7, 10, 20) // 30 bps = 0.3% + assert.strictEqual(r.fee, 30_000_000n) + assert.strictEqual(r.feeHuman, "3") + assert.strictEqual(r.netHuman, "997") +}) + +// ── isMultiToken ───────────────────────────────────────────────────────────── + +test("isMultiToken - 2+ distinct addresses", () => { + assert.strictEqual(isMultiToken([XLM.address, USDC.address]), true) + assert.strictEqual(isMultiToken(["native", USDC.address]), true) +}) + +test("isMultiToken - single or empty list is not multi-token", () => { + assert.strictEqual(isMultiToken([XLM.address]), false) + assert.strictEqual(isMultiToken([]), false) +}) diff --git a/frontend/lib/deposit-token.ts b/frontend/lib/deposit-token.ts new file mode 100644 index 0000000..9c4bf0c --- /dev/null +++ b/frontend/lib/deposit-token.ts @@ -0,0 +1,157 @@ +/** + * Pure logic for multi-token (SEP-41) deposits. No wallet, no network, no + * React — mirrors `deposit-calendar.ts` / `batch-deposit.ts` so the amount, + * decimals, balance and fee maths are unit-testable in isolation. + * + * Stellar SEP-41 assets carry their own `decimals` (native XLM is 7, USDC SAC + * is 7, but a custom contract could be e.g. 6 or 18). This module keeps every + * conversion in exact integer (base-units / stroops) arithmetic so deposits + * recorded by the API and passed to contracts never lose floating-point + * precision. + */ + +// ── Core conversions ───────────────────────────────────────────────────────── + +/** + * Number of base units per 1 human unit for an asset with `decimals` digits. + */ +export function scaleForDecimals(decimals: number): bigint { + if (!Number.isInteger(decimals) || decimals < 0 || decimals > 18) { + throw new Error(`invalid decimals: ${decimals}`) + } + return 10n ** BigInt(decimals) +} + +/** + * Convert a human-readable amount (as a string, e.g. "0.07") into exact + * base-units (stroops) for the given asset precision. Rejects more fractional + * digits than the asset supports (e.g. 8 decimal places for a 7-decimal + * asset) to avoid silently truncating user funds. + */ +export function humanToBaseUnits(amount: string | number, decimals: number): bigint { + const raw = typeof amount === "string" ? amount.trim() : String(amount) + if (!raw || !/^-?\d+(\.\d+)?$/.test(raw)) { + throw new Error(`invalid amount: ${raw}`) + } + const negative = raw.startsWith("-") + const unsigned = negative ? raw.slice(1) : raw + const [whole, frac = ""] = unsigned.split(".") + if (frac.length > decimals) { + throw new Error( + `amount ${raw} exceeds ${decimals} decimal places for this asset (got ${frac.length})` + ) + } + const padded = frac.padEnd(decimals, "0") + const units = BigInt(whole === "" ? "0" : whole) * scaleForDecimals(decimals) + BigInt(padded) + return negative ? -units : units +} + +/** + * Convert exact base-units back to a human-readable string for the given + * asset precision (e.g. `700000n @ 7` → "0.07"). Exact — no float rounding. + */ +export function baseUnitsToHuman(units: bigint, decimals: number): string { + const negative = units < 0n + const abs = negative ? -units : units + const scale = scaleForDecimals(decimals) + const whole = abs / scale + const frac = (abs % scale).toString().padStart(decimals, "0").replace(/0+$/, "") + const sign = negative ? "-" : "" + return frac ? `${sign}${whole}.${frac}` : `${sign}${whole}` +} + +/** + * Strip trailing zeros (and a trailing decimal point) from a human amount + * string for display, e.g. "50.0000000" → "50". Never changes the value. + */ +export function trimHumanAmount(amount: string): string { + if (!amount.includes(".")) return amount + return amount.replace(/\.?0+$/, "") +} + +// ── Token selection ────────────────────────────────────────────────────────── + +export interface DepositToken { + /** "native" for XLM, or a C… SEP-41 contract id. */ + address: string + symbol: string + name?: string + decimals: number +} + +/** + * Validate that `token` is allowed for a pool whose supported set is + * `supported`. An empty supported set (contract default) means unrestricted. + * Returns an error message string, or `null` when the token is acceptable. + */ +export function validateTokenSelection( + token: Pick, + supported: string[] +): string | null { + if (!token.address) return "Please choose a deposit token." + if (supported.length === 0) return null + const normalized = supported.map((a) => a.toUpperCase()) + if (normalized.includes(token.address.toUpperCase())) return null + if (normalized.includes("native") && token.address === "native") return null + return `${token.symbol} is not an accepted deposit token for this pool (accepted: ${supported.join(", ")}).` +} + +// ── Balance checking ───────────────────────────────────────────────────────── + +/** + * Check a wallet's balance (in base units) is sufficient for a human amount + * in the given decimals. Returns an error message string, or `null` if the + * balance covers the amount. + */ +export function checkSufficientBalance( + humanAmount: string, + decimals: number, + walletBalanceBaseUnits: bigint +): string | null { + let wanted: bigint + try { + wanted = humanToBaseUnits(humanAmount, decimals) + } catch { + return "Enter a valid amount." + } + if (wanted <= 0n) return "Amount must be greater than zero." + if (walletBalanceBaseUnits < wanted) return "Insufficient balance for the selected token." + return null +} + +// ── Fees ───────────────────────────────────────────────────────────────────── + +/** + * Compute the treasury + relayer fee (in basis points) taken from a deposit. + * Returns the fee as base units (exact integer) and the net credited amount, + * both in the settlement token's own unit. + */ +export function computeDepositFee( + grossHuman: string, + decimals: number, + treasuryFeeBps: number, + relayerFeeBps: number +): { fee: bigint; feeHuman: string; net: bigint; netHuman: string } { + const gross = humanToBaseUnits(grossHuman, decimals) + const grossFeeBps = treasuryFeeBps + relayerFeeBps + // fee = round(gross * bps / 10000), exact with factor 10000 + const fee = (gross * BigInt(grossFeeBps) + 5000n) / 10000n + const net = gross - fee + return { + fee, + feeHuman: baseUnitsToHuman(fee, decimals), + net, + netHuman: baseUnitsToHuman(net, decimals), + } +} + +// ── Supported-token helpers ────────────────────────────────────────────────── + +/** + * True when the supported-token list has 2+ distinct entries (i.e. the pool + * is genuinely multi-token, not just the single settlement token). + */ +export function isMultiToken(supported: string[]): boolean { + const unique = new Set(supported.map((a) => a.toUpperCase())) + return unique.size >= 2 +} diff --git a/frontend/lib/pending-transactions.test.ts b/frontend/lib/pending-transactions.test.ts index a6fbde4..40eb284 100644 --- a/frontend/lib/pending-transactions.test.ts +++ b/frontend/lib/pending-transactions.test.ts @@ -4,6 +4,7 @@ import { addPendingTransactionRecord, reconcilePendingTransactions, findRecentPendingTransaction, + pendingTransactionLabel, pendingTransactionStorageKey, readPendingTransactionRecords, removePendingTransactionRecord, @@ -155,3 +156,20 @@ test("reconcilePendingTransactions keeps recent not-found records but removes st assert.deepEqual(outcomes, [{ record: stale, outcome: "dropped" }]) assert.deepEqual(readPendingTransactionRecords(address, storage), [recent]) }) + +test("set_supported_tokens is a recognized pending transaction type", () => { + const storage = createStorage() + const address = "gabcdef" + addPendingTransactionRecord( + address, + { + hash: "hash-tokens", + type: "set_supported_tokens", + poolId: "pool-1", + submittedAt: Date.now(), + }, + storage + ) + assert.strictEqual(pendingTransactionLabel("set_supported_tokens"), "token settings update") + assert.strictEqual(readPendingTransactionRecords(address, storage).length, 1) +}) diff --git a/frontend/lib/pending-transactions.ts b/frontend/lib/pending-transactions.ts index 757aeaf..2d55bb2 100644 --- a/frontend/lib/pending-transactions.ts +++ b/frontend/lib/pending-transactions.ts @@ -2,7 +2,8 @@ import { RECENT_DUPLICATE_WINDOW_MS, DROPPED_TX_WINDOW_MS } from "@/lib/constants" -export type PendingTransactionType = "deposit" | "withdraw" | "trigger_payout" +export type PendingTransactionType = + "deposit" | "withdraw" | "trigger_payout" | "set_supported_tokens" export interface PendingTransactionRecord { hash: string @@ -46,7 +47,12 @@ function normalizeKeyPart(value: string): string { } function isPendingTransactionType(value: string): value is PendingTransactionType { - return value === "deposit" || value === "withdraw" || value === "trigger_payout" + return ( + value === "deposit" || + value === "withdraw" || + value === "trigger_payout" || + value === "set_supported_tokens" + ) } export function pendingTransactionStorageKey(address: string): string { @@ -152,6 +158,8 @@ export function pendingTransactionLabel(type: PendingTransactionType): string { return "withdrawal" case "trigger_payout": return "payout trigger" + case "set_supported_tokens": + return "token settings update" } } @@ -163,6 +171,8 @@ export function pendingTransactionSuccessMessage(type: PendingTransactionType): return "Your withdrawal from earlier completed successfully." case "trigger_payout": return "Your payout trigger from earlier completed successfully." + case "set_supported_tokens": + return "Your token settings update from earlier completed successfully." } } diff --git a/frontend/lib/supabase.ts b/frontend/lib/supabase.ts index d9232e0..e438edc 100644 --- a/frontend/lib/supabase.ts +++ b/frontend/lib/supabase.ts @@ -31,6 +31,7 @@ export type Database = { token_address: string token_symbol: string token_decimals: number + supported_tokens: string[] total_saved: number target_amount: number | null progress: number @@ -57,6 +58,7 @@ export type Database = { token_address: string token_symbol?: string token_decimals?: number + supported_tokens?: string[] total_saved?: number target_amount?: number | null progress?: number @@ -81,6 +83,7 @@ export type Database = { token_address?: string token_symbol?: string token_decimals?: number + supported_tokens?: string[] total_saved?: number target_amount?: number | null progress?: number diff --git a/frontend/lib/tx-retry.ts b/frontend/lib/tx-retry.ts index 07279cd..2d107fd 100644 --- a/frontend/lib/tx-retry.ts +++ b/frontend/lib/tx-retry.ts @@ -41,7 +41,14 @@ import { // ── Pending transaction tracker types ──────────────────────────────────────── export type PendingTxType = - "deposit" | "withdraw" | "payout" | "emergency_withdraw" | "pause" | "join" | "create" + | "deposit" + | "withdraw" + | "payout" + | "emergency_withdraw" + | "pause" + | "join" + | "create" + | "set_supported_tokens" export interface PendingTransaction { hash: string @@ -616,5 +623,6 @@ export async function submitWithRetry(options: TxRetryOptions): Promise