diff --git a/docs/INTEGRATION_GUIDE.md b/docs/INTEGRATION_GUIDE.md index 2b54338..a8aa600 100644 --- a/docs/INTEGRATION_GUIDE.md +++ b/docs/INTEGRATION_GUIDE.md @@ -257,7 +257,10 @@ A stream looks like this. Amounts are strings in the token's smallest unit. "remaining": "957223", "refundable": "1", "currentBlock": 9053473, - "progress": 100 + "progress": 100, + "tokenDecimals": 6, + "depositFormatted": "1.20", + "claimableFormatted": "0.957222" } ``` @@ -269,8 +272,10 @@ A stream looks like this. Amounts are strings in the token's smallest unit. | `remaining` | Still held by the contract for this stream | | `refundable` | What would return to the sender on cancel now | | `progress` | Percent of the stream's duration elapsed | +| `tokenDecimals` | The token's decimals, read from the token contract | +| `depositFormatted`, `claimableFormatted` | Human-readable amounts using `tokenDecimals`. `null` if decimals could not be read | -Convert amounts yourself using the token's decimals from the table above. Do not rely on the `…Formatted` fields. +For your own calculations, use the raw amounts with `tokenDecimals`. Errors return JSON with an `error` field: `400` for a malformed ID or address, `404` when not found, and `502` when the upstream blockchain API is unavailable. diff --git a/frontend/src/app/api/daos/[admin]/route.ts b/frontend/src/app/api/daos/[admin]/route.ts index 8c540ec..270b0ce 100644 --- a/frontend/src/app/api/daos/[admin]/route.ts +++ b/frontend/src/app/api/daos/[admin]/route.ts @@ -1,6 +1,5 @@ import { getDao, - formatTokenAmount, jsonResponse, errorResponse, STACKS_ADDRESS_RE, @@ -24,7 +23,20 @@ export async function GET( } return jsonResponse({ ...dao, - totalDepositedFormatted: formatTokenAmount(dao.totalDeposited), + // No `totalDepositedFormatted`. + // + // `total-deposited` is a single uint that stream-factory increments by + // the raw deposit of every tracked stream, regardless of which token that + // stream uses. Summing 1e8-scale sBTC raw units with 1e6-scale USDA raw + // units yields a number that is not an amount of anything. + // + // This route previously ran it through formatTokenAmount() with the + // default 8 decimals, publishing a confidently wrong figure. There is no + // correct single-token rendering without a per-token breakdown, and + // `daos` has no token key to derive one from — that needs a contract + // change, not a display fix. Until then, consumers must show the raw + // integer with a label that says what it is, or omit it. + totalDepositedIsCrossTokenAggregate: true, }); } catch (err) { return errorResponse(err); diff --git a/frontend/src/app/api/streams/[id]/route.ts b/frontend/src/app/api/streams/[id]/route.ts index c421f54..46e8117 100644 --- a/frontend/src/app/api/streams/[id]/route.ts +++ b/frontend/src/app/api/streams/[id]/route.ts @@ -5,6 +5,9 @@ import { getRemainingBalance, getRefundableAmount, getCurrentBlockHeight, + getTokenDecimals, + getTokenSymbol, + tokenDisplayLabel, getStreamStatusLabel, getStreamProgress, formatTokenAmount, @@ -32,13 +35,15 @@ export async function GET( return jsonResponse({ error: "Stream not found" }, 404); } - const [claimable, streamed, remaining, refundable, currentBlock] = + const [claimable, streamed, remaining, refundable, currentBlock, decimals, symbol] = await Promise.all([ getClaimableBalance(id), getStreamedAmount(id), getRemainingBalance(id), getRefundableAmount(id), getCurrentBlockHeight(), + getTokenDecimals(stream.token), + getTokenSymbol(stream.token), ]); const progress = getStreamProgress( @@ -58,9 +63,20 @@ export async function GET( refundable, currentBlock, progress: Math.round(progress * 100) / 100, - depositFormatted: formatTokenAmount(stream.depositAmount), + tokenDecimals: decimals, + // Always a string, and always this token's own label. A client that + // receives a null label cannot tell "unavailable" from "forgot", so the + // contract name is the honest fallback. + tokenLabel: tokenDisplayLabel(stream.token, symbol), + tokenSymbol: symbol, + depositFormatted: + decimals !== null + ? formatTokenAmount(stream.depositAmount, decimals) + : null, claimableFormatted: - claimable !== null ? formatTokenAmount(claimable) : null, + decimals !== null && claimable !== null + ? formatTokenAmount(claimable, decimals) + : null, }); } catch (err) { return errorResponse(err); diff --git a/frontend/src/app/dashboard/analytics/page.tsx b/frontend/src/app/dashboard/analytics/page.tsx index 213142e..23cc927 100644 --- a/frontend/src/app/dashboard/analytics/page.tsx +++ b/frontend/src/app/dashboard/analytics/page.tsx @@ -9,7 +9,8 @@ import { useSenderStreams } from "@/hooks/use-streams"; import { useBlockHeight } from "@/hooks/use-block-height"; import { useWalletStore } from "@/stores/wallet-store"; import { formatTokenAmount, getStreamProgress, pickPrimaryToken } from "@/lib/utils"; -import { STREAM_STATUS, BLOCKS_PER_DAY, getTokenConfigByContractId } from "@/lib/constants"; +import { STREAM_STATUS, BLOCKS_PER_DAY, unresolvableTokenLabel } from "@/lib/constants"; +import { useTokenMetadata, useTokensMetadata } from "@/hooks/use-token-metadata"; import { useAppStore } from "@/stores/app-store"; import { BarChart3, Zap, TrendingDown, Clock, Coins } from "lucide-react"; @@ -19,6 +20,14 @@ export default function AnalyticsPage() { useBlockHeight(); const blockHeight = useAppStore((s) => s.currentBlockHeight); + const { primaryTokenId, primaryStreams, otherCount } = pickPrimaryToken(streams); + const { token: primaryToken } = useTokenMetadata(primaryTokenId ?? ""); + // The per-stream table can span tokens, so resolve each distinct one. + const tokensById = useTokensMetadata(streams.map((s) => s.token)); + const primaryDecimals = primaryToken?.decimals; + const primarySymbol = + primaryToken?.symbol ?? (primaryTokenId ? unresolvableTokenLabel(primaryTokenId) : ""); + if (!isConnected) { return ( s.status === STREAM_STATUS.ACTIVE); - const { token: primaryToken, primaryStreams, otherCount } = pickPrimaryToken(streams); const totalDeposited = primaryStreams.reduce((a, s) => a + s.depositAmount, 0n); const totalWithdrawn = primaryStreams.reduce((a, s) => a + s.withdrawnAmount, 0n); const totalRemaining = totalDeposited - totalWithdrawn; @@ -41,7 +49,10 @@ export default function AnalyticsPage() { const burnRatePerBlockRaw = primaryStreams .filter((s) => s.status === STREAM_STATUS.ACTIVE) .reduce((a, s) => a + Number(s.ratePerBlock) / 1e12, 0); - const burnRatePerDay = burnRatePerBlockRaw * BLOCKS_PER_DAY / Math.pow(10, primaryToken.decimals); + const burnRatePerDay = + primaryDecimals === undefined + ? 0 + : (burnRatePerBlockRaw * BLOCKS_PER_DAY) / Math.pow(10, primaryDecimals); // Funds utilization const utilization = @@ -62,18 +73,22 @@ export default function AnalyticsPage() {
0 - ? `${primaryToken.symbol} (+${otherCount} in other tokens)` - : `${primaryToken.symbol} in streams` + ? `${primarySymbol} (+${otherCount} in other tokens)` + : `${primarySymbol} in streams` } icon={} /> } trend="down" /> @@ -131,7 +146,7 @@ export default function AnalyticsPage() { blockHeight, s.totalPausedDuration ); - const tokenConfig = getTokenConfigByContractId(s.token); + const rowToken = tokensById[s.token]; return ( @@ -141,13 +156,13 @@ export default function AnalyticsPage() { {s.recipient.slice(0, 8)}... - {tokenConfig.symbol} + {rowToken?.symbol ?? unresolvableTokenLabel(s.token)} - {formatTokenAmount(s.depositAmount, tokenConfig.decimals)} + {rowToken ? formatTokenAmount(s.depositAmount, rowToken.decimals) : "—"} - {formatTokenAmount(s.withdrawnAmount, tokenConfig.decimals)} + {rowToken ? formatTokenAmount(s.withdrawnAmount, rowToken.decimals) : "—"} diff --git a/frontend/src/app/dashboard/create/page.tsx b/frontend/src/app/dashboard/create/page.tsx index 5957b4a..95cb909 100644 --- a/frontend/src/app/dashboard/create/page.tsx +++ b/frontend/src/app/dashboard/create/page.tsx @@ -16,6 +16,8 @@ import { DURATION_UNITS, EXPLORER_BASE, BLOCKS_PER_HOUR, + toRawAmount, + fromRawAmount, type DurationUnit, type TokenConfig, } from "@/lib/constants"; @@ -36,10 +38,7 @@ export default function CreateStreamPage() { const [selectedToken, setSelectedToken] = useState(DEFAULT_TOKEN); const [errors, setErrors] = useState>({}); - const { balance, isLoading: isBalanceLoading } = useTokenBalance( - selectedToken.contractId, - selectedToken.ftName, - ); + const { balance, isLoading: isBalanceLoading } = useTokenBalance(selectedToken); if (!isConnected) { return ( @@ -53,20 +52,29 @@ export default function CreateStreamPage() { const unitConfig = DURATION_UNITS.find((u) => u.value === durationUnit)!; const durationBlocks = Math.max(1, Math.round(parseFloat(durationValue || "0") * unitConfig.blocksPerUnit)); - // Use the selected token's decimals for raw unit conversion - const tokenMultiplier = Math.pow(10, selectedToken.decimals); - const amountRaw = Math.round(parseFloat(amount || "0") * tokenMultiplier); - const ratePerBlock = durationBlocks > 0 ? amountRaw / durationBlocks : 0; + // Exact string→bigint conversion. parseFloat(amount) * 10**decimals loses + // precision above 2^53 raw units and silently rounds, so the deposited + // amount can differ from what the user typed. Returns null on a malformed + // amount, which validate() reports rather than quietly sending 0. + const amountRaw = toRawAmount(amount || "0", selectedToken.decimals); + // ratePerBlock is display-only and intentionally stays in float, since the + // on-chain stream rate is derived integer division in the contract, not the + // raw total divided by blocks. + const ratePerBlock = durationBlocks > 0 && amountRaw ? Number(amountRaw) / durationBlocks : 0; + const displayPerBlock = BigInt(Math.max(1, Math.floor(ratePerBlock))); function validate(): boolean { const errs: Record = {}; if (!recipient || !recipient.startsWith("S")) errs.recipient = "Enter a valid Stacks address"; if (recipient === address) errs.recipient = "Cannot stream to yourself"; if (!amount || parseFloat(amount) <= 0) errs.amount = "Enter a positive amount"; + else if (amountRaw === null) { + errs.amount = "Enter a valid amount (digits and up to one decimal point)"; + } // Pre-flight balance check. The on-chain ft-transfer? inside create-stream // returns (err u1) if the wallet is short — catch it here so users don't // burn gas on a doomed tx. - else if (BigInt(amountRaw) > balance) { + else if (amountRaw > balance) { errs.amount = `Insufficient ${selectedToken.symbol} balance. You have ${formatTokenAmount(balance, selectedToken.decimals)} ${selectedToken.symbol}.`; } if (!durationValue || parseFloat(durationValue) <= 0) errs.duration = "Enter a positive duration"; @@ -77,7 +85,7 @@ export default function CreateStreamPage() { async function handleSubmit(e: React.FormEvent) { e.preventDefault(); - if (!validate() || !address) return; + if (!validate() || !address || amountRaw === null) return; // Fetch the latest block height right before submitting to avoid stale data. // Nakamoto Stacks blocks tick every ~5s, so a small buffer is not enough — @@ -96,8 +104,8 @@ export default function CreateStreamPage() { const txOptions = buildCreateStreamTx({ recipient, tokenContract: selectedToken.contractId, - ftName: selectedToken.ftName, - depositAmount: BigInt(amountRaw), + token: selectedToken, + depositAmount: amountRaw, startBlock: latestBlock + 120, durationBlocks, memo: memo || undefined, @@ -169,13 +177,13 @@ export default function CreateStreamPage() { setAmount(e.target.value)} error={errors.amount} - hint={amountRaw > 0 ? `${amountRaw.toLocaleString()} raw units (${selectedToken.decimals} decimals)` : undefined} + hint={amountRaw !== null && amountRaw > 0n ? `${amountRaw.toLocaleString()} raw units (${selectedToken.decimals} decimals)` : undefined} disabled={isSubmitting} /> {address && ( @@ -189,7 +197,7 @@ export default function CreateStreamPage() { {balance > 0n && !isSubmitting && (
+ {!token && !isTokenLoading && ( +
+ +

+ This stream's token is not a token StackStream can verify. It may + not follow SIP-010, or its asset name could not be determined. + Claiming is unavailable because the required post-condition cannot + be built safely. +

+
+ )} +
setAmount(e.target.value)} error={error} - hint={amountRaw > 0n ? `${amountRaw.toLocaleString()} raw units` : undefined} + hint={ + amountRaw !== null && amountRaw > 0n + ? `${amountRaw.toLocaleString()} raw units` + : undefined + } + disabled={!token} /> - + {token && ( + + )}
-
diff --git a/frontend/src/components/stream/stream-card.tsx b/frontend/src/components/stream/stream-card.tsx index 69b5f21..a913630 100644 --- a/frontend/src/components/stream/stream-card.tsx +++ b/frontend/src/components/stream/stream-card.tsx @@ -11,8 +11,9 @@ import { getStreamStatusLabel, formatStreamWindow, } from "@/lib/utils"; -import { STREAM_STATUS, getTokenConfigByContractId } from "@/lib/constants"; +import { STREAM_STATUS, unresolvableTokenLabel } from "@/lib/constants"; import { useAppStore } from "@/stores/app-store"; +import { useTokenMetadata } from "@/hooks/use-token-metadata"; import { useStreamProgress } from "@/hooks/use-stream-progress"; import type { StreamData } from "@/lib/stacks"; import { Pause, Play, XCircle, ArrowUpCircle, Download, TimerOff } from "lucide-react"; @@ -50,7 +51,12 @@ export function StreamCard({ actionLoading, }: StreamCardProps) { const blockHeight = useAppStore((s) => s.currentBlockHeight); - const tokenConfig = getTokenConfigByContractId(stream.token); + // Resolve the stream's own token. getTokenConfigByContractId previously + // returned DEFAULT_TOKEN for any uncurated contract, so a USDA stream was + // labelled "sBTC" and its amounts divided by 1e8 instead of 1e6. + const { token: tokenConfig, isLoading: isTokenLoading } = useTokenMetadata(stream.token); + const decimals = tokenConfig?.decimals; + const symbol = tokenConfig?.symbol ?? unresolvableTokenLabel(stream.token); const isActive = stream.status === STREAM_STATUS.ACTIVE; const isPaused = stream.status === STREAM_STATUS.PAUSED; const isTerminal = @@ -115,35 +121,46 @@ export function StreamCard({ {perspective === "recipient" && !isTerminal ? (

Claimable Balance

- + {decimals === undefined ? ( + // Show the token identity but not a number. Rendering the balance + // with a guessed scale is how a 1.2 USDA claimable showed as 0.012. +

+ {isTokenLoading ? "Loading token…" : `Unavailable (${symbol})`} +

+ ) : ( + + )}
) : (

Deposited

- {formatTokenAmount(stream.depositAmount, tokenConfig.decimals)} {tokenConfig.symbol} + {decimals === undefined ? "—" : formatTokenAmount(stream.depositAmount, decimals)}{" "} + {symbol}

Streamed

- {formatTokenAmount(streamed || stream.withdrawnAmount, tokenConfig.decimals)} {tokenConfig.symbol} + {decimals === undefined ? "—" : formatTokenAmount(streamed || stream.withdrawnAmount, decimals)}{" "} + {symbol}

Withdrawn

- {formatTokenAmount(stream.withdrawnAmount, tokenConfig.decimals)} {tokenConfig.symbol} + {decimals === undefined ? "—" : formatTokenAmount(stream.withdrawnAmount, decimals)}{" "} + {symbol}

diff --git a/frontend/src/components/stream/top-up-dialog.tsx b/frontend/src/components/stream/top-up-dialog.tsx index 9d77167..e6b8213 100644 --- a/frontend/src/components/stream/top-up-dialog.tsx +++ b/frontend/src/components/stream/top-up-dialog.tsx @@ -6,12 +6,13 @@ import { Input } from "@/components/ui/input"; import { Button } from "@/components/ui/button"; import { useStacksTx } from "@/hooks/use-stacks-tx"; import { useTokenBalance } from "@/hooks/use-token-balance"; +import { useTokenMetadata } from "@/hooks/use-token-metadata"; import { buildTopUpStreamTx, type StreamData } from "@/lib/stacks"; import { useWalletStore } from "@/stores/wallet-store"; -import { SUPPORTED_TOKENS, DEFAULT_TOKEN } from "@/lib/constants"; import { formatTokenAmount, formatTxError } from "@/lib/utils"; +import { toRawAmount, fromRawAmount, unresolvableTokenLabel } from "@/lib/constants"; import { toast } from "sonner"; -import { ArrowUpCircle } from "lucide-react"; +import { ArrowUpCircle, AlertTriangle } from "lucide-react"; interface TopUpDialogProps { open: boolean; @@ -33,22 +34,36 @@ export function TopUpDialog({ const [amount, setAmount] = useState(""); const [error, setError] = useState(""); - const tokenConfig = SUPPORTED_TOKENS.find((t) => t.contractId === stream.token) ?? DEFAULT_TOKEN; - const tokenMultiplier = Math.pow(10, tokenConfig.decimals); - const amountRaw = Math.round(parseFloat(amount || "0") * tokenMultiplier); - const { balance, isLoading: isBalanceLoading } = useTokenBalance( - tokenConfig.contractId, - tokenConfig.ftName, - ); + // Resolve the *stream's* token from the chain. The previous code looked the + // contract up in SUPPORTED_TOKENS and fell back to DEFAULT_TOKEN, so topping + // up a USDA stream built a post-condition naming "sbtc-token": the deposit + // moved, but the wallet rejected the tx, and topping up any unlisted token + // could not work at all. + const { token, isLoading: isTokenLoading } = useTokenMetadata(stream.token); + const { balance, isLoading: isBalanceLoading } = useTokenBalance(token); + + const amountRaw = toRawAmount(amount || "0", token?.decimals ?? 0); + const label = token?.symbol ?? unresolvableTokenLabel(stream.token); function validate(): boolean { + if (!token) { + setError( + `Token metadata for ${stream.token} could not be read from the chain, ` + + `so StackStream cannot build a safe post-condition for this top-up.` + ); + return false; + } if (!amount || parseFloat(amount) <= 0) { setError("Enter a positive amount"); return false; } - if (BigInt(amountRaw) > balance) { + if (amountRaw === null) { + setError("Enter a valid amount (digits and up to one decimal point)"); + return false; + } + if (amountRaw > balance) { setError( - `Insufficient ${tokenConfig.symbol} balance. You have ${formatTokenAmount(balance, tokenConfig.decimals)} ${tokenConfig.symbol}.`, + `Insufficient ${token.symbol} balance. You have ${formatTokenAmount(balance, token.decimals)} ${token.symbol}.` ); return false; } @@ -58,13 +73,13 @@ export function TopUpDialog({ async function handleSubmit(e: React.FormEvent) { e.preventDefault(); - if (!validate() || !address) return; + if (!validate() || !address || !token || amountRaw === null) return; const txOptions = buildTopUpStreamTx({ streamId, tokenContract: stream.token, - ftName: tokenConfig.ftName, - amount: BigInt(amountRaw), + token, + amount: amountRaw, senderAddress: address, }); const result = await execute(txOptions); @@ -94,36 +109,62 @@ export function TopUpDialog({ Top Up Stream #{streamId}

- Current deposit: {formatTokenAmount(stream.depositAmount, tokenConfig.decimals)} {tokenConfig.symbol} + {token ? ( + <> + Current deposit:{" "} + {formatTokenAmount(stream.depositAmount, token.decimals)} {token.symbol} + + ) : isTokenLoading ? ( + "Reading token metadata from the chain…" + ) : ( + `Unrecognized token ${unresolvableTokenLabel(stream.token)}` + )}

+ {!token && !isTokenLoading && ( +
+ +

+ This stream's token is not a token StackStream can verify. It may + not follow SIP-010, or its asset name could not be determined. Top-up + is unavailable because the required post-condition cannot be built + safely. +

+
+ )} +
setAmount(e.target.value)} error={error} - hint={amountRaw > 0 ? `${amountRaw.toLocaleString()} raw units` : undefined} + hint={ + amountRaw !== null && amountRaw > 0n + ? `${amountRaw.toLocaleString()} raw units` + : undefined + } + disabled={!token} /> - {address && ( + {address && token && (
Available:{" "} {isBalanceLoading ? "…" - : `${formatTokenAmount(balance, tokenConfig.decimals)} ${tokenConfig.symbol}`} + : `${formatTokenAmount(balance, token.decimals)} ${token.symbol}`} {balance > 0n && ( -
diff --git a/frontend/src/components/wallet/mint-dialog.tsx b/frontend/src/components/wallet/mint-dialog.tsx index 4f14d13..a890292 100644 --- a/frontend/src/components/wallet/mint-dialog.tsx +++ b/frontend/src/components/wallet/mint-dialog.tsx @@ -9,6 +9,7 @@ import { buildFaucetTx } from "@/lib/stacks"; import { useWalletStore } from "@/stores/wallet-store"; import { useTokenBalance } from "@/hooks/use-token-balance"; import { formatTokenAmount } from "@/lib/utils"; +import { DEFAULT_TOKEN, toRawAmount } from "@/lib/constants"; import { toast } from "sonner"; import { Droplets } from "lucide-react"; @@ -23,12 +24,15 @@ export function MintDialog({ open, onClose }: MintDialogProps) { const { balance, refetch } = useTokenBalance(); const [amount, setAmount] = useState("100"); - const amountRaw = Math.round(parseFloat(amount || "0") * 1e8); - const maxRaw = 100_000_000_000; // 1000 msBTC max per call + // The mock token is fixed at 8 decimals, but derive the multiplier from the + // registered metadata rather than hardcoding 1e8 so a future testnet token + // with different decimals can't be minted at the wrong scale. + const maxRaw = 100_000_000_000n; // 1000 msBTC max per call + const amountRaw = toRawAmount(amount || "0", DEFAULT_TOKEN.decimals); async function handleMint(e: React.FormEvent) { e.preventDefault(); - if (!address || amountRaw <= 0) return; + if (!address || amountRaw === null || amountRaw <= 0n) return; if (amountRaw > maxRaw) { toast.error("Max 1,000 msBTC per faucet call"); @@ -36,7 +40,7 @@ export function MintDialog({ open, onClose }: MintDialogProps) { } const txOptions = buildFaucetTx({ - amount: BigInt(amountRaw), + amount: amountRaw, senderAddress: address, }); const result = await execute(txOptions); @@ -72,7 +76,7 @@ export function MintDialog({ open, onClose }: MintDialogProps) {

Current balance

- {formatTokenAmount(balance)} msBTC + {formatTokenAmount(balance, DEFAULT_TOKEN.decimals)} msBTC

diff --git a/frontend/src/hooks/use-token-balance.ts b/frontend/src/hooks/use-token-balance.ts index 259d20f..b1a2480 100644 --- a/frontend/src/hooks/use-token-balance.ts +++ b/frontend/src/hooks/use-token-balance.ts @@ -1,26 +1,39 @@ "use client"; +/** + * Fetches a wallet's balance for one SIP-010 token. + * + * Takes resolved metadata rather than a bare contract id + asset name, because + * the balances endpoint is keyed by `contractId::assetName` and a wrong asset + * name reads 0 rather than erroring — which then surfaces as a misleading + * "Insufficient balance. You have 0.00". + */ + import { useQuery } from "@tanstack/react-query"; import { getTokenBalance } from "@/lib/stacks"; import { useWalletStore } from "@/stores/wallet-store"; import { DEFAULT_TOKEN, BALANCE_POLL_INTERVAL } from "@/lib/constants"; +import { useTokenMetadata } from "./use-token-metadata"; +import type { ResolvedToken } from "@/lib/token-metadata"; /** - * Fetches the SIP-010 balance for the connected wallet. - * @param tokenContractId - The fully-qualified contract ID of the token. - * Defaults to the network's default token (msBTC on testnet, sBTC on mainnet). - * @param ftName - The fungible token asset name inside the contract. - * Defaults to the default token's ftName. + * @param token - resolved metadata, or null to fall back to the network's + * default token (testnet msBTC balance display in the header, and the + * testnet faucet flow). Never pass a *different* stream's token implicitly. */ -export function useTokenBalance( - tokenContractId: string = DEFAULT_TOKEN.contractId, - ftName: string = DEFAULT_TOKEN.ftName -) { +export function useTokenBalance(token?: ResolvedToken | null) { const address = useWalletStore((s) => s.address); + const effective = token ?? { + contractId: DEFAULT_TOKEN.contractId, + assetName: DEFAULT_TOKEN.assetName, + decimals: DEFAULT_TOKEN.decimals, + symbol: DEFAULT_TOKEN.symbol, + curated: true, + } satisfies ResolvedToken; const query = useQuery({ - queryKey: ["token-balance", address, tokenContractId], - queryFn: () => getTokenBalance(address!, tokenContractId, ftName), + queryKey: ["token-balance", address, effective.contractId, effective.assetName], + queryFn: () => getTokenBalance(address!, effective.contractId, effective), enabled: !!address, refetchInterval: BALANCE_POLL_INTERVAL, }); @@ -31,3 +44,16 @@ export function useTokenBalance( refetch: query.refetch, }; } + +/** + * Resolve a contract id's metadata and read the wallet balance for it in one + * hook, for the common case where a caller has only a contract id to hand. + * + * Returns `isResolving` so callers can avoid rendering a "0.00" balance while + * metadata is still in flight, which is indistinguishable from a real zero. + */ +export function useTokenBalanceByContractId(contractId: string) { + const { token, isLoading: isResolving } = useTokenMetadata(contractId); + const balanceState = useTokenBalance(token); + return { ...balanceState, token, isResolving }; +} diff --git a/frontend/src/hooks/use-token-metadata.ts b/frontend/src/hooks/use-token-metadata.ts new file mode 100644 index 0000000..5e99a1b --- /dev/null +++ b/frontend/src/hooks/use-token-metadata.ts @@ -0,0 +1,80 @@ +"use client"; + +/** + * React hook for resolving SIP-010 token metadata. + * + * The resolver (`lib/token-metadata-client.ts`) is cached by contract id, so + * calling this per rendered stream costs one shared fetch per distinct token, + * not one per component. + * + * The important design point is the shape of the return value. There is no + * `DEFAULT_TOKEN` escape hatch: `token` is null until the chain has been + * consulted, and stays null if the token cannot be resolved. Components must + * render that state (a placeholder, or a refusal for write actions) rather than + * substituting a default, because a substituted default is precisely what + * produced wrong asset names in post-conditions. + */ + +import { useQuery } from "@tanstack/react-query"; +import { resolveTokenMetadata } from "@/lib/token-metadata-client"; +import type { ResolvedToken } from "@/lib/token-metadata"; + +/** + * Token decimals never change for a deployed token, so metadata is cached far + * more aggressively than balances. `Infinity` means "never refetch within this + * session"; the module-level cache in the resolver is the real backstop. + */ +const TOKEN_METADATA_STALE_TIME = 60 * 60 * 1000; + +export interface TokenMetadataState { + token: ResolvedToken | null; + isLoading: boolean; + isError: boolean; +} + +export function useTokenMetadata(contractId: string): TokenMetadataState { + const query = useQuery({ + queryKey: ["token-metadata", contractId], + queryFn: () => resolveTokenMetadata(contractId), + enabled: !!contractId, + staleTime: TOKEN_METADATA_STALE_TIME, + // Metadata is immutable, so never refetch on window focus. The resolver's + // module cache already collapses concurrent calls for the same contract. + refetchOnWindowFocus: false, + retry: 1, + }); + + return { + token: query.data ?? null, + isLoading: query.isLoading, + isError: query.isError, + }; +} + +/** + * Resolve metadata for a list of contract ids (one per token, not per stream). + * Returns a lookup keyed by contract id. Tokens that fail to resolve are simply + * absent from the map — callers see `undefined` and handle it. + */ +export function useTokensMetadata( + contractIds: readonly string[] +): Record { + const unique = Array.from(new Set(contractIds.filter(Boolean))); + const results = useQuery({ + queryKey: ["token-metadata", unique], + queryFn: async () => { + const entries = await Promise.all( + unique.map(async (id) => [id, await resolveTokenMetadata(id)] as const) + ); + return Object.fromEntries( + entries.filter((e): e is readonly [string, ResolvedToken] => e[1] !== null) + ); + }, + enabled: unique.length > 0, + staleTime: TOKEN_METADATA_STALE_TIME, + refetchOnWindowFocus: false, + retry: 1, + }); + + return (results.data ?? {}) as Record; +} diff --git a/frontend/src/lib/constants.ts b/frontend/src/lib/constants.ts index 608196e..bba12e6 100644 --- a/frontend/src/lib/constants.ts +++ b/frontend/src/lib/constants.ts @@ -96,100 +96,81 @@ export const MAX_STREAMS_PER_USER = 100; // Token Configuration // ============================================================================ -/** Shape of every token entry used throughout the UI */ -export interface TokenConfig { - symbol: string; - name: string; - decimals: number; - contractId: string; - /** Fungible token name inside the contract — used for post-conditions */ - ftName: string; - icon: string; - description: string; -} - /** - * Real mainnet SIP-010 tokens supported by StackStream. - * - * Contract IDs reflect Stacks mainnet as of Epoch 3.0 / Q1 2026. - * Verify addresses on Stacks Explorer before deploying if significant time - * has passed since this file was last updated. - * - * Adding a new token: append an entry here and supply an icon in /public/. - * The protocol itself is permissionless — any SIP-010 token can be streamed; - * this list controls what the frontend surfaces in the token selector. + * Token metadata is defined in `token-metadata.ts` (pure + testable) and + * resolved from chain in `token-metadata-client.ts`. Re-exported here so the + * existing `@/lib/constants` import surface keeps working. */ -const MAINNET_TOKENS: readonly TokenConfig[] = [ - { - symbol: "sBTC", - name: "sBTC", - decimals: 8, - contractId: "SM3VDXK3WZZSA84XXFKAFAF15NNZX32CTSG82JFQ4.sbtc-token", - ftName: "sbtc-token", - icon: "/bitcoin.svg", - description: "1:1 Bitcoin-backed asset on Stacks — the flagship streaming token", - }, - { - symbol: "USDA", - name: "USDA", - decimals: 6, - contractId: "SP2C2YFP12AJZB4MABJBAJ55XECVS7E4PMMZ89YZR.usda-token", - ftName: "usda", - icon: "/usda.svg", - description: "Arkadiko USD stablecoin — ideal for stable payroll streams", - }, - { - symbol: "ALEX", - name: "ALEX Token", - decimals: 8, - contractId: "SP102V8P0F7JX67ARQ77WEA3D3CFB5XW39REDT0AM.token-alex", - ftName: "alex", - icon: "/alex.svg", - description: "ALEX DeFi protocol token", - }, - { - symbol: "xBTC", - name: "Wrapped Bitcoin", +export { + getCuratedToken, + getCuratedTokens, + registerCuratedToken, + isValidAssetName, + isValidContractId, + isValidDecimals, + toRawAmount, + fromRawAmount, + unresolvableTokenLabel, + type ResolvedToken, + type TokenConfig, +} from "./token-metadata"; + +import { getCuratedTokens, registerCuratedToken } from "./token-metadata"; +import type { TokenConfig } from "./token-metadata"; + +// The mock testnet token's contract id depends on the deployer address, so it +// is registered here rather than hardcoded in token-metadata.ts (which this +// file already imports from, and which must not import back). +if (!IS_MAINNET) { + registerCuratedToken({ + contractId: MOCK_TOKEN_CONTRACT, + assetName: "mock-sbtc", decimals: 8, - contractId: "SP3DX3H4FEYZJZ586MFBS25ZW3HZDMEW92260R2PR.Wrapped-Bitcoin", - ftName: "wrapped-bitcoin", - icon: "/bitcoin.svg", - description: "Tokenized Bitcoin on Stacks", - }, -]; - -/** Testnet tokens — mock only, faucet available */ -const TESTNET_TOKENS: readonly TokenConfig[] = [ - { symbol: "msBTC", name: "Mock sBTC", - decimals: 8, - contractId: MOCK_TOKEN_CONTRACT, - ftName: "mock-sbtc", + curated: true, icon: "/bitcoin.svg", description: "Testnet mock token with public faucet", - }, -]; + }); +} /** - * Network-aware supported token list. - * Mainnet: 4 real SIP-010 tokens (sBTC, USDA, ALEX, xBTC). - * Testnet: 1 mock token with faucet for development. + * Tokens the UI offers in the create-stream selector. + * + * This is a UI seed list, NOT a protocol allowlist. `stream-manager.clar` takes + * any `(token )`, so any SIP-010 token can be streamed; streams + * in tokens outside this list still resolve and function correctly, they just + * don't appear in this dropdown. Display metadata (name, description, icon) + * cannot be read off-chain, which is the only reason the list exists. */ -export const SUPPORTED_TOKENS: readonly TokenConfig[] = IS_MAINNET - ? MAINNET_TOKENS - : TESTNET_TOKENS; +export const SUPPORTED_TOKENS = getCuratedTokens(); -/** Default token for the create stream form (first in list) */ +/** + * Default token for the create-stream form (first in the selector). + * + * Only ever a *seed*. Never use this to resolve the token of an existing + * stream — a stream in an unlisted token is not this token, and using it as a + * fallback is what produced wrong asset names in post-conditions. + */ export const DEFAULT_TOKEN = SUPPORTED_TOKENS[0]; /** - * Lookup a TokenConfig by its on-chain contract identifier (e.g. "SM3VDX...sbtc-token"). - * Falls back to DEFAULT_TOKEN when not found so callers always get a non-null - * record. Used by transaction builders to derive the ftName for post-conditions. + * Curated lookup by exact contract id, e.g. "SM3VDX...sbtc-token". + * + * Returns null when the contract is not curated. Callers MUST handle null: + * + * - Display: show the contract name, or resolve via `useTokenMetadata`. + * - Transaction building: refuse. A substituted default yields a + * post-condition naming an asset the transaction never touches, which the + * wallet rejects with "a post-condition was not met" — a failure that + * gives the user no clue about the real cause. + * + * This function previously returned DEFAULT_TOKEN on a miss. That silently + * mislabelled every unlisted token as sBTC, reported 6-decimal balances at an + * 8-decimal scale (100x wrong), and broke claim/cancel/top-up post-conditions. */ -export function getTokenConfigByContractId(contractId: string): TokenConfig { - return SUPPORTED_TOKENS.find((t) => t.contractId === contractId) ?? DEFAULT_TOKEN; +export function getTokenConfigByContractId(contractId: string): TokenConfig | null { + return SUPPORTED_TOKENS.find((t) => t.contractId === contractId) ?? null; } /** Polling interval for balance updates (ms) */ diff --git a/frontend/src/lib/openclaw-server.ts b/frontend/src/lib/openclaw-server.ts index ddc3a88..916b8a0 100644 --- a/frontend/src/lib/openclaw-server.ts +++ b/frontend/src/lib/openclaw-server.ts @@ -96,8 +96,72 @@ function parseStreamData(raw: Record): StreamData { }; } -export async function getStream(streamId: number): Promise { - const result = await callReadOnly(STREAM_MANAGER_CONTRACT, "get-stream", [ +// SIP-010 decimals, read from the token contract itself so any token formats +// correctly, not only the ones the app lists. Decimals never change for a +// deployed token, so cache per warm instance. Returns null if the call fails; +// callers then omit formatted amounts rather than guess a wrong scale. +const decimalsCache = new Map(); + +export async function getTokenDecimals( + tokenContract: string +): Promise { + const hit = decimalsCache.get(tokenContract); + if (hit !== undefined) return hit; + try { + const result = await callReadOnly(tokenContract, "get-decimals"); + if (!result.success) return null; + const decimals = Number(result.value.value); + if (!Number.isInteger(decimals) || decimals < 0 || decimals > 38) return null; + decimalsCache.set(tokenContract, decimals); + return decimals; + } catch { + return null; + } +} + +// SIP-010 symbols never change for a deployed token either, so cache alongside +// decimals. Returns null on failure — callers fall back to the contract's own +// name rather than a symbol belonging to some other token. +const symbolCache = new Map(); + +export async function getTokenSymbol( + tokenContract: string +): Promise { + const hit = symbolCache.get(tokenContract); + if (hit !== undefined) return hit; + try { + const result = await callReadOnly(tokenContract, "get-symbol"); + // cvToJSON wraps `(response (string-ascii ...))` the same way it wraps + // `(response uint ...)` — as { value: { type, value } } — so the string is + // at value.value, not value. Reading value directly returns the wrapper + // object, which is how tokenSymbol came back null. + const raw = result.value?.value; + const symbol = typeof raw === "string" ? raw.trim() : ""; + if (!symbol || symbol.length > 32) return null; + symbolCache.set(tokenContract, symbol); + return symbol; + } catch { + return null; + } +} + +/** + * A display label for a token contract, preferring the on-chain symbol and + * falling back to the contract's name component. + * + * The fallback matters: `SP….usda-token` is self-describing in a way that + * "0.012 USDA" is not. Never substitute another token's symbol here. + */ +export function tokenDisplayLabel( + tokenContract: string, + symbol: string | null +): string { + if (symbol) return symbol; + const contractName = tokenContract.split(".")[1]; + return contractName && contractName.length > 0 ? contractName : tokenContract; +} + +export async function getStream(streamId: number): Promise { const result = await callReadOnly(STREAM_MANAGER_CONTRACT, "get-stream", [ uintCV(streamId), ]); if (result.value === null) return null; @@ -192,9 +256,16 @@ export async function getCurrentBlockHeight(): Promise { // Formatting helpers (mirrors openclaw-service/src/utils.ts) // ============================================================================ +/** + * `decimals` is required, not defaulted. The previous `= 8` default meant any + * caller that forgot it silently formatted a 6-decimal token at 1/100th of its + * real value — the /api/streams/:id bug, where a 1.2 USDA deposit rendered as + * "0.012". Making the argument mandatory turns that class of mistake into a + * compile error. + */ export function formatTokenAmount( amount: bigint | number, - decimals = 8, + decimals: number, displayDecimals = 6 ): string { const num = typeof amount === "number" ? amount : Number(amount); diff --git a/frontend/src/lib/stacks.ts b/frontend/src/lib/stacks.ts index a3be0b3..02f6853 100644 --- a/frontend/src/lib/stacks.ts +++ b/frontend/src/lib/stacks.ts @@ -26,6 +26,8 @@ import { MOCK_TOKEN_CONTRACT, IS_MAINNET, } from "./constants"; +import { resolveTokenMetadata } from "./token-metadata-client"; +import { isValidContractId, type ResolvedToken } from "./token-metadata"; // ============================================================================ // Network helpers @@ -66,6 +68,53 @@ async function callReadOnly( return cvToJSON(result); } +// ============================================================================ +// Token metadata guard +// ============================================================================ + +/** + * Thrown when a stream's token metadata cannot be proven from the chain. + * + * Every post-condition builder below runs through this. The alternative — + * falling back to a default token — produces a post-condition that names an + * asset the transaction never touches, so the wallet rejects the transaction + * with "a post-condition was not met (token transfer rejected)". The user has + * no way to act on that, and the real cause (unknown token metadata) never + * surfaces. Refusing up front, with the contract named, is the only honest + * failure mode. + * + * Callers should catch `UnresolvableTokenError` and show its message. + */ +export class UnresolvableTokenError extends Error { + readonly contractId: string; + + constructor(contractId: string) { + super( + `Token metadata for ${contractId} could not be read from the chain. ` + + `Its SIP-010 asset name is ambiguous or its contract did not respond, ` + + `so StackStream cannot build a safe post-condition for it. ` + + `Try again shortly, or use one of the tokens in the selector.` + ); + this.name = "UnresolvableTokenError"; + this.contractId = contractId; + } +} + +/** + * Resolve the metadata a post-condition needs, or throw. + * + * The builders are synchronous and must not be, so callers resolve up front + * and pass `token` in. This helper exists for the call sites that build + * transactions inline. + */ +export async function requireTokenMetadata( + contractId: string +): Promise { + const token = await resolveTokenMetadata(contractId); + if (!token) throw new UnresolvableTokenError(contractId); + return token; +} + // ============================================================================ // Stream Manager read-only calls // ============================================================================ @@ -220,8 +269,12 @@ export async function isRegisteredDao(admin: string): Promise { export function buildCreateStreamTx(params: { recipient: string; tokenContract: string; - /** Fungible token asset name inside the contract (e.g. "sbtc-token", "mock-sbtc", "usda") */ - ftName: string; + /** + * Proven token metadata. Obtain via `requireTokenMetadata` so the + * `define-fungible-token` name used in the post-condition is read from the + * chain rather than assumed. + */ + token: ResolvedToken; depositAmount: bigint; startBlock: number; durationBlocks: number; @@ -229,7 +282,6 @@ export function buildCreateStreamTx(params: { senderAddress: string; }) { const [mgrAddr, mgrName] = splitContract(STREAM_MANAGER_CONTRACT); - const [tokenAddr, tokenName] = splitContract(params.tokenContract); const functionArgs: ClarityValue[] = [ principalCV(params.recipient), @@ -253,7 +305,10 @@ export function buildCreateStreamTx(params: { postConditions: [ Pc.principal(params.senderAddress) .willSendLte(params.depositAmount) - .ft(`${tokenAddr}.${tokenName}`, params.ftName), + .ft( + params.tokenContract as `${string}.${string}`, + params.token.assetName + ), ], network: getNetwork(), }; @@ -267,7 +322,8 @@ export function buildCreateStreamTx(params: { export function buildClaimTx(params: { streamId: number; tokenContract: string; - ftName: string; + /** Proven token metadata — see `buildCreateStreamTx`. */ + token: ResolvedToken; amount: bigint; }) { const [mgrAddr, mgrName] = splitContract(STREAM_MANAGER_CONTRACT); @@ -285,7 +341,10 @@ export function buildClaimTx(params: { postConditions: [ Pc.principal(`${mgrAddr}.${mgrName}`) .willSendLte(params.amount) - .ft(params.tokenContract as `${string}.${string}`, params.ftName), + .ft( + params.tokenContract as `${string}.${string}`, + params.token.assetName + ), ], network: getNetwork(), }; @@ -294,7 +353,8 @@ export function buildClaimTx(params: { export function buildClaimAllTx(params: { streamId: number; tokenContract: string; - ftName: string; + /** Proven token metadata — see `buildCreateStreamTx`. */ + token: ResolvedToken; /** * Stable upper bound for the payout: deposit − withdrawn (the stream's * remaining escrow). NEVER bound this by the claimable amount — claimable @@ -319,7 +379,10 @@ export function buildClaimAllTx(params: { postConditions: [ Pc.principal(`${mgrAddr}.${mgrName}`) .willSendLte(params.remainingBalance) - .ft(params.tokenContract as `${string}.${string}`, params.ftName), + .ft( + params.tokenContract as `${string}.${string}`, + params.token.assetName + ), ], network: getNetwork(), }; @@ -350,15 +413,16 @@ export function buildResumeStreamTx(streamId: number) { network: getNetwork(), }; } - export function buildCancelStreamTx(params: { streamId: number; tokenContract: string; - ftName: string; + /** Proven token metadata — see `buildCreateStreamTx`. */ + token: ResolvedToken; /** Upper bound for total token movement from the contract on cancel (recipient + sender refund). */ unclaimedBalance: bigint; }) { const [mgrAddr, mgrName] = splitContract(STREAM_MANAGER_CONTRACT); + return { contractAddress: mgrAddr, contractName: mgrName, @@ -371,7 +435,10 @@ export function buildCancelStreamTx(params: { postConditions: [ Pc.principal(`${mgrAddr}.${mgrName}`) .willSendLte(params.unclaimedBalance) - .ft(params.tokenContract as `${string}.${string}`, params.ftName), + .ft( + params.tokenContract as `${string}.${string}`, + params.token.assetName + ), ], network: getNetwork(), }; @@ -380,13 +447,12 @@ export function buildCancelStreamTx(params: { export function buildTopUpStreamTx(params: { streamId: number; tokenContract: string; - /** Fungible token asset name inside the contract (e.g. "sbtc-token", "mock-sbtc", "usda") */ - ftName: string; + /** Proven token metadata — see `buildCreateStreamTx`. */ + token: ResolvedToken; amount: bigint; senderAddress: string; }) { const [mgrAddr, mgrName] = splitContract(STREAM_MANAGER_CONTRACT); - const [tokenAddr, tokenName] = splitContract(params.tokenContract); return { contractAddress: mgrAddr, @@ -404,7 +470,10 @@ export function buildTopUpStreamTx(params: { postConditions: [ Pc.principal(params.senderAddress) .willSendLte(params.amount) - .ft(`${tokenAddr}.${tokenName}`, params.ftName), + .ft( + params.tokenContract as `${string}.${string}`, + params.token.assetName + ), ], network: getNetwork(), }; @@ -470,13 +539,25 @@ export async function getCurrentBlockHeight(): Promise { return data.stacks_tip_height; } +/** + * Read a wallet's balance of one SIP-010 asset. + * + * `token` must be resolved metadata, because the balances endpoint is keyed by + * `contractId::assetName` and a wrong asset name silently reads 0 rather than + * erroring. A zero balance would then block a legitimate top-up with + * "Insufficient balance. You have 0.00". + * + * When a contract defines several fungible tokens, the exact `assetName` match + * is used and there is no fallback to "any asset in this contract" — a + * balance in `sbtc-token-locked` is not a balance of `sbtc-token`, and + * reporting it as one would misstate what a user can spend. + */ export async function getTokenBalance( address: string, tokenContract: string, - /** Fungible token asset name inside the contract (e.g. "sbtc-token", "mock-sbtc", "usda") */ - ftName: string + token: Pick ): Promise { - if (!address) return 0n; + if (!address || !isValidContractId(tokenContract)) return 0n; const res = await fetch( getApiUrl(`/extended/v1/address/${address}/balances`), { headers: { Accept: "application/json" } } @@ -484,8 +565,13 @@ export async function getTokenBalance( if (!res.ok) return 0n; const data = await res.json(); const ftBalances = data.fungible_tokens || {}; - const key = `${tokenContract}::${ftName}`; - return BigInt(ftBalances[key]?.balance ?? "0"); + const entry = ftBalances[`${tokenContract}::${token.assetName}`]; + if (!entry) return 0n; + try { + return BigInt(entry.balance); + } catch { + return 0n; + } } // ============================================================================ diff --git a/frontend/src/lib/token-metadata-client.ts b/frontend/src/lib/token-metadata-client.ts new file mode 100644 index 0000000..e3f36c3 --- /dev/null +++ b/frontend/src/lib/token-metadata-client.ts @@ -0,0 +1,219 @@ +/** + * Network side of SIP-010 token metadata resolution. + * + * Splits the work from `token-metadata.ts` so that file stays pure and + * testable. This module owns the cache and the three chain reads: + * + * decimals — SIP-010 `get-decimals` + * assetName — `/v2/contracts/interface`, with an explicit ambiguity refusal + * symbol — SIP-010 `get-symbol`, display only + * + * `token-metadata-client` is used by the Next.js API routes and the browser + * alike, so it must never throw and never return a guessed value. Every + * failure path yields null, and callers refuse the action. + */ + +import { + fetchCallReadOnlyFunction, + cvToJSON, + type ClarityValue, +} from "@stacks/transactions"; +import { HIRO_API_BASE } from "./constants"; +import { + isValidAssetName, + isValidContractId, + isValidDecimals, + pickUnambiguousAssetName, + getCuratedToken, + type ResolvedToken, +} from "./token-metadata"; + +function splitContract(contractId: string): [string, string] { + const [addr, name] = contractId.split("."); + return [addr, name]; +} + +async function callReadOnly( + contractId: string, + functionName: string, + args: ClarityValue[] = [] +) { + const [contractAddress, contractName] = splitContract(contractId); + const result = await fetchCallReadOnlyFunction({ + contractAddress, + contractName, + functionName, + functionArgs: args, + senderAddress: contractAddress, + network: process.env.NEXT_PUBLIC_NETWORK === "mainnet" ? "mainnet" : "testnet", + }); + return cvToJSON(result); +} + +// ============================================================================ +// Cache +// ============================================================================ + +/** + * Decimals never change for a deployed token (SIP-010 fixes them at deploy + * time), so a process-lifetime cache is safe and removes a round-trip from + * every stream render. + * + * Cached per module instance, which means per serverless instance in production + * and per page load in the browser. Deliberately not persisted: token metadata + * must never go stale across a redeploy or a token migration. + */ +const cache = new Map(); + +/** Test seam: drop the memoized metadata. */ +export function clearTokenMetadataCache(): void { + cache.clear(); +} + +// ============================================================================ +// Individual reads +// ============================================================================ + +/** + * Read SIP-010 `get-decimals`. + * + * `cvToJSON` wraps a `(response uint ...)` as + * `{ success, value: { type: "uint", value: "6" } }`, so the number lives at + * `value.value` as a decimal string. Reading `value` directly yields NaN. + */ +export async function getTokenDecimals(contractId: string): Promise { + if (!isValidContractId(contractId)) return null; + try { + const result = await callReadOnly(contractId, "get-decimals"); + if (!result.success) return null; + const decimals = Number(result.value?.value); + return isValidDecimals(decimals) ? decimals : null; + } catch { + return null; + } +} + +/** + * Read SIP-010 `get-symbol`. Display only — never used in post-conditions. + */ +async function getTokenSymbol(contractId: string): Promise { + try { + const result = await callReadOnly(contractId, "get-symbol"); + if (!result.success) return null; + const raw = result.value?.value; + if (typeof raw !== "string" || raw.length === 0 || raw.length > 32) return null; + return /^[\x20-\x7e]+$/.test(raw) ? raw : null; + } catch { + return null; + } +} + +/** + * Read the `define-fungible-token` name from the contract interface. + * + * This is the field post-conditions need, and it is NOT `get-name`: USDA's + * `get-name` is "USDA" while its asset name is "usda". There is no SIP-010 + * read-only function that returns the asset name, so the interface endpoint is + * the documented way to get it. + * + * Returns null when the contract defines zero or several fungible tokens — + * see `pickUnambiguousAssetName` for why several is a refusal, not a guess. + */ +export async function getTokenAssetName(contractId: string): Promise { + if (!isValidContractId(contractId)) return null; + const [deployer, contractName] = splitContract(contractId); + try { + const res = await fetch( + `${HIRO_API_BASE}/v2/contracts/interface/${deployer}/${contractName}`, + { headers: { Accept: "application/json" } } + ); + if (!res.ok) return null; + const iface = (await res.json()) as { fungible_tokens?: { name?: unknown }[] }; + const assetName = pickUnambiguousAssetName(iface.fungible_tokens); + return isValidAssetName(assetName) ? assetName : null; + } catch { + return null; + } +} + +// ============================================================================ +// Full resolution +// ============================================================================ + +/** + * Resolve the metadata needed to interact with a token, or null. + * + * Resolution order: + * 1. The curated list, if the contract id matches exactly. Free, and immune + * to an interface-endpoint outage. Correct even for multi-asset contracts + * like sBTC, where the curated entry records the verified asset name. + * 2. Chain reads for anything else — which is how a stream in a token the + * UI has never heard of still gets a correct post-condition and correct + * decimals. + * + * Returns null when the chain cannot prove an asset name (notably the + * multi-asset contracts). Callers MUST treat null as "refuse", never as + * "default to sBTC": substituting a default is what produces rejected + * transactions and misreported balances. + */ +export async function resolveTokenMetadata( + contractId: string +): Promise { + if (!isValidContractId(contractId)) return null; + + const cached = cache.get(contractId); + if (cached !== undefined) return cached; + + const curated = getCuratedToken(contractId); + let resolved: ResolvedToken | null = null; + + if (curated) { + resolved = { + contractId: curated.contractId, + assetName: curated.assetName, + decimals: curated.decimals, + symbol: curated.symbol, + curated: true, + }; + } else { + // assetName and decimals are independent reads; run them together. The + // symbol is display-only, so its absence does not block resolution. + const [assetName, decimals, symbol] = await Promise.all([ + getTokenAssetName(contractId), + getTokenDecimals(contractId), + getTokenSymbol(contractId), + ]); + if (assetName !== null && decimals !== null) { + resolved = { + contractId, + assetName, + decimals, + // A display symbol is nice to have; the asset name is an honest + // substitute and is guaranteed unique to this contract. + symbol: symbol ?? contractId.split(".")[1] ?? contractId, + curated: false, + }; + } + } + + // Cache both hits and misses so a failing token does not re-hit the chain on + // every render. The negative entries are what stop a broken contract from + // becoming a request amplifier. + cache.set(contractId, resolved); + return resolved; +} + +/** + * Resolve several tokens at once, for pages that render many streams. + * Duplicates are collapsed by `resolveTokenMetadata`'s cache, so this is cheap + * to call with a list containing repeats. + * + * Returns a nullable list rather than filtering, so callers keep the index + * alignment with their input. A filtered result would silently shift every + * later token's identity if one contract in the middle failed to resolve. + */ +export function resolveAllTokens( + contractIds: readonly string[] +): Promise> { + return Promise.all(contractIds.map((id) => resolveTokenMetadata(id))); +} diff --git a/frontend/src/lib/token-metadata.ts b/frontend/src/lib/token-metadata.ts new file mode 100644 index 0000000..49931b1 --- /dev/null +++ b/frontend/src/lib/token-metadata.ts @@ -0,0 +1,281 @@ +/** + * SIP-010 token metadata resolution. + * + * WHY THIS EXISTS + * + * The protocol is permissionless: `stream-manager.clar` takes the token as a + * `(token )` argument and stores `(contract-of token)`. Any + * SIP-010 token can be streamed. But every consumer of a stream also needs + * three facts about that token, and none of them can be guessed: + * + * 1. assetName — the `define-fungible-token` name, used verbatim in Clarity + * post-conditions. NOT the same as `get-name`. USDA's `get-name` returns + * "USDA" while its asset name is "usda". Canonical sBTC's asset name is + * "sbtc-token", not "sbtc". + * 2. decimals — for scaling raw uint amounts. USDA and stSTX are 6, sBTC + * and ALEX are 8, so hardcoding 8 misreports a 6-decimal deposit by 100x. + * 3. symbol — display only. + * + * Guessing any of these is actively dangerous. A wrong `assetName` makes the + * wallet reject the transaction via its post-condition check; the user sees + * "a post-condition was not met" with no indication of the real cause. A wrong + * `decimals` makes a 100-token top-up send 100x the intended amount. + * + * So this module resolves metadata from the chain and, when it cannot prove a + * value, returns null. Callers must handle null by refusing the action rather + * than substituting a default. See `resolveTokenMetadata` and + * `getTokenConfigByContractId` below. + * + * Pure and network-free by design: the actual fetching lives in + * `token-metadata-client.ts`, which keeps this file testable without a network + * or a wallet. + */ + +// ============================================================================ +// Types +// ============================================================================ + +/** + * A token's proven metadata. Every field is required, and every field must + * come from the chain (or from a hand-verified entry in the curated list) — + * never from a default. + */ +export interface ResolvedToken { + /** Fully-qualified `deployer.contract-name`, e.g. "SP2C2...ZM.usda-token" */ + contractId: string; + /** The `define-fungible-token` name. Goes into post-conditions verbatim. */ + assetName: string; + /** SIP-010 `get-decimals`. Raw amounts are scaled by 10^decimals. */ + decimals: number; + /** Display symbol. Derived from `get-symbol`, or the asset name as a fallback. */ + symbol: string; + /** True when this came from the hand-verified curated list, not a chain read. */ + curated: boolean; +} + +export interface TokenConfig extends ResolvedToken { + name: string; + icon?: string; + description?: string; +} + +// ============================================================================ +// Curated mainnet list +// ============================================================================ + +/** + * Hand-verified metadata for the mainnet tokens the UI surfaces in its + * selector. + * + * This list exists for two reasons, neither of which is "the protocol only + * supports these tokens": + * + * 1. Curated entries cost zero network round-trips, so the common path is + * instant and cannot fail. + * 2. The selector needs display metadata (name, description, icon) that + * cannot be read off-chain. + * + * A stream in any token OUTSIDE this list still resolves correctly — the + * resolver reads the chain instead. Identity here is the full contract id, + * never the symbol: there are two mainnet USDA contracts and several ALEX + * ones, so a symbol-keyed allowlist would resolve the wrong contract. + * + * The assetName values were verified against + * `/v2/contracts/interface/{deployer}/{contract}` on mainnet. Note sBTC and + * ALEX each define a second `-locked` asset; the canonical transferable asset + * is the un-suffixed one, which is why this list records an explicit assetName + * rather than letting the resolver pick one from an ambiguous set. + */ +const CURATED: readonly TokenConfig[] = [ + { + contractId: "SM3VDXK3WZZSA84XXFKAFAF15NNZX32CTSG82JFQ4.sbtc-token", + assetName: "sbtc-token", + decimals: 8, + symbol: "sBTC", + name: "Stacks BTC", + curated: true, + icon: "/bitcoin.svg", + description: "Native Bitcoin on Stacks — the flagship streaming token", + }, + { + contractId: "SP2C2YFP12AJZB4MABJBAJ55XECVS7E4PMMZ89YZR.usda-token", + assetName: "usda", + decimals: 6, + symbol: "USDA", + name: "USDA", + curated: true, + icon: "/usda.svg", + description: "Arkadiko USD stablecoin — ideal for stable payroll streams", + }, + { + contractId: "SP102V8P0F7JX67ARQ77WEA3D3CFB5XW39REDT0AM.token-alex", + assetName: "alex", + decimals: 8, + symbol: "ALEX", + name: "ALEX", + curated: true, + icon: "/alex.svg", + description: "ALEX DeFi protocol token", + }, + { + contractId: "SP3DX3H4FEYZJZ586MFBS25ZW3HZDMEW92260R2PR.Wrapped-Bitcoin", + assetName: "wrapped-bitcoin", + decimals: 8, + symbol: "xBTC", + name: "Wrapped Bitcoin", + curated: true, + icon: "/bitcoin.svg", + description: "Tokenized Bitcoin on Stacks", + }, +]; + +// ============================================================================ +// Curated list lookup +// ============================================================================ + +/** + * Extra curated entries, registered by `constants.ts`. + * + * The mock testnet token's contract id depends on the deployer address, so it + * cannot be hardcoded here without importing `constants.ts` — which imports + * this module. A tiny registry breaks the cycle without a dependency inversion. + */ +const EXTRA: TokenConfig[] = []; + +/** + * Register a network-specific curated entry (used for the testnet mock token). + * + * Re-registering the same contract id is ignored rather than overwritten, so + * this is idempotent and cannot be used to swap the asset name of an already + * curated token. + */ +export function registerCuratedToken(token: TokenConfig): void { + if (!getCuratedToken(token.contractId)) { + EXTRA.push(token); + } +} + +/** Look up a curated entry by full contract id. Never matches on symbol. */ +export function getCuratedToken(contractId: string): TokenConfig | null { + return ( + CURATED.find((t) => t.contractId === contractId) ?? + EXTRA.find((t) => t.contractId === contractId) ?? + null + ); +} + +/** + * Curated tokens, in selector display order. This is a UI seed list, NOT a + * protocol allowlist — see the module docstring. + */ +export function getCuratedTokens(): readonly TokenConfig[] { + return [...CURATED, ...EXTRA]; +} + +// ============================================================================ +// Validation +// ============================================================================ + +/** + * SIP-010 permits 0..38 decimals (uint128 range). Values outside that are + * either a misparse or a malicious contract, and either way we refuse rather + * than scale amounts with them. + */ +export function isValidDecimals(decimals: unknown): decimals is number { + return Number.isInteger(decimals) && (decimals as number) >= 0 && (decimals as number) <= 38; +} + +/** + * A Clarity asset name is a printable ASCII string of at most 128 chars. + * Rejecting anything else keeps a hostile contract from injecting control + * characters into post-conditions or the UI. + */ +const ASSET_NAME_RE = /^[\x20-\x7e]{1,128}$/; + +export function isValidAssetName(name: unknown): name is string { + return typeof name === "string" && ASSET_NAME_RE.test(name); +} + +/** Reject contract ids that aren't `SP….name` / `SM….name` before using them in a URL. */ +const CONTRACT_ID_RE = /^S[PM][A-Z0-9]{38,40}\.[a-zA-Z0-9\-_!?+<>=/*]{1,128}$/; + +export function isValidContractId(contractId: unknown): contractId is string { + return typeof contractId === "string" && CONTRACT_ID_RE.test(contractId); +} + +/** + * Parse a `define-fungible-token` list from `/v2/contracts/interface` into the + * single asset name we can prove. + * + * A contract may define several fungible tokens — canonical sBTC defines both + * `sbtc-token` and `sbtc-token-locked`, ALEX defines `alex` and `alex-locked`. + * Picking `fungible_tokens[0]` would be a coin flip, and a wrong pick means a + * post-condition names an asset the transaction never touches, so the wallet + * rejects it. Therefore: + * + * - exactly one fungible token -> unambiguous, accept it + * - more than one -> ambiguous, return null and refuse + * + * Curated tokens bypass this entirely because their asset name was verified by + * hand. For an uncurated multi-asset token the caller must supply the asset + * name from a trusted source (see `resolveTokenMetadata` in the client). + */ +export function pickUnambiguousAssetName( + fungibleTokens: readonly { name?: unknown }[] | null | undefined +): string | null { + if (!Array.isArray(fungibleTokens) || fungibleTokens.length === 0) return null; + const names = fungibleTokens + .map((f) => f?.name) + .filter((n): n is string => isValidAssetName(n)); + if (names.length !== fungibleTokens.length) return null; // a malformed entry + if (names.length !== 1) return null; // ambiguous + return names[0]; +} + +// ============================================================================ +// Display helpers +// ============================================================================ + +/** + * A display label for a token we could not fully resolve. Deliberately shows + * the asset identity rather than a plausible-looking symbol, so a user is + * never shown "sBTC" over a balance that is actually some other token. + */ +export function unresolvableTokenLabel(contractId: string): string { + const contractName = contractId.split(".")[1]; + return contractName ?? contractId; +} + +/** + * Convert a human-entered amount into raw token units. + * + * Done with integer string arithmetic rather than floating point: `parseFloat` + * loses precision well before 1e15, and a silently rounded top-up amount is a + * fund-safety bug, not a cosmetic one. + * + * Returns null when the input is not a clean positive decimal, so callers can + * reject it rather than send something the user did not ask for. + */ +export function toRawAmount(amount: string, decimals: number): bigint | null { + if (!isValidDecimals(decimals)) return null; + const trimmed = amount.trim(); + if (!/^\d*(\.\d*)?$/.test(trimmed) || trimmed === "" || trimmed === ".") return null; + + const [wholePart, fractionPart = ""] = trimmed.split("."); + // Truncate rather than round: never send more than the user typed. + const padded = (fractionPart + "0".repeat(decimals)).slice(0, decimals); + const combined = `${wholePart || "0"}${padded}`; + const raw = BigInt(combined); + return raw > 0n ? raw : null; +} + +/** Convert raw token units into a decimal string, without floating point. */ +export function fromRawAmount(raw: bigint, decimals: number): string { + if (!isValidDecimals(decimals)) return "0"; + const base = 10n ** BigInt(decimals); + const whole = raw / base; + const fraction = raw % base; + if (fraction === 0n) return whole.toString(); + const fracStr = fraction.toString().padStart(decimals, "0").replace(/0+$/, ""); + return `${whole.toString()}.${fracStr}`; +} diff --git a/frontend/src/lib/utils.ts b/frontend/src/lib/utils.ts index 584f2ce..3fc0810 100644 --- a/frontend/src/lib/utils.ts +++ b/frontend/src/lib/utils.ts @@ -1,21 +1,27 @@ import { type ClassValue, clsx } from "clsx"; import { twMerge } from "tailwind-merge"; -import { - BLOCK_TIME_SECONDS, - DEFAULT_TOKEN, - getTokenConfigByContractId, - type TokenConfig, -} from "./constants"; +import { BLOCK_TIME_SECONDS } from "./constants"; import { clarityErrorMessage } from "./stacks"; export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)); } -/** Format a micro-token amount (8 decimals for sBTC) into a human-readable string */ +/** + * Format a raw SIP-010 amount for display. + * + * `decimals` has no default on purpose. An omitted argument silently formats + * every 6-decimal token at an 8-decimal scale — a 100x error with no visible + * symptom, which is the class of bug that produced "0.012" being shown for + * 1.2 USDA. Call sites must pass the token's own decimals. + * + * Note the float conversion below is display-only and loses precision above + * 2^53 raw units. Anything that feeds a transaction must use the exact + * string/bigint helpers in `token-metadata.ts` instead. + */ export function formatTokenAmount( amount: bigint | number, - decimals = 8, + decimals: number, displayDecimals = 6 ): string { const num = typeof amount === "number" ? amount : Number(amount); @@ -136,23 +142,28 @@ export function blockToClockTime(targetBlock: number, currentBlock: number): str } /** - * Pick the dominant token across a set of streams (by stream count) for - * aggregate stat-card / hero-card display. Streams in other tokens are - * reported via `otherCount` so callers can surface them as a footnote. + * Group streams by token and identify the dominant one (by stream count) for + * aggregate stat-card / hero-card display. Streams in other tokens are reported + * via `otherCount` so callers can surface them as a footnote. * - * Aggregating raw amounts across tokens with different decimals would be - * meaningless (1e8 raw sBTC sums incorrectly with 1e6 raw USDA), so callers - * should reduce only `primaryStreams` when computing totals. + * This returns the dominant token's *contract id*, not a TokenConfig, because a + * config lookup would need a fallback for uncurated tokens and that fallback + * is exactly the bug this refactor removes. Callers resolve the id via + * `useTokenMetadata`, and render a placeholder if it cannot be resolved. + * + * Aggregating raw amounts across tokens is never correct — 1e8 raw sBTC does + * not sum with 1e6 raw USDA — so callers must reduce only `primaryStreams`, + * using the resolved decimals of `primaryTokenId`. */ export function pickPrimaryToken( streams: readonly T[], ): { - token: TokenConfig; + primaryTokenId: string | null; primaryStreams: T[]; otherCount: number; } { if (streams.length === 0) { - return { token: DEFAULT_TOKEN, primaryStreams: [], otherCount: 0 }; + return { primaryTokenId: null, primaryStreams: [], otherCount: 0 }; } const byToken = streams.reduce>((acc, s) => { (acc[s.token] ??= []).push(s); @@ -163,7 +174,7 @@ export function pickPrimaryToken( ); const primaryStreams = byToken[primaryTokenId]; return { - token: getTokenConfigByContractId(primaryTokenId), + primaryTokenId, primaryStreams, otherCount: streams.length - primaryStreams.length, }; @@ -173,11 +184,22 @@ export function pickPrimaryToken( * Build a user-facing toast message from a failed TxResult. * Routes Clarity error codes (e.g. `u207`) through the human-readable * mapping so users see "Stream has already ended" instead of raw `u207`. + * + * `result` is optional: token-metadata resolution runs *before* a transaction + * exists, so its failures arrive as thrown errors rather than TxResults. Pass + * the caught error as `cause` in that case and its message is used verbatim, + * which keeps "why we refused" intact instead of collapsing it to "failed". */ export function formatTxError( prefix: string, - result: { status: string; errorCode?: string }, + result?: { status: string; errorCode?: string } | null, + cause?: unknown, ): string { + if (cause) { + const message = cause instanceof Error ? cause.message : String(cause); + return `${prefix}: ${message}`; + } + if (!result) return prefix; if (result.status === "timeout") return "Transaction timed out"; const human = clarityErrorMessage(result.errorCode); if (human) return `${prefix}: ${human}`; diff --git a/openclaw-service/src/routes/daos.ts b/openclaw-service/src/routes/daos.ts index b2230bb..0f20d10 100644 --- a/openclaw-service/src/routes/daos.ts +++ b/openclaw-service/src/routes/daos.ts @@ -1,7 +1,6 @@ import { Router } from "express"; import { getDao, getDaoCount } from "../stacks-client"; import { validateParams, adminParam } from "../middleware/validate"; -import { formatTokenAmount } from "../utils"; const router = Router(); @@ -25,7 +24,16 @@ router.get("/:admin", validateParams(adminParam), async (req, res, next) => { } res.json({ ...dao, - totalDepositedFormatted: formatTokenAmount(dao.totalDeposited), + // No `totalDepositedFormatted`. + // + // stream-factory increments `total-deposited` by the raw deposit of every + // tracked stream regardless of token, and the `daos` map has no token key. + // Summing 1e8-scale sBTC raw units with 1e6-scale USDA raw units yields a + // number that is not an amount of anything, so there is no correct single + // rendering. This route previously emitted one at 8 decimals, which was + // confidently wrong. Fixing it properly needs a contract change; until + // then consumers get the raw integer plus this flag. + totalDepositedIsCrossTokenAggregate: true, }); } catch (err) { next(err); diff --git a/openclaw-service/src/routes/streams.ts b/openclaw-service/src/routes/streams.ts index e7d5545..bbd860e 100644 --- a/openclaw-service/src/routes/streams.ts +++ b/openclaw-service/src/routes/streams.ts @@ -10,6 +10,9 @@ import { getRecipientStreams, getStreamNonce, getCurrentBlockHeight, + getTokenDecimals, + getTokenSymbol, + tokenDisplayLabel, } from "../stacks-client"; import { getStreamStatusLabel, formatTokenAmount, getStreamProgress } from "../utils"; import { validateParams, streamIdParam, addressParam } from "../middleware/validate"; @@ -66,14 +69,25 @@ router.get("/:id", validateParams(streamIdParam), async (req, res, next) => { return; } - const [claimable, streamed, remaining, refundable, currentBlock] = - await Promise.all([ - getClaimableBalance(id), - getStreamedAmount(id), - getRemainingBalance(id), - getRefundableAmount(id), - getCurrentBlockHeight(), - ]); + const [ + claimable, + streamed, + remaining, + refundable, + currentBlock, + decimals, + symbol, + ] = await Promise.all([ + getClaimableBalance(id), + getStreamedAmount(id), + getRemainingBalance(id), + getRefundableAmount(id), + getCurrentBlockHeight(), + // Read the token's own decimals. Assuming 8 is what made a 1.2 USDA + // deposit report as "0.012". + getTokenDecimals(stream.token), + getTokenSymbol(stream.token), + ]); const progress = getStreamProgress( stream.startBlock, @@ -92,8 +106,20 @@ router.get("/:id", validateParams(streamIdParam), async (req, res, next) => { refundable, currentBlock, progress: Math.round(progress * 100) / 100, - depositFormatted: formatTokenAmount(stream.depositAmount), - claimableFormatted: claimable !== null ? formatTokenAmount(claimable) : null, + tokenDecimals: decimals, + // Always a string, and always this token's own label. + tokenLabel: tokenDisplayLabel(stream.token, symbol), + tokenSymbol: symbol, + // null rather than a guess when decimals are unavailable. A consumer that + // formats these itself has tokenDecimals to work from. + depositFormatted: + decimals !== null + ? formatTokenAmount(stream.depositAmount, decimals) + : null, + claimableFormatted: + decimals !== null && claimable !== null + ? formatTokenAmount(claimable, decimals) + : null, }); } catch (err) { next(err); diff --git a/openclaw-service/src/routes/tokens.ts b/openclaw-service/src/routes/tokens.ts index 2fc83dc..c9711d1 100644 --- a/openclaw-service/src/routes/tokens.ts +++ b/openclaw-service/src/routes/tokens.ts @@ -1,5 +1,5 @@ import { Router } from "express"; -import { getTokenBalance } from "../stacks-client"; +import { getTokenBalance, getTokenDecimals } from "../stacks-client"; import { validateParams, tokenBalanceParams } from "../middleware/validate"; import { formatTokenAmount } from "../utils"; @@ -14,11 +14,16 @@ router.get( const addr = req.params.address as string; const contract = req.params.contract as string; const balance = await getTokenBalance(addr, contract); + // Scale from the token's own decimals, and publish tokenDecimals so a + // consumer can verify the scale rather than trust it. + const decimals = await getTokenDecimals(contract); res.json({ address: addr, tokenContract: contract, + tokenDecimals: decimals, balance, - balanceFormatted: formatTokenAmount(balance), + balanceFormatted: + decimals !== null ? formatTokenAmount(balance, decimals) : null, }); } catch (err) { next(err); diff --git a/openclaw-service/src/stacks-client.ts b/openclaw-service/src/stacks-client.ts index 32b9542..aee3ba0 100644 --- a/openclaw-service/src/stacks-client.ts +++ b/openclaw-service/src/stacks-client.ts @@ -238,6 +238,72 @@ export async function getCurrentBlockHeight(): Promise { return data.stacks_tip_height; } +// SIP-010 metadata, read from the token contract rather than assumed. The +// protocol is permissionless, so no hardcoded token table is complete: a caller +// that assumes 8 decimals misreports every 6-decimal token by 100x. +// +// Decimals never change for a deployed token, so cache per warm instance. +// Returns null on failure — callers then omit the formatted field rather than +// publish a number at an unknown scale. +const tokenDecimalsCache = new Map(); +const tokenSymbolCache = new Map(); + +export async function getTokenDecimals( + tokenContract: string +): Promise { + const hit = tokenDecimalsCache.get(tokenContract); + if (hit !== undefined) return hit; + try { + // get-decimals resolves as { value: { value: } } — reading + // result.value directly yields the wrapper object and Number(...) gives NaN, + // which previously propagated as a null scale. + const result = await callReadOnly(tokenContract, "get-decimals"); + const decimals = Number( + (result as { value?: { value?: unknown } })?.value?.value + ); + if (!Number.isInteger(decimals) || decimals < 0 || decimals > 38) return null; + tokenDecimalsCache.set(tokenContract, decimals); + return decimals; + } catch { + return null; + } +} + +/** + * A display label for a token, preferring the on-chain symbol and falling back + * to the contract's own name component. Never substitutes another token's + * symbol — a plausible label on the wrong token is worse than a raw contract + * name. + */ +export function tokenDisplayLabel( + tokenContract: string, + symbol: string | null +): string { + if (symbol) return symbol; + const contractName = tokenContract.split(".")[1]; + return contractName && contractName.length > 0 ? contractName : tokenContract; +} + +export async function getTokenSymbol( + tokenContract: string +): Promise { + const hit = tokenSymbolCache.get(tokenContract); + if (hit !== undefined) return hit; + try { + // cvToJSON wraps `(response (string-ascii ...))` the same way it wraps + // `(response uint ...)` — as { value: { type, value } } — so the string is + // at value.value. Reading value directly returns the wrapper object. + const result = await callReadOnly(tokenContract, "get-symbol"); + const raw = (result as { value?: { value?: unknown } })?.value?.value; + const symbol = typeof raw === "string" ? raw.trim() : ""; + if (!symbol || symbol.length > 32) return null; + tokenSymbolCache.set(tokenContract, symbol); + return symbol; + } catch { + return null; + } +} + export async function getTokenBalance( address: string, tokenContract: string diff --git a/openclaw-service/src/utils.ts b/openclaw-service/src/utils.ts index e219944..ca8dae9 100644 --- a/openclaw-service/src/utils.ts +++ b/openclaw-service/src/utils.ts @@ -1,7 +1,14 @@ -/** Format a micro-token amount (8 decimals for sBTC) into a human-readable string */ +/** + * Format a raw SIP-010 amount. + * + * `decimals` is required. The previous `= 8` default meant a caller that + * forgot it published 6-decimal amounts (USDA) at 1/100th of their real value — + * a 1.2 USDA deposit reported as "0.012". Requiring the argument makes that a + * type error rather than a silently wrong number in a financial API. + */ export function formatTokenAmount( amount: bigint | number, - decimals = 8, + decimals: number, displayDecimals = 6 ): string { const num = typeof amount === "number" ? amount : Number(amount); diff --git a/tests/token-metadata.test.ts b/tests/token-metadata.test.ts new file mode 100644 index 0000000..69c9065 Binary files /dev/null and b/tests/token-metadata.test.ts differ