Skip to content
Merged
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
61 changes: 56 additions & 5 deletions frontend/app/api/pools/deposit/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,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) {
Expand All @@ -24,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(
Expand Down Expand Up @@ -72,6 +84,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")
Expand All @@ -83,15 +113,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",
},
])

Expand Down
84 changes: 84 additions & 0 deletions frontend/app/api/pools/tokens/route.ts
Original file line number Diff line number Diff line change
@@ -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 }
)
}
}
132 changes: 132 additions & 0 deletions frontend/components/group/deposit-token-picker.tsx
Original file line number Diff line number Diff line change
@@ -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<string>(
poolTokenAddress === "native" ? "native" : poolTokenAddress.toUpperCase()
)
const [balances, setBalances] = useState<Record<string, number>>({})
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<string, number> = {}
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 (
<div className="space-y-1.5">
<Select value={selectedId} onValueChange={setSelectedId}>
<SelectTrigger className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
{unique.map((id) => {
const token = getTokenByAddress(id === "native" ? "native" : id)
const label = token ? `${token.icon} ${token.symbol}` : id
return (
<SelectItem key={id} value={id}>
{label}
</SelectItem>
)
})}
</SelectContent>
</Select>
{walletAddress &&
(loading ? (
<Skeleton className="h-3 w-28" />
) : (
<p className="text-xs text-muted-foreground">
{shownSymbol} balance:{" "}
<span className="font-medium">{(balances[selectedId] ?? 0).toFixed(2)}</span>{" "}
{shownSymbol}
</p>
))}
{validationError && <p className="text-xs text-destructive">{validationError}</p>}
</div>
)
}
Loading
Loading