From 7410d67a2cd6efd6d660cb4592818a685650747c Mon Sep 17 00:00:00 2001 From: jayteemoney Date: Thu, 24 Sep 2026 14:49:42 +0100 Subject: [PATCH 1/6] fix(api): format stream amounts with the token's real decimals /api/streams/:id formatted every amount as if the token had 8 decimals, so USDA (6 decimals) showed 100x too small: stream 11 read "0.012" instead of "1.20". Decimals are now read from the token contract's SIP-010 get-decimals and cached per instance, so any token formats correctly, listed or not. If the read fails the formatted fields are null rather than a wrong number. Adds tokenDecimals to the response. Verified on a local mainnet build: all 11 streams (USDA) format correctly, and get-decimals parses as 8 for sBTC, ALEX and xBTC. Claude-Session: https://claude.ai/code/session_011DndgWvvL6sUfzTgmC67Wq --- docs/INTEGRATION_GUIDE.md | 9 +++++++-- frontend/src/app/api/streams/[id]/route.ts | 14 ++++++++++--- frontend/src/lib/openclaw-server.ts | 23 ++++++++++++++++++++++ 3 files changed, 41 insertions(+), 5 deletions(-) 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/streams/[id]/route.ts b/frontend/src/app/api/streams/[id]/route.ts index c421f54..aec821f 100644 --- a/frontend/src/app/api/streams/[id]/route.ts +++ b/frontend/src/app/api/streams/[id]/route.ts @@ -5,6 +5,7 @@ import { getRemainingBalance, getRefundableAmount, getCurrentBlockHeight, + getTokenDecimals, getStreamStatusLabel, getStreamProgress, formatTokenAmount, @@ -32,13 +33,14 @@ export async function GET( return jsonResponse({ error: "Stream not found" }, 404); } - const [claimable, streamed, remaining, refundable, currentBlock] = + const [claimable, streamed, remaining, refundable, currentBlock, decimals] = await Promise.all([ getClaimableBalance(id), getStreamedAmount(id), getRemainingBalance(id), getRefundableAmount(id), getCurrentBlockHeight(), + getTokenDecimals(stream.token), ]); const progress = getStreamProgress( @@ -58,9 +60,15 @@ export async function GET( refundable, currentBlock, progress: Math.round(progress * 100) / 100, - depositFormatted: formatTokenAmount(stream.depositAmount), + tokenDecimals: decimals, + 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/lib/openclaw-server.ts b/frontend/src/lib/openclaw-server.ts index ddc3a88..ec80666 100644 --- a/frontend/src/lib/openclaw-server.ts +++ b/frontend/src/lib/openclaw-server.ts @@ -96,6 +96,29 @@ function parseStreamData(raw: Record): StreamData { }; } +// 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; + } +} + export async function getStream(streamId: number): Promise { const result = await callReadOnly(STREAM_MANAGER_CONTRACT, "get-stream", [ uintCV(streamId), From 7dbc7a6bf3682ea44a5da918f4291b95c23f0a8e Mon Sep 17 00:00:00 2001 From: jayteemoney Date: Fri, 2 Oct 2026 04:23:56 +0100 Subject: [PATCH 2/6] feat(frontend): resolve token metadata from chain, refuse when unproven getTokenConfigByContractId fell back to DEFAULT_TOKEN (sBTC) on any miss, so every consumer silently assumed sBTC for tokens outside the four-entry curated list. Three distinct failures followed: - Amounts: a 6-decimal token (USDA, stSTX) divided by 1e8 instead of 1e6, reporting balances 100x too small. - Post-conditions: create/top-up/claim/cancel named "sbtc-token" for a USDA stream, so the wallet rejected the tx with "a post-condition was not met" and no hint at the cause. - Labels: any unlisted token was displayed as "sBTC". The protocol is permissionless, so no hardcoded list is ever complete. Adds a resolver that reads decimals and the define-fungible-token asset name from the token itself, falling back to the curated list only when it is not curated. assetName is not get-name: USDA's get-name is "USDA" while its asset name is "usda". Contracts defining several fungible tokens (sBTC and ALEX each expose a -locked variant) are refused rather than guessed, so callers disable the action and say why instead of building a condition that cannot pass. Builders now take a resolved ResolvedToken rather than a bare ftName, so it is impossible to compile a transaction with an unproven asset name. getTokenConfigByContractId returns TokenConfig | null; all call sites handle it. A new UnresolvableTokenError distinguishes "we refused" from "the wallet rejected it" in the UI. Also removes the decimals=8 default from formatTokenAmount so a forgotten argument is a type error rather than a silently wrong number. --- frontend/src/hooks/use-token-metadata.ts | 80 ++++++ frontend/src/lib/constants.ts | 139 +++++------ frontend/src/lib/token-metadata-client.ts | 219 +++++++++++++++++ frontend/src/lib/token-metadata.ts | 281 ++++++++++++++++++++++ tests/token-metadata.test.ts | Bin 0 -> 7872 bytes 5 files changed, 640 insertions(+), 79 deletions(-) create mode 100644 frontend/src/hooks/use-token-metadata.ts create mode 100644 frontend/src/lib/token-metadata-client.ts create mode 100644 frontend/src/lib/token-metadata.ts create mode 100644 tests/token-metadata.test.ts 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/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/tests/token-metadata.test.ts b/tests/token-metadata.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..69c9065b4815ff1ee566e2b934cf42a6d27c329b GIT binary patch literal 7872 zcmbtZ+j84D65VHi1&S}Gb|qRDUu35;Dfyy_J+arZys_iinG7UC5;qj7B0$TYsZ{Mp z>=*8r>}dd`NJ@6>Sye?zD4@~kK7IPaU^b5v#r|NvkfS6RiaHBaor#CJ7%BFRjgxrB zoclltshl?(8(UlKdMemFO|UW&Y7r-28{Hs#@%#bc)Mvx>iph|PrJK3AM2LdM~} zNIZ6`j|-v+PGM7#2omTf9GepCm+kJPDOc-SMoFQ7I(n+SE3k^%f{GVz*6lAa}E5=bSI>RJ_F z@kMVIr;(y% zT(qVA-^l!LadRbLof!-a*ue7Us?sAo!Ff_8a67D?z>$EEjMHQ!v~!ab-XUvhXu-+k z5`$2#f;f^4O2!NJ-+%vy{bO+4BS-B5EXMJSM?TO7$z({T0e}TgDt+#dXG0A_S^?`C zfY9$G$*GrJFCO3>1|Q9}E~6N3o^qfqX8b-5d~XBylFDfDmN|pV_EGzD|FYHYo!uR7 zpZ5;W4tr-iJGUoCp9Xs;+n49(cisKZcUK;1)6iyvwTzI#;q?(CA?KY>$G1OqZtm^| zz3%qyZU3iUzt?Z=TwL7UcG^eRgCD!?v;N0zPY%_nBBb}>v0Y&F}Tx|hxV-r4Q$ zUhnGT-rmhguXEfv>L2dh-gNe_PL8jey>rj_j)&qQ*W@<_>#P_9FUMNInXJPbyFe2y6w__DN`(N8ctp5v~S7?EzxoAJ5pDlJ`k8T3y5 z+8Z3jER@_xEZ9IO7gB1T41O0i*y2#o|GiK|6E)TAzv0_z2=0gzoGCcip>(TFi5LXj zGZ2C+W6%kcz<4O|c+AsKRW1QkBNixl5rq=KK3MLU^28^&`K-%kflLI^wlqK+KC)J` z`QQ~VQ!#=|Xs*5UGJWjTcFPv1ZXyJ(Zt6b2i4gbdO@y$gHWdsmRSeVUTf@SIfCf}H zDTFqSp-aKMNq65|wRIltiBM?*7U67~&X9saBE2_k9!u2#Sp2|{OeA1^P>?n=d<7>4 zk!Xz5XcB-gP2g#M@z@baG6Xs4L?&iz2sC^JP>=*4jnmYN{H3o*v{1#R#(m zC750K&CvY1qR2Iuc-}#)0cB9ED~MJ6n$nBtgfdEINnK;csbW4nmL@|>4NDKkkyxMA zh61&Z_%n%Z?Ty#D*cn`}0d=$8JHOd(dsJ6foLt1gnxB^#*v@>FW8ivlojcG%;2Hvc?3Jo-hB|?#f{TOBOpRuK7I8OU1wBPf4@j+Jwu5pm95!l~Y z>v=f6*x0KHaob7;s~v@cm~l=-pzn-I1UIw6&&OlxYk2giri3&f^oa^v@Y%EMW|5w` zuT&+F9W^g43%PshV>BI=D29qR>Y?T+P^e@doL)AXt!7>fv4lqxq3iNQ{0n;8#As9% zp+w*oYQelft29n-({ij!WKp(xb?NTQmv*`@U)yM9YsY9YzoSEEf8AoZvL#ww*kpk` zM}^BGPD8_CW9n+if+_mkRvVQc8fAKbehIcuQ3{Pwl$2ary?Lwa?W{Z3;<>uq>1Nr; zeZiF|Nv{eoWUtJ>=)gf@GjMaxhpR<=S*4@A88*eM1>=*xF06Nu{~DZXH{DLZ;6}yG z1GH-$k46HZYltC|dZmap%hwoXnX@s8;7RgGk*bxBLmEZUC_p6Dq)@g}FPjdywlw^n znFuCs? z9Yq5*T_x@zDw88L!C_W48JXgL^@9p##>cx0}s6 za;=VcqFWkPw7j;dvUDlh^qQ@9DW*$v8n^WYGQL1YRA)J_Ignv$>D)X|@5&j@(Pw4* zpluJc5+a;HTLZl)Mn!%DqnWuZX;c^_x%%jWDog#!oLt&t4z9CYmCCL{xJbnxksxlr>uTH((Kdf=?G~6;Jd8k}l3k=Tj1|4} z-tCt`uYdC8^y2#D$CE3M^%y7^l9y&MLrbbBAkQ#0B9+n^nazR;ahqavhzTJwogAYW z%}5$BLwQ16{}$Si6RUqBQ?1x2FZ&St3KnzF^qWuSK)b1A=;flqRtQ>F5|;_tl+-tu zol}wlD(Q(y$$FT&K##q^pbx`Ly1qT@FqoW1%00&(Pg}_~#sn&s+OfYP$HCJzFYiS6 zzwGa?{E4(u1=la_PUW;I8!%!vX2v7MWqu50x1B&KN~HP5O2-1>#Rd&kgzW~J*7d5Kojc_@fdVc4Dev)JKn%(QZ4Dv}L|?8H7!3n)j=E}WtWCK# zomb$3n7$xD0ahTw8MR&Jr36ADm1KX6!F|v;d7gaPE*tEisjkr|{0nPw_g0Cdf9)X)b2EICNm7KJ?Hbaz~ttCKd zP%RtG_3xuf+UN>xiisP5UU)@7m8J~@69JqJ@v<*oAU@0}q9jE}op&3Is#b>1g;fW)3 zBX7%J-56v7X>%cReY|Vnus6NIBKr|p2R@ZncoVDZD}60Z+mwp*c)Mb`|L3xKd9TgJ V=#81-obtCFuR3~FUL?>x{s+TclFI-9 literal 0 HcmV?d00001 From e40d26776c1079a4e6f5dbaf98311ae7d6fbeba7 Mon Sep 17 00:00:00 2001 From: jayteemoney Date: Fri, 2 Oct 2026 04:24:08 +0100 Subject: [PATCH 3/6] refactor(frontend): require resolved metadata in tx builders and amounts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Transaction builders took a bare ftName string, so a caller could pass an unverified one and get no compiler error. They now take a ResolvedToken, making an unproven asset name unrepresentable. getTokenBalance also took (contractId, ftName) separately; since the Hiro balances endpoint keys on contractId::assetName, a wrong asset name reads zero rather than erroring, which surfaced as "Insufficient balance. You have 0.00". It now takes the same ResolvedToken and matches exactly, with no fallback to "any asset in this contract" — a balance in sbtc-token-locked is not a balance of sbtc-token. pickPrimaryToken returned a TokenConfig, so it had to fall back to a default for uncurated contracts. It now returns the dominant contract id and callers resolve it, keeping the "never guess" property. Amount entry uses exact string/bigint conversion instead of parseFloat(amount) * 10**decimals, which loses precision above 2^53 raw units (~90M at 8 decimals) and rounded silently. --- frontend/src/hooks/use-token-balance.ts | 48 ++++++--- frontend/src/lib/stacks.ts | 126 ++++++++++++++++++++---- frontend/src/lib/utils.ts | 58 +++++++---- 3 files changed, 183 insertions(+), 49 deletions(-) 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/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/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}`; From 5c493953984210292927e0b320b40da88ba3ccca Mon Sep 17 00:00:00 2001 From: jayteemoney Date: Fri, 2 Oct 2026 04:24:22 +0100 Subject: [PATCH 4/6] fix(frontend): stop mislabelling and mis-scaling uncurated tokens MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every page that rendered or transacted on a stream read decimals and symbol from the fallback-prone config. Now each resolves the stream's own token and renders "—" or a refusal while unresolved, so a wrong number is never shown in place of a right one. Claim and top-up dialogs resolved tokens via SUPPORTED_TOKENS.find(...)?? DEFAULT_TOKEN, so topping up a USDA stream built a post-condition naming sbtc-token. Both now resolve from chain, explain an unverifiable token, and disable the submit button rather than failing opaquely at the wallet. Aggregate pages (earn, dashboard, analytics) reduce only streams sharing the dominant token and label the rest as a footnote, since summing raw amounts across 6- and 8-decimal tokens is meaningless. The OpenClaw widget read a "formatted.deposit" field the API never returned, so every amount fell through to an 8-decimal default and was labelled DEFAULT_TOKEN — the same 100x error, in the assistant's answer. It now reads the real fields and shows "—" when the API could not determine a scale. --- frontend/src/app/api/daos/[admin]/route.ts | 16 +++- frontend/src/app/api/streams/[id]/route.ts | 10 +- frontend/src/app/dashboard/analytics/page.tsx | 39 +++++--- frontend/src/app/dashboard/create/page.tsx | 44 +++++---- frontend/src/app/dashboard/page.tsx | 75 ++++++++++----- frontend/src/app/dashboard/streams/page.tsx | 41 +++++--- frontend/src/app/earn/history/page.tsx | 16 +++- frontend/src/app/earn/page.tsx | 42 ++++++++- frontend/src/app/earn/streams/page.tsx | 48 ++++++---- frontend/src/components/layout/header.tsx | 2 +- .../components/openclaw/assistant-widget.tsx | 40 +++++++- .../src/components/stream/claim-dialog.tsx | 94 +++++++++++++------ .../src/components/stream/stream-card.tsx | 47 +++++++--- .../src/components/stream/top-up-dialog.tsx | 85 ++++++++++++----- .../src/components/wallet/mint-dialog.tsx | 14 ++- 15 files changed, 443 insertions(+), 170 deletions(-) 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 aec821f..46e8117 100644 --- a/frontend/src/app/api/streams/[id]/route.ts +++ b/frontend/src/app/api/streams/[id]/route.ts @@ -6,6 +6,8 @@ import { getRefundableAmount, getCurrentBlockHeight, getTokenDecimals, + getTokenSymbol, + tokenDisplayLabel, getStreamStatusLabel, getStreamProgress, formatTokenAmount, @@ -33,7 +35,7 @@ export async function GET( return jsonResponse({ error: "Stream not found" }, 404); } - const [claimable, streamed, remaining, refundable, currentBlock, decimals] = + const [claimable, streamed, remaining, refundable, currentBlock, decimals, symbol] = await Promise.all([ getClaimableBalance(id), getStreamedAmount(id), @@ -41,6 +43,7 @@ export async function GET( getRefundableAmount(id), getCurrentBlockHeight(), getTokenDecimals(stream.token), + getTokenSymbol(stream.token), ]); const progress = getStreamProgress( @@ -61,6 +64,11 @@ export async function GET( currentBlock, progress: Math.round(progress * 100) / 100, 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) 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

From e3d799d10263c2c0b1dbfb1762f5824cbd654dc2 Mon Sep 17 00:00:00 2001 From: jayteemoney Date: Fri, 2 Oct 2026 04:24:41 +0100 Subject: [PATCH 5/6] fix(api): publish token decimals and stop formatting the DAO total MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit /daos/:admin published totalDepositedFormatted at a hardcoded 8 decimals. That value is not an amount of anything: 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 cannot be rendered correctly at any scale. It now returns the raw integer plus totalDepositedIsCrossTokenAggregate, and consumers show it unformatted. A real fix needs a contract change to key the total by token. /streams/:id additionally returns tokenDecimals and tokenLabel. Clients that format amounts themselves previously had no way to learn the scale, so they guessed 8 — which is how a 1.2 USDA deposit surfaced as 0.012 in the assistant widget. --- frontend/src/lib/openclaw-server.ts | 54 +++++++++++++++++++++++++++-- 1 file changed, 51 insertions(+), 3 deletions(-) diff --git a/frontend/src/lib/openclaw-server.ts b/frontend/src/lib/openclaw-server.ts index ec80666..916b8a0 100644 --- a/frontend/src/lib/openclaw-server.ts +++ b/frontend/src/lib/openclaw-server.ts @@ -119,8 +119,49 @@ export async function getTokenDecimals( } } -export async function getStream(streamId: number): Promise { - const result = await callReadOnly(STREAM_MANAGER_CONTRACT, "get-stream", [ +// 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; @@ -215,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); From 84127837db21db6cee110edb091dcd4a18405f02 Mon Sep 17 00:00:00 2001 From: jayteemoney Date: Fri, 2 Oct 2026 04:25:00 +0100 Subject: [PATCH 6/6] fix(openclaw): read token decimals before formatting amounts The standalone Express service had the same 8-decimal assumption as the Next.js routes, so the published read API and the OpenClaw skill reported 6-decimal amounts at 1/100th of their value. Adds getTokenDecimals/getTokenSymbol with per-token caching (both are immutable for a deployed token), makes the decimals argument required so a forgotten one is a compile error, and exposes tokenDecimals plus tokenLabel so consumers can verify the scale instead of trusting it. Mirrors the Next.js DAO change: no totalDepositedFormatted. --- openclaw-service/src/routes/daos.ts | 12 ++++- openclaw-service/src/routes/streams.ts | 46 ++++++++++++++---- openclaw-service/src/routes/tokens.ts | 9 +++- openclaw-service/src/stacks-client.ts | 66 ++++++++++++++++++++++++++ openclaw-service/src/utils.ts | 11 ++++- 5 files changed, 128 insertions(+), 16 deletions(-) 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);