From 78f8cfe7bdcc22a6d480e63bfea00c3c4d3fc053 Mon Sep 17 00:00:00 2001 From: jayteemoney Date: Mon, 5 Oct 2026 07:35:26 +0100 Subject: [PATCH 1/6] fix(frontend): make token resolution network-aware and retry transient misses The contract-id check accepted only SP/SM principals, so on a testnet build (the default when NEXT_PUBLIC_NETWORK is unset) the mock token could never resolve and every stream showed as unverifiable. Ids from both networks are now well formed; contractIdNetwork() says which network one belongs to, and the resolver, the curated list and the token search are scoped to the network the app runs on. Pasting a contract from the other network now says so instead of failing as "could not verify". The resolver cached failures for the life of the instance, so one 429 or timeout from the node marked a valid token unverifiable until a cold start. Hits are still cached forever (decimals, symbol and asset name are fixed at deploy); misses now expire after 60s, and concurrent lookups share one request. The API routes had a second, separately cached copy of the decimals and symbol readers; they now use the same resolver. Also removes verifySelection's two unused parameters, corrects its comment about mismatched listings (it refuses, it does not hand back chain values), and replaces the literal NUL byte in token-metadata.test.ts with "\0" so git stops treating the file as binary and PR diffs show it. --- .../src/components/stream/token-selector.tsx | 21 ++- frontend/src/hooks/use-token-search.ts | 3 +- frontend/src/lib/constants.ts | 3 +- frontend/src/lib/openclaw-server.ts | 53 +----- frontend/src/lib/token-metadata-client.ts | 155 ++++++++++++------ frontend/src/lib/token-metadata.ts | 49 +++++- frontend/src/lib/token-registry.ts | 39 +++-- tests/token-metadata-client.test.ts | 115 +++++++++++++ tests/token-metadata.test.ts | Bin 7872 -> 10232 bytes tests/token-registry.test.ts | 16 +- 10 files changed, 304 insertions(+), 150 deletions(-) create mode 100644 tests/token-metadata-client.test.ts diff --git a/frontend/src/components/stream/token-selector.tsx b/frontend/src/components/stream/token-selector.tsx index c730984..dc358bb 100644 --- a/frontend/src/components/stream/token-selector.tsx +++ b/frontend/src/components/stream/token-selector.tsx @@ -38,6 +38,8 @@ import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { useCuratedTokens, useTokenSearch } from "@/hooks/use-token-search"; import { resolveTokenMetadata } from "@/lib/token-metadata-client"; +import { contractIdNetwork } from "@/lib/token-metadata"; +import { NETWORK, NETWORK_LABEL } from "@/lib/constants"; import { MAX_RESULTS, classifyAll, @@ -274,12 +276,7 @@ export function TokenSelector({ value, onChange, disabled }: TokenSelectorProps) const verify = async (candidate: DiscoveredToken): Promise => { setVerifying(true); try { - const result = await verifySelection( - candidate, - curatedIds, - curatedSymbols, - resolveTokenMetadata, - ); + const result = await verifySelection(candidate, resolveTokenMetadata); if (result.status === "verified" && result.resolved) { onChange(toSelection(result.resolved, candidate)); @@ -349,6 +346,14 @@ export function TokenSelector({ value, onChange, disabled }: TokenSelectorProps) */ const selectByContractId = async (contractId: ContractId) => { setManualError(null); + // Checked before any chain read: the node would answer a wrong-network id + // with a bare "not found", which reads as if the token does not exist. + if (contractIdNetwork(contractId) !== NETWORK) { + setManualError( + `That contract is not on ${NETWORK_LABEL}. Check you copied the ${NETWORK_LABEL.toLowerCase()} contract id.`, + ); + return; + } setVerifying(true); try { // The chain is the authority here, and it is the only source: the @@ -405,7 +410,7 @@ export function TokenSelector({ value, onChange, disabled }: TokenSelectorProps) const parsed = parseTokenDeepLink(manual); if (!isValidContractId(parsed)) { setManualError( - "That is not a valid contract id. It should look like SP….contract-name.", + `That is not a valid contract id. It should look like ${NETWORK === "mainnet" ? "SP" : "ST"}….contract-name.`, ); return; } @@ -589,7 +594,7 @@ export function TokenSelector({ value, onChange, disabled }: TokenSelectorProps) setManual(e.target.value); setManualError(null); }} - placeholder="SP….contract-name" + placeholder={`${NETWORK === "mainnet" ? "SP" : "ST"}….contract-name`} disabled={disabled || verifying} autoComplete="off" spellCheck={false} diff --git a/frontend/src/hooks/use-token-search.ts b/frontend/src/hooks/use-token-search.ts index 4aca672..dbf9604 100644 --- a/frontend/src/hooks/use-token-search.ts +++ b/frontend/src/hooks/use-token-search.ts @@ -21,6 +21,7 @@ import { type DiscoveredToken, } from "@/lib/token-registry"; import { getCuratedTokens } from "@/lib/token-metadata"; +import { NETWORK } from "@/lib/constants"; /** * Ids and symbols of the hand-verified tokens, used to catch impersonators. @@ -145,5 +146,5 @@ export function useTokenSearch(query: string): TokenSearchState { * useless, the selector still offers the tokens we have verified ourselves. */ export function useCuratedTokens() { - return getCuratedTokens(); + return getCuratedTokens(NETWORK); } diff --git a/frontend/src/lib/constants.ts b/frontend/src/lib/constants.ts index bba12e6..8e50806 100644 --- a/frontend/src/lib/constants.ts +++ b/frontend/src/lib/constants.ts @@ -109,6 +109,7 @@ export { isValidContractId, isValidDecimals, toRawAmount, + hasExcessPrecision, fromRawAmount, unresolvableTokenLabel, type ResolvedToken, @@ -143,7 +144,7 @@ if (!IS_MAINNET) { * 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 = getCuratedTokens(); +export const SUPPORTED_TOKENS = getCuratedTokens(NETWORK); /** * Default token for the create-stream form (first in the selector). diff --git a/frontend/src/lib/openclaw-server.ts b/frontend/src/lib/openclaw-server.ts index 916b8a0..ff454e8 100644 --- a/frontend/src/lib/openclaw-server.ts +++ b/frontend/src/lib/openclaw-server.ts @@ -96,54 +96,11 @@ 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; - } -} - -// 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; - } -} +// SIP-010 decimals and symbol come from the same memoized resolver the UI +// uses, so the API and the app can never disagree about a token's scale, and a +// transient node failure is retried after a short TTL instead of being cached +// for the life of the instance. +export { getTokenDecimals, getTokenSymbol } from "./token-metadata-client"; /** * A display label for a token contract, preferring the on-chain symbol and diff --git a/frontend/src/lib/token-metadata-client.ts b/frontend/src/lib/token-metadata-client.ts index e3f36c3..8bf8ff2 100644 --- a/frontend/src/lib/token-metadata-client.ts +++ b/frontend/src/lib/token-metadata-client.ts @@ -18,10 +18,10 @@ import { cvToJSON, type ClarityValue, } from "@stacks/transactions"; -import { HIRO_API_BASE } from "./constants"; +import { HIRO_API_BASE, NETWORK } from "./constants"; import { isValidAssetName, - isValidContractId, + isContractIdOnNetwork, isValidDecimals, pickUnambiguousAssetName, getCuratedToken, @@ -45,7 +45,7 @@ async function callReadOnly( functionName, functionArgs: args, senderAddress: contractAddress, - network: process.env.NEXT_PUBLIC_NETWORK === "mainnet" ? "mainnet" : "testnet", + network: NETWORK, }); return cvToJSON(result); } @@ -55,19 +55,79 @@ async function callReadOnly( // ============================================================================ /** - * 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. + * Memoize an async chain read whose successful result never changes. * - * 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. + * SIP-010 fixes decimals, symbol and the asset name at deploy time, so a hit + * is cached for the life of the module (per serverless instance, or per page + * load in the browser). It is deliberately not persisted, so a redeploy or a + * token migration always starts clean. + * + * A miss is different: it is usually a rate limit, a timeout or a node hiccup, + * not a fact about the token. Caching it forever would make one 429 mark a + * valid token "unverifiable" until the instance restarts. Misses are therefore + * cached for `NEGATIVE_TTL_MS` only, which still stops a broken contract from + * turning every render into a request. + * + * Concurrent calls for the same key share one in-flight promise, so a page + * rendering twenty streams in one token makes one round-trip, not twenty. */ -const cache = new Map(); +const NEGATIVE_TTL_MS = 60_000; + +interface Memo { + hits: Map; + misses: Map; + inflight: Map>; +} + +const memos: Memo[] = []; + +function createMemo(): Memo { + const memo: Memo = { hits: new Map(), misses: new Map(), inflight: new Map() }; + memos.push(memo as Memo); + return memo; +} + +function memoized( + memo: Memo, + key: string, + load: () => Promise +): Promise { + const hit = memo.hits.get(key); + if (hit !== undefined) return Promise.resolve(hit); + + const missUntil = memo.misses.get(key); + if (missUntil !== undefined && missUntil > Date.now()) return Promise.resolve(null); -/** Test seam: drop the memoized metadata. */ + const pending = memo.inflight.get(key); + if (pending) return pending; + + const request = load() + .catch(() => null) + .then((value) => { + memo.inflight.delete(key); + if (value === null) { + memo.misses.set(key, Date.now() + NEGATIVE_TTL_MS); + } else { + memo.hits.set(key, value); + memo.misses.delete(key); + } + return value; + }); + memo.inflight.set(key, request); + return request; +} + +const decimalsMemo = createMemo(); +const symbolMemo = createMemo(); +const resolvedMemo = createMemo(); + +/** Test seam: drop every memoized read, hits and misses alike. */ export function clearTokenMetadataCache(): void { - cache.clear(); + for (const memo of memos) { + memo.hits.clear(); + memo.misses.clear(); + memo.inflight.clear(); + } } // ============================================================================ @@ -81,31 +141,28 @@ export function clearTokenMetadataCache(): void { * `{ 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 { +export function getTokenDecimals(contractId: string): Promise { + if (!isContractIdOnNetwork(contractId, NETWORK)) return Promise.resolve(null); + return memoized(decimalsMemo, contractId, async () => { 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. + * Read SIP-010 `get-symbol`. Display only, never used in post-conditions. */ -async function getTokenSymbol(contractId: string): Promise { - try { +export function getTokenSymbol(contractId: string): Promise { + if (!isContractIdOnNetwork(contractId, NETWORK)) return Promise.resolve(null); + return memoized(symbolMemo, contractId, async () => { 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; + const raw = typeof result.value?.value === "string" ? result.value.value.trim() : ""; + if (raw.length === 0 || raw.length > 32) return null; return /^[\x20-\x7e]+$/.test(raw) ? raw : null; - } catch { - return null; - } + }); } /** @@ -120,7 +177,7 @@ async function getTokenSymbol(contractId: string): Promise { * see `pickUnambiguousAssetName` for why several is a refusal, not a guess. */ export async function getTokenAssetName(contractId: string): Promise { - if (!isValidContractId(contractId)) return null; + if (!isContractIdOnNetwork(contractId, NETWORK)) return null; const [deployer, contractName] = splitContract(contractId); try { const res = await fetch( @@ -159,23 +216,20 @@ export async function getTokenAssetName(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 = { + return { contractId: curated.contractId, assetName: curated.assetName, decimals: curated.decimals, symbol: curated.symbol, curated: true, }; - } else { + } + + if (!isContractIdOnNetwork(contractId, NETWORK)) return null; + + return memoized(resolvedMemo, contractId, async () => { // 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([ @@ -183,24 +237,17 @@ export async function resolveTokenMetadata( 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; + if (assetName === null || decimals === null) return null; + return { + 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, + }; + }); } /** diff --git a/frontend/src/lib/token-metadata.ts b/frontend/src/lib/token-metadata.ts index 49931b1..10c84f0 100644 --- a/frontend/src/lib/token-metadata.ts +++ b/frontend/src/lib/token-metadata.ts @@ -167,9 +167,13 @@ export function getCuratedToken(contractId: string): TokenConfig | null { /** * Curated tokens, in selector display order. This is a UI seed list, NOT a * protocol allowlist — see the module docstring. + * + * Pass `network` to get only the tokens deployed there, so a testnet build + * never offers a mainnet contract its wallet cannot transact with. */ -export function getCuratedTokens(): readonly TokenConfig[] { - return [...CURATED, ...EXTRA]; +export function getCuratedTokens(network?: StacksNetworkName): readonly TokenConfig[] { + const all = [...CURATED, ...EXTRA]; + return network ? all.filter((t) => contractIdNetwork(t.contractId) === network) : all; } // ============================================================================ @@ -196,13 +200,38 @@ 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 type StacksNetworkName = "mainnet" | "testnet"; + +/** + * Shape check for `deployer.contract-name`, run before an id is used in a URL + * or rendered. Accepts both networks' version bytes: `SP`/`SM` are mainnet, + * `ST`/`SN` are testnet. Which network an id belongs to is a separate question, + * answered by `contractIdNetwork`, so this stays a pure shape filter. + */ +const CONTRACT_ID_RE = /^S[PMTN][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); } +/** The network a well-formed contract id belongs to, from its version byte. */ +export function contractIdNetwork(contractId: string): StacksNetworkName | null { + if (!isValidContractId(contractId)) return null; + return contractId[1] === "P" || contractId[1] === "M" ? "mainnet" : "testnet"; +} + +/** + * True when the id is well formed AND deployed on `network`. A mainnet app + * reading a testnet contract gets a confusing "not found" from the node, so + * callers check this first and say which network the id is actually on. + */ +export function isContractIdOnNetwork( + contractId: unknown, + network: StacksNetworkName +): contractId is string { + return isValidContractId(contractId) && contractIdNetwork(contractId) === network; +} + /** * Parse a `define-fungible-token` list from `/v2/contracts/interface` into the * single asset name we can prove. @@ -269,6 +298,18 @@ export function toRawAmount(amount: string, decimals: number): bigint | null { return raw > 0n ? raw : null; } +/** + * True when `amount` has more fractional digits than the token can represent. + * + * `toRawAmount` truncates those digits rather than round up, so it never sends + * more than the user typed. Truncating silently would still send less than they + * typed, so forms check this first and ask the user to fix the amount. + */ +export function hasExcessPrecision(amount: string, decimals: number): boolean { + const fraction = amount.trim().split(".")[1] ?? ""; + return fraction.replace(/0+$/, "").length > decimals; +} + /** Convert raw token units into a decimal string, without floating point. */ export function fromRawAmount(raw: bigint, decimals: number): string { if (!isValidDecimals(decimals)) return "0"; diff --git a/frontend/src/lib/token-registry.ts b/frontend/src/lib/token-registry.ts index df01a63..0ace554 100644 --- a/frontend/src/lib/token-registry.ts +++ b/frontend/src/lib/token-registry.ts @@ -35,7 +35,8 @@ * suggest a token, or refuse to suggest one. */ -import type { ResolvedToken } from "./token-metadata"; +import { isValidContractId as isWellFormedContractId, type ResolvedToken } from "./token-metadata"; +import { HIRO_API_BASE } from "./constants"; /** Fully-qualified `deployer.contract-name`, e.g. "SP2C2…ZM.usda-token". */ export type ContractId = string; @@ -144,8 +145,10 @@ interface RegistryListResponse { // Configuration // ============================================================================ +// Defaults to the Hiro API for the network the app runs on, so a testnet build +// searches testnet tokens rather than offering mainnet contracts it cannot use. const API_BASE = - process.env.NEXT_PUBLIC_HIRO_API_BASE?.replace(/\/$/, "") ?? "https://api.hiro.so"; + process.env.NEXT_PUBLIC_HIRO_API_BASE?.replace(/\/$/, "") ?? HIRO_API_BASE; /** Registry page size. The API caps this at 60. */ export const REGISTRY_PAGE_SIZE = 60; @@ -165,8 +168,6 @@ export const MAX_RESULTS = 25; // Pure helpers — no network, fully unit-tested // ============================================================================ -const CONTRACT_ID_RE = /^S[PM][A-Z0-9]{38,40}\.[a-zA-Z0-9\-_!?+<>=/*]{1,128}$/; - /** * A row from `/metadata/v1/search`. * @@ -196,7 +197,7 @@ export interface RegistrySearchRow { * capability. Its job is to reject typos and hostile strings early. */ export function isValidContractId(value: unknown): value is ContractId { - return typeof value === "string" && CONTRACT_ID_RE.test(value); + return isWellFormedContractId(value); } /** @@ -452,14 +453,14 @@ export function candidateFromResolved( export type VerifyStatus = /** Curated, or uncurated but fully verified on-chain. */ | "verified" - /** Chain verification disagrees with the registry; treated as unverified. */ + /** The listing disagrees with the chain. Not selectable until the user re-picks. */ | "unverified" /** Cannot be verified, so it must not be streamed. */ | "unverifiable"; export interface VerifiedSelection { status: VerifyStatus; - /** Chain-proven metadata. Null unless `status` is "verified". */ + /** Chain-proven metadata. Null unless `status` is "verified", so nothing else can be streamed. */ resolved: ResolvedToken | null; /** Human-readable reason, shown when status is not "verified". */ reason?: string; @@ -475,21 +476,20 @@ export interface VerifiedSelection { * * - `assetName` and `decimals` MUST come from the chain resolver. The * registry is never allowed to supply either, no matter how confident it - * looks. I verified the registry's decimals match the chain 25/25, but - * "has always agreed" is not a property a payment can depend on, and an - * indexer is one deploy behind the chain forever. + * looks. It has agreed with the chain in every spot check, but "has always + * agreed" is not a property a payment can depend on, and an indexer is one + * deploy behind the chain forever. * - If the registry and the chain disagree on either field, the result is * downgraded to "unverified" rather than trusting either side. * - A null from the resolver is "unverifiable", never a fallback to a default - * token. That mistake is what the merged PR fixed. + * token. Falling back to sBTC is what used to put the wrong asset name in + * post-conditions. * * Verified-but-new tokens resolve to `status: "verified"` with no friction, * which is what keeps the selector from blocking legitimate team onboarding. */ export async function verifySelection( discovered: DiscoveredToken, - curatedIds: readonly ContractId[], - curatedSymbols: ReadonlyMap, resolve: (contractId: ContractId) => Promise, ): Promise { const impersonates = discovered.impersonates; @@ -522,10 +522,10 @@ export async function verifySelection( const assetAgrees = resolved.assetName === discovered.assetName; if (!decimalsAgree || !assetAgrees) { - // The chain is authoritative, so we still hand back the chain values — - // but the user is told the listing disagreed, because that discrepancy is - // itself a signal worth surfacing (a mismatched asset name can mean a - // renamed token, a proxy, or something hostile). + // Refuse rather than silently swap in the chain values: the user picked a + // listing that described a different asset, and a mismatched asset name + // can mean a renamed token, a proxy, or something hostile. They can still + // stream it by entering the contract id, which verifies from the chain. return { status: "unverified", resolved: null, @@ -536,10 +536,9 @@ export async function verifySelection( }; } - // An impersonator still resolves on-chain — the contract genuinely is that - // token — so verification alone cannot catch it. It is reported as verified + // An impersonator still resolves on-chain (the contract genuinely is that + // token), so verification alone cannot catch it. It is reported as verified // but carries its impersonation forward so the UI can flag it explicitly. - void curatedIds; return { status: "verified", resolved, impersonates }; } diff --git a/tests/token-metadata-client.test.ts b/tests/token-metadata-client.test.ts new file mode 100644 index 0000000..d6a5367 --- /dev/null +++ b/tests/token-metadata-client.test.ts @@ -0,0 +1,115 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; + +// The chain is mocked: these tests are about the cache policy around the reads, +// not the reads themselves. `NEXT_PUBLIC_NETWORK` is unset here, so the module +// runs as a testnet build and every contract id below is a testnet one. +const readOnly = vi.fn(); +// Mocked by path: the frontend resolves its own copy of the package, which is a +// different module from the one at the repo root. +vi.mock("../frontend/node_modules/@stacks/transactions", async (importOriginal) => { + const actual = await importOriginal>(); + return { + ...actual, + fetchCallReadOnlyFunction: (args: { functionName: string }) => readOnly(args.functionName), + cvToJSON: (value: unknown) => value, + }; +}); + +import { + clearTokenMetadataCache, + getTokenDecimals, + resolveTokenMetadata, +} from "../frontend/src/lib/token-metadata-client"; + +const TOKEN = "ST2CY5V39NHDPWSXMW9QDT3HC3GD6Q6XX4CFRK9AG.team-token"; +// Deliberately not curated: curated entries resolve without a chain read. +const MAINNET_TOKEN = "SP2C2YFP12AJZB4MABJBAJ55XECVS7E4PMMZ89YZR.some-team-token"; + +function chainAnswers() { + readOnly.mockImplementation(async (fn: string) => { + if (fn === "get-decimals") return { success: true, value: { value: "6" } }; + if (fn === "get-symbol") return { success: true, value: { value: "TEAM" } }; + throw new Error(`unexpected ${fn}`); + }); +} + +function interfaceAnswers(fungibleTokens: { name: string }[]) { + return vi.fn(async () => ({ + ok: true, + json: async () => ({ fungible_tokens: fungibleTokens }), + })); +} + +beforeEach(() => { + clearTokenMetadataCache(); + readOnly.mockReset(); + vi.useFakeTimers(); +}); + +afterEach(() => { + vi.useRealTimers(); + vi.unstubAllGlobals(); +}); + +describe("token metadata cache", () => { + it("resolves a testnet token on a testnet build", async () => { + chainAnswers(); + vi.stubGlobal("fetch", interfaceAnswers([{ name: "team" }])); + const resolved = await resolveTokenMetadata(TOKEN); + expect(resolved).toEqual({ + contractId: TOKEN, + assetName: "team", + decimals: 6, + symbol: "TEAM", + curated: false, + }); + }); + + it("refuses a contract from the other network without touching the chain", async () => { + chainAnswers(); + vi.stubGlobal("fetch", interfaceAnswers([{ name: "usda" }])); + expect(await resolveTokenMetadata(MAINNET_TOKEN)).toBeNull(); + expect(readOnly).not.toHaveBeenCalled(); + }); + + it("caches a hit forever", async () => { + chainAnswers(); + expect(await getTokenDecimals(TOKEN)).toBe(6); + vi.advanceTimersByTime(24 * 60 * 60 * 1000); + expect(await getTokenDecimals(TOKEN)).toBe(6); + expect(readOnly).toHaveBeenCalledTimes(1); + }); + + it("retries a miss once the negative TTL has passed", async () => { + // A rate limit is not a fact about the token, so it must not stick. + readOnly.mockRejectedValueOnce(new Error("429 Too Many Requests")); + expect(await getTokenDecimals(TOKEN)).toBeNull(); + + chainAnswers(); + // Still inside the TTL: the miss is served from cache, no new request. + vi.advanceTimersByTime(30_000); + expect(await getTokenDecimals(TOKEN)).toBeNull(); + expect(readOnly).toHaveBeenCalledTimes(1); + + vi.advanceTimersByTime(31_000); + expect(await getTokenDecimals(TOKEN)).toBe(6); + expect(readOnly).toHaveBeenCalledTimes(2); + }); + + it("shares one in-flight request between concurrent callers", async () => { + chainAnswers(); + const results = await Promise.all([ + getTokenDecimals(TOKEN), + getTokenDecimals(TOKEN), + getTokenDecimals(TOKEN), + ]); + expect(results).toEqual([6, 6, 6]); + expect(readOnly).toHaveBeenCalledTimes(1); + }); + + it("still refuses a multi-asset contract rather than guessing", async () => { + chainAnswers(); + vi.stubGlobal("fetch", interfaceAnswers([{ name: "team" }, { name: "team-locked" }])); + expect(await resolveTokenMetadata(TOKEN)).toBeNull(); + }); +}); diff --git a/tests/token-metadata.test.ts b/tests/token-metadata.test.ts index 69c9065b4815ff1ee566e2b934cf42a6d27c329b..9c0eb0bf6fed894a02d6577adec5d2f53237c65a 100644 GIT binary patch delta 1814 zcmb7F&2AG{6c!{x6+a@5xRa0=k&p?n|SQh^&cpcWw*)P@|PErS*q*+Q|TK_QEQhaRlpo>b%% zaSc7DL3cYHsSJkn3PDG#gAha*v<3+k_FrLO&G;JC0aRJun~Q>_)Zo<4f|r0#D&-r4=-dGTRo=jZi} z>c;c>+O2wJ_i=S`yS7!?t*m=?o@LEDe$ih}$8zpby5#q%wY&7qjq9)=zz)@GNE5*j zE4^eZrfkss8ffGm#xW2IZ48rlGw!tQCHb7Yn*1?yVY<3fTTOrbHFNQLb?1BCWzFp; z^&IpW6|gX7JnbpjPG1mq#H)bDfx)jh1HkqMcf1%Na^e zlAl|=W;c@<9TQ9lTlk57R2d0G@fBzF_~oTD7r+@#oBW+yILU2DK=OWeaiW`L>rggw zTbfj~D?;4k0z>l6Qr2^e>pGAf94t@JRm>h-%;`>y1S!S~)r(Nj3b!ykvPA?8d1o0v zPyg7@(jTF5=;FmFnkE{oSB2EUpsGboxc=80mbs_b@46-xn~a6=t{QLrj$F!R_#vxL zz|JR^&Ry~JV!W&eQRJ#}PAR>upqDgy2r$ROg i8vl)q)Ma9U!7wG?7Ahsw=fWhno1UL~_{l%BbN>UIOk3Ij delta 23 fcmez2f53LbJI2jMO!Wec43lewj5a?NQs4mqcT5O` diff --git a/tests/token-registry.test.ts b/tests/token-registry.test.ts index 4107d41..4404037 100644 --- a/tests/token-registry.test.ts +++ b/tests/token-registry.test.ts @@ -390,8 +390,6 @@ describe("verifySelection", () => { // a proxy, or something hostile. const out = await verifySelection( discovered({ contractId: USDA, decimals: 6, assetName: "usda" }), - CURATED_IDS, - CURATED_SYMBOLS, async () => ({ contractId: USDA, assetName: "usda-real", @@ -408,8 +406,6 @@ describe("verifySelection", () => { it("verifies an uncurated token with no friction once the chain agrees", async () => { const out = await verifySelection( discovered(), - CURATED_IDS, - CURATED_SYMBOLS, stubResolved({ contractId: NEW_TOKEN, assetName: "nt", @@ -423,9 +419,9 @@ describe("verifySelection", () => { }); it("treats a resolver failure as unverifiable, never as a default token", async () => { - // Regression guard for the exact bug the merged PR fixed: substituting a + // Regression guard for the old sBTC fallback: substituting a // fallback here would silently stream sBTC. - const out = await verifySelection(discovered(), CURATED_IDS, CURATED_SYMBOLS, stubResolved(null)); + const out = await verifySelection(discovered(), stubResolved(null)); expect(out.status).toBe("unverifiable"); expect(out.resolved).toBeNull(); }); @@ -437,8 +433,6 @@ describe("verifySelection", () => { trust: "unusable", unusableReason: "No asset name — this listing cannot identify the token it describes", }), - CURATED_IDS, - CURATED_SYMBOLS, async () => { called = true; return null; @@ -458,8 +452,6 @@ describe("verifySelection", () => { trust: "unverified", warnings: ["This listing publishes no ticker."], }), - CURATED_IDS, - CURATED_SYMBOLS, stubResolved({ contractId: NEW_TOKEN, assetName: "nt", @@ -483,8 +475,6 @@ describe("verifySelection", () => { trust: "impersonator", impersonates: SBTC, }), - CURATED_IDS, - CURATED_SYMBOLS, stubResolved({ contractId: FAKE_SBTC, assetName: "sBTC", @@ -500,8 +490,6 @@ describe("verifySelection", () => { it("downgrades on a decimals-only mismatch and says so", async () => { const out = await verifySelection( discovered({ decimals: 6 }), - CURATED_IDS, - CURATED_SYMBOLS, stubResolved({ contractId: NEW_TOKEN, assetName: "nt", From 5ab05344d20387fb76704644d1cf4d5d79055cc3 Mon Sep 17 00:00:00 2001 From: jayteemoney Date: Mon, 5 Oct 2026 07:35:26 +0100 Subject: [PATCH 2/6] fix(frontend): reject amounts with more decimals than the token has toRawAmount truncates extra fractional digits so it never sends more than was typed, but doing that silently still sends less than was typed. The create, top-up and claim forms now stop and say how many decimal places the token supports. --- frontend/src/app/dashboard/create/page.tsx | 4 ++++ frontend/src/components/stream/claim-dialog.tsx | 6 +++++- frontend/src/components/stream/top-up-dialog.tsx | 6 +++++- 3 files changed, 14 insertions(+), 2 deletions(-) diff --git a/frontend/src/app/dashboard/create/page.tsx b/frontend/src/app/dashboard/create/page.tsx index 761465f..6c15145 100644 --- a/frontend/src/app/dashboard/create/page.tsx +++ b/frontend/src/app/dashboard/create/page.tsx @@ -16,6 +16,7 @@ import { EXPLORER_BASE, BLOCKS_PER_HOUR, toRawAmount, + hasExcessPrecision, fromRawAmount, type DurationUnit, } from "@/lib/constants"; @@ -91,6 +92,9 @@ export default function CreateStreamPage() { else if (amountRaw === null) { errs.amount = "Enter a valid amount (digits and up to one decimal point)"; } + else if (hasExcessPrecision(amount, selectedToken.decimals)) { + errs.amount = `${selectedToken.symbol} has ${selectedToken.decimals} decimal places. Remove the extra digits.`; + } // 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. diff --git a/frontend/src/components/stream/claim-dialog.tsx b/frontend/src/components/stream/claim-dialog.tsx index 470ecd7..d4e57b2 100644 --- a/frontend/src/components/stream/claim-dialog.tsx +++ b/frontend/src/components/stream/claim-dialog.tsx @@ -8,7 +8,7 @@ import { useStacksTx } from "@/hooks/use-stacks-tx"; import { useTokenMetadata } from "@/hooks/use-token-metadata"; import { buildClaimTx, type StreamData } from "@/lib/stacks"; import { formatTokenAmount, formatTxError } from "@/lib/utils"; -import { toRawAmount, fromRawAmount, unresolvableTokenLabel } from "@/lib/constants"; +import { toRawAmount, fromRawAmount, hasExcessPrecision, unresolvableTokenLabel } from "@/lib/constants"; import { toast } from "sonner"; import { Download, AlertTriangle } from "lucide-react"; @@ -56,6 +56,10 @@ export function ClaimDialog({ setError("Enter a valid amount (digits and up to one decimal point)"); return false; } + if (hasExcessPrecision(amount, token.decimals)) { + setError(`${token.symbol} has ${token.decimals} decimal places. Remove the extra digits.`); + return false; + } if (amountRaw > claimable) { setError(`Max claimable is ${formatTokenAmount(claimable, token.decimals)} ${token.symbol}`); return false; diff --git a/frontend/src/components/stream/top-up-dialog.tsx b/frontend/src/components/stream/top-up-dialog.tsx index e6b8213..f262d10 100644 --- a/frontend/src/components/stream/top-up-dialog.tsx +++ b/frontend/src/components/stream/top-up-dialog.tsx @@ -10,7 +10,7 @@ import { useTokenMetadata } from "@/hooks/use-token-metadata"; import { buildTopUpStreamTx, type StreamData } from "@/lib/stacks"; import { useWalletStore } from "@/stores/wallet-store"; import { formatTokenAmount, formatTxError } from "@/lib/utils"; -import { toRawAmount, fromRawAmount, unresolvableTokenLabel } from "@/lib/constants"; +import { toRawAmount, fromRawAmount, hasExcessPrecision, unresolvableTokenLabel } from "@/lib/constants"; import { toast } from "sonner"; import { ArrowUpCircle, AlertTriangle } from "lucide-react"; @@ -61,6 +61,10 @@ export function TopUpDialog({ setError("Enter a valid amount (digits and up to one decimal point)"); return false; } + if (hasExcessPrecision(amount, token.decimals)) { + setError(`${token.symbol} has ${token.decimals} decimal places. Remove the extra digits.`); + return false; + } if (amountRaw > balance) { setError( `Insufficient ${token.symbol} balance. You have ${formatTokenAmount(balance, token.decimals)} ${token.symbol}.` From 5620a3eabdc563786c641e66829fbac35a2e81e5 Mon Sep 17 00:00:00 2001 From: jayteemoney Date: Mon, 5 Oct 2026 07:35:26 +0100 Subject: [PATCH 3/6] refactor(frontend): clear every lint error so CI can gate on lint Replaces the remaining `any` types at the Clarity JSON and API boundaries with narrow types, types the wallet call options (getNetwork now returns the "mainnet" | "testnet" literal), and drops unused imports. useStreamProgress read Date.now() during render and set state synchronously inside effects. It now derives the value: the animation frame is tagged with the snapshot it extrapolates from and only shown while it matches, so a stale frame can never overwrite a new snapshot. eslint: 18 problems (14 errors, 4 warnings) -> 0. --- frontend/src/app/dashboard/page.tsx | 1 - frontend/src/app/earn/page.tsx | 2 +- .../components/openclaw/assistant-widget.tsx | 72 +++++++++++++---- frontend/src/hooks/use-stacks-tx.ts | 23 +++++- frontend/src/hooks/use-stream-progress.ts | 40 ++++------ frontend/src/lib/stacks.ts | 78 +++++++++++-------- 6 files changed, 136 insertions(+), 80 deletions(-) diff --git a/frontend/src/app/dashboard/page.tsx b/frontend/src/app/dashboard/page.tsx index a94f655..dc7c2bb 100644 --- a/frontend/src/app/dashboard/page.tsx +++ b/frontend/src/app/dashboard/page.tsx @@ -1,7 +1,6 @@ "use client"; import { StatCard } from "@/components/ui/stat-card"; -import { Card, CardTitle } from "@/components/ui/card"; import { Button } from "@/components/ui/button"; import { EmptyState } from "@/components/ui/empty-state"; import { StreamCard } from "@/components/stream/stream-card"; diff --git a/frontend/src/app/earn/page.tsx b/frontend/src/app/earn/page.tsx index 12095f5..b08534f 100644 --- a/frontend/src/app/earn/page.tsx +++ b/frontend/src/app/earn/page.tsx @@ -1,7 +1,7 @@ "use client"; import { StatCard } from "@/components/ui/stat-card"; -import { Card, CardTitle } from "@/components/ui/card"; +import { Card } from "@/components/ui/card"; import { EmptyState } from "@/components/ui/empty-state"; import { Skeleton } from "@/components/ui/skeleton"; import { RealtimeBalance } from "@/components/stream/realtime-balance"; diff --git a/frontend/src/components/openclaw/assistant-widget.tsx b/frontend/src/components/openclaw/assistant-widget.tsx index c0cb438..d58d471 100644 --- a/frontend/src/components/openclaw/assistant-widget.tsx +++ b/frontend/src/components/openclaw/assistant-widget.tsx @@ -5,17 +5,55 @@ import { Button } from "@/components/ui/button"; import { cn } from "@/lib/utils"; import { OPENCLAW_API_URL, unresolvableTokenLabel } from "@/lib/constants"; import { formatTokenAmount, getStreamStatusLabel, getStreamStatusColor } from "@/lib/utils"; -import { MessageCircle, X, Search, Loader2, Zap, Hash, User, Building2 } from "lucide-react"; +import { MessageCircle, X, Search, Zap, Hash, User, Building2 } from "lucide-react"; type QueryType = "stream" | "sender" | "recipient" | "dao" | "block"; interface ResultEntry { type: QueryType; query: string; - data: any; + /** Raw JSON from the API, narrowed to a response type where it is rendered. */ + data: unknown; error?: string; } +// Response shapes from the public read API. Every field is optional because +// the widget renders whatever came back and must not crash on a missing one. + +interface StreamResponse { + streamId?: number; + id?: number; + status?: number | { code?: number; label?: string }; + sender?: string; + recipient?: string; + token?: string; + depositFormatted?: string | null; + depositAmount?: string; + "deposit-amount"?: string; + claimableFormatted?: string | null; + claimable?: string; + tokenDecimals?: number | null; + tokenLabel?: string | null; + progress?: number; +} + +type StreamListResponse = { streamIds?: number[] } | number[]; + +interface DaoResponse { + name?: string; + admin?: string; + isActive?: boolean; + "is-active"?: boolean; + totalStreamsCreated?: number; + "total-streams-created"?: number; + totalDepositedFormatted?: string | null; +} + +interface BlockResponse { + blockHeight?: number; + block_height?: number; +} + async function fetchApi(path: string) { const res = await fetch(`${OPENCLAW_API_URL}${path}`); if (!res.ok) { @@ -50,13 +88,15 @@ function formatApiAmount( return "—"; } -function StreamResult({ data }: { data: any }) { +function StreamResult({ data }: { data: StreamResponse }) { + const statusCode = typeof data.status === "object" ? data.status.code : data.status; + const statusLabel = typeof data.status === "object" ? data.status.label : undefined; return (
Stream #{data.streamId ?? data.id} - - {data.status?.label ?? getStreamStatusLabel(data.status)} + + {statusLabel ?? getStreamStatusLabel(statusCode ?? -1)}
@@ -85,8 +125,8 @@ function StreamResult({ data }: { data: any }) { ); } -function StreamListResult({ data, label }: { data: any; label: string }) { - const ids = data.streamIds ?? data; +function StreamListResult({ data, label }: { data: StreamListResponse; label: string }) { + const ids = Array.isArray(data) ? data : data.streamIds; return (

{label}

@@ -105,7 +145,7 @@ function StreamListResult({ data, label }: { data: any; label: string }) { ); } -function DaoResult({ data }: { data: any }) { +function DaoResult({ data }: { data: DaoResponse }) { return (
@@ -133,7 +173,7 @@ function DaoResult({ data }: { data: any }) { ); } -function BlockResult({ data }: { data: any }) { +function BlockResult({ data }: { data: BlockResponse }) { return (

Current block height

@@ -192,7 +232,7 @@ export function AssistantWidget() { setLoading(true); try { - let data: any; + let data: unknown; switch (queryType) { case "stream": data = await fetchApi(`/api/streams/${safe}`); @@ -211,10 +251,10 @@ export function AssistantWidget() { break; } setResults((prev) => [...prev, { type: queryType, query: q, data }]); - } catch (err: any) { + } catch (err: unknown) { setResults((prev) => [ ...prev, - { type: queryType, query: q, data: null, error: err.message }, + { type: queryType, query: q, data: null, error: err instanceof Error ? err.message : String(err) }, ]); } finally { setLoading(false); @@ -308,16 +348,16 @@ export function AssistantWidget() { {result.error ? (

{result.error}

) : result.type === "stream" ? ( - + ) : result.type === "sender" || result.type === "recipient" ? ( ) : result.type === "dao" ? ( - + ) : ( - + )}
))} diff --git a/frontend/src/hooks/use-stacks-tx.ts b/frontend/src/hooks/use-stacks-tx.ts index 2eca699..7fd4128 100644 --- a/frontend/src/hooks/use-stacks-tx.ts +++ b/frontend/src/hooks/use-stacks-tx.ts @@ -3,7 +3,11 @@ import { useCallback, useState } from "react"; import { useQueryClient } from "@tanstack/react-query"; import { toast } from "sonner"; -import { PostConditionMode } from "@stacks/transactions"; +import { + PostConditionMode, + type ClarityValue, + type PostCondition, +} from "@stacks/transactions"; import { waitForTxConfirmation, clarityErrorMessage } from "@/lib/stacks"; import { isUserCancel, @@ -32,6 +36,17 @@ export interface TxResult { * * Ref: https://docs.stacks.co/stacks.js/connect#contract-calls */ +/** What every `build…Tx` helper in `lib/stacks.ts` returns. */ +export interface ContractCallOptions { + contractAddress: string; + contractName: string; + functionName: string; + functionArgs: ClarityValue[]; + network: "mainnet" | "testnet"; + postConditions?: PostCondition[]; + postConditionMode?: PostConditionMode; +} + export function useStacksTx() { const [status, setStatus] = useState("idle"); const [txId, setTxId] = useState(null); @@ -39,7 +54,7 @@ export function useStacksTx() { const queryClient = useQueryClient(); const execute = useCallback( - async (options: Record): Promise => { + async (options: ContractCallOptions): Promise => { setStatus("pending"); setError(null); setTxId(null); @@ -141,9 +156,9 @@ export function useStacksTx() { errorRepr: result.errorRepr, }; } - } catch (err: any) { + } catch (err: unknown) { toast.dismiss(toastId); - if (err?.message === "cancelled") { + if ((err as { message?: unknown } | null)?.message === "cancelled") { setStatus("idle"); return null; } diff --git a/frontend/src/hooks/use-stream-progress.ts b/frontend/src/hooks/use-stream-progress.ts index af589a0..5b0706c 100644 --- a/frontend/src/hooks/use-stream-progress.ts +++ b/frontend/src/hooks/use-stream-progress.ts @@ -1,6 +1,6 @@ "use client"; -import { useEffect, useRef, useState } from "react"; +import { useEffect, useState } from "react"; import { BLOCK_TIME_SECONDS } from "@/lib/constants"; /** @@ -30,40 +30,28 @@ export function useStreamProgress( Math.max(0, (snapshotElapsed / duration) * 100) ); - const [value, setValue] = useState(snapshotProgress); - const baseRef = useRef(snapshotProgress); - const startTimeRef = useRef(Date.now()); + // The latest animation frame, tagged with the snapshot it extrapolates from + // so a frame computed from an old snapshot is never shown over a new one. + const [frame, setFrame] = useState<{ base: number; value: number } | null>(null); useEffect(() => { - baseRef.current = snapshotProgress; - startTimeRef.current = Date.now(); - setValue(snapshotProgress); - }, [snapshotProgress]); - - useEffect(() => { - if (!isAccruing) { - setValue(snapshotProgress); - return; - } + if (!isAccruing) return; const progressPerSecond = 100 / (duration * BLOCK_TIME_SECONDS); - let raf: number | null = null; + const base = snapshotProgress; + const startedAt = Date.now(); + let raf = 0; function tick() { - const elapsed = (Date.now() - startTimeRef.current) / 1000; - const interpolated = Math.min( - 100, - baseRef.current + progressPerSecond * elapsed - ); - setValue(interpolated); + const elapsed = (Date.now() - startedAt) / 1000; + setFrame({ base, value: Math.min(100, base + progressPerSecond * elapsed) }); raf = requestAnimationFrame(tick); } raf = requestAnimationFrame(tick); - return () => { - if (raf !== null) cancelAnimationFrame(raf); - }; + return () => cancelAnimationFrame(raf); }, [isAccruing, duration, snapshotProgress]); - return value; -} + // Not accruing, or no frame yet for this snapshot: the snapshot is the truth. + return isAccruing && frame?.base === snapshotProgress ? frame.value : snapshotProgress; +} \ No newline at end of file diff --git a/frontend/src/lib/stacks.ts b/frontend/src/lib/stacks.ts index 02f6853..495cfb2 100644 --- a/frontend/src/lib/stacks.ts +++ b/frontend/src/lib/stacks.ts @@ -33,7 +33,7 @@ import { isValidContractId, type ResolvedToken } from "./token-metadata"; // Network helpers // ============================================================================ -export function getNetwork() { +export function getNetwork(): "mainnet" | "testnet" { return IS_MAINNET ? "mainnet" : "testnet"; } @@ -128,11 +128,24 @@ export async function requireTokenMetadata( * * This helper returns the inner value object or null. */ -function unwrapOptional(cv: { value: any }): any { +/** A Clarity value as cvToJSON returns it: `{ type, value }`, nested for tuples. */ +interface CvJson { + type?: string; + value?: unknown; +} + +/** A tuple's fields, each itself a `CvJson`. */ +type CvTuple = Record; + +function unwrapOptional(cv: CvJson): CvJson | null { if (cv.value === null || cv.value === undefined) return null; - // If the value itself has a .value property, it's the optional wrapper - // around a typed value (tuple, uint, principal, etc.) - return cv.value; + // The optional wraps a typed value (tuple, uint, principal, etc.) + return cv.value as CvJson; +} + +/** A uint or principal leaf, which cvToJSON renders as a string. */ +function cvString(cv: CvJson | undefined): string { + return String(cv?.value); } export async function getStream(streamId: number) { @@ -144,7 +157,7 @@ export async function getStream(streamId: number) { const inner = unwrapOptional(result); if (inner === null) return null; // inner is { type: "(tuple ...)", value: { sender: ..., ... } } - return parseStreamData(inner.value); + return parseStreamData(inner.value as CvTuple); } export async function getStreamStatus(streamId: number): Promise { @@ -166,7 +179,7 @@ export async function getClaimableBalance(streamId: number): Promise { @@ -177,7 +190,7 @@ export async function getStreamedAmount(streamId: number): Promise { @@ -188,7 +201,7 @@ export async function getRemainingBalance(streamId: number): Promise { @@ -199,7 +212,7 @@ export async function getRefundableAmount(streamId: number): Promise { @@ -242,7 +255,7 @@ export async function getDao(admin: string) { ); const inner = unwrapOptional(result); if (inner === null) return null; - return parseDaoData(inner.value); + return parseDaoData(inner.value as CvTuple); } export async function getDaoCount(): Promise { @@ -700,21 +713,22 @@ export interface StreamData { memo: string | null; } -function parseStreamData(raw: Record): StreamData { +function parseStreamData(raw: CvTuple): StreamData { + const memo = raw.memo?.value as CvJson | null | undefined; return { - sender: raw.sender.value, - recipient: raw.recipient.value, - token: raw.token.value, - depositAmount: BigInt(raw["deposit-amount"].value), - withdrawnAmount: BigInt(raw["withdrawn-amount"].value), - startBlock: Number(raw["start-block"].value), - endBlock: Number(raw["end-block"].value), - ratePerBlock: BigInt(raw["rate-per-block"].value), - status: Number(raw.status.value), - pausedAtBlock: Number(raw["paused-at-block"].value), - totalPausedDuration: Number(raw["total-paused-duration"].value), - createdAtBlock: Number(raw["created-at-block"].value), - memo: raw.memo?.value?.value ?? null, + sender: cvString(raw.sender), + recipient: cvString(raw.recipient), + token: cvString(raw.token), + depositAmount: BigInt(cvString(raw["deposit-amount"])), + withdrawnAmount: BigInt(cvString(raw["withdrawn-amount"])), + startBlock: Number(cvString(raw["start-block"])), + endBlock: Number(cvString(raw["end-block"])), + ratePerBlock: BigInt(cvString(raw["rate-per-block"])), + status: Number(cvString(raw.status)), + pausedAtBlock: Number(cvString(raw["paused-at-block"])), + totalPausedDuration: Number(cvString(raw["total-paused-duration"])), + createdAtBlock: Number(cvString(raw["created-at-block"])), + memo: typeof memo?.value === "string" ? memo.value : null, }; } @@ -727,13 +741,13 @@ export interface DaoData { isActive: boolean; } -function parseDaoData(raw: Record): DaoData { +function parseDaoData(raw: CvTuple): DaoData { return { - name: raw.name.value, - admin: raw.admin.value, - totalStreamsCreated: Number(raw["total-streams-created"].value), - totalDeposited: BigInt(raw["total-deposited"].value), - createdAtBlock: Number(raw["created-at-block"].value), - isActive: raw["is-active"].value, + name: cvString(raw.name), + admin: cvString(raw.admin), + totalStreamsCreated: Number(cvString(raw["total-streams-created"])), + totalDeposited: BigInt(cvString(raw["total-deposited"])), + createdAtBlock: Number(cvString(raw["created-at-block"])), + isActive: raw["is-active"]?.value === true, }; } From 6ee3698e4314c29eff9a4accd4a3940e6c4f0740 Mon Sep 17 00:00:00 2001 From: jayteemoney Date: Mon, 5 Oct 2026 07:35:26 +0100 Subject: [PATCH 4/6] test(contracts): make every fuzz test reproducible from its seed Three property tests drew from Math.random instead of the seeded RNG, so a failure printed a FUZZ_SEED that could not reproduce it. All now use the seed. "multi-cycle pause, claimable frozen" failed for durations of 10-13 and 20-23 blocks. Each pause pushes accrual past end-block, and pause-stream is refused once end-block passes (ERR-STREAM-ENDED, by design). The test ignored the refusal and asserted a freeze that never started. It now stops the cycle on that refusal, the same guard the sibling "returns to ACTIVE" test already had. Contract unchanged. --- tests/stream-manager.test.ts | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/tests/stream-manager.test.ts b/tests/stream-manager.test.ts index 364bd6a..fe562f4 100644 --- a/tests/stream-manager.test.ts +++ b/tests/stream-manager.test.ts @@ -1323,14 +1323,23 @@ describe("StackStream - Stream Manager Contract", () => { // --- Invariant 4: Multi-cycle Pause Accounting --- it("invariant: multi-cycle pause — claimable frozen during each individual pause", () => { - const { deposit, duration } = generateRandomStream(500_000_000n, 1_000_000_000n); + const { deposit, duration } = generateRandomStream(500_000_000n, 1_000_000_000n, rng); const startBlock = getCurrentBlock() + 2; const createResult = createStream(wallet1, wallet2, Number(deposit), startBlock, Number(duration)); const streamId = (createResult.result as any).value.value as bigint; for (let cycle = 0; cycle < 3; cycle++) { simnet.mineEmptyBlocks(Math.max(3, Math.floor(Number(duration) * 0.1))); - simnet.callPublicFn(streamManagerContract, "pause-stream", [Cl.uint(streamId)], wallet1); + + // Same guard as the "returns to ACTIVE" invariant below: pause is refused + // once end-block passes (ERR-STREAM-ENDED), while accrual is still + // finishing because earlier pauses pushed the effective end later. The + // freeze invariant only applies to a pause that actually took effect. + const pause = simnet.callPublicFn(streamManagerContract, "pause-stream", [Cl.uint(streamId)], wallet1); + if ((pause.result as any).type !== "ok") { + expect(pause.result).toBeErr(Cl.uint(207)); + break; + } const claimableAtPause = (simnet.callReadOnlyFn(streamManagerContract, "get-claimable-balance", [Cl.uint(streamId)], deployer).result as any).value.value as bigint; @@ -1401,7 +1410,7 @@ describe("StackStream - Stream Manager Contract", () => { }); it("invariant: multi-cycle pause — status returns to ACTIVE after each resume", () => { - const { deposit, duration } = generateRandomStream(500_000_000n, 1_000_000_000n); + const { deposit, duration } = generateRandomStream(500_000_000n, 1_000_000_000n, rng); const startBlock = getCurrentBlock() + 2; const createResult = createStream(wallet1, wallet2, Number(deposit), startBlock, Number(duration)); const streamId = (createResult.result as any).value.value as bigint; @@ -1507,7 +1516,7 @@ describe("StackStream - Stream Manager Contract", () => { }); it("invariant: multiple sequential top-ups preserve rate-per-block", () => { - const { deposit, duration } = generateRandomStream(200_000_000n, 500_000_000n); + const { deposit, duration } = generateRandomStream(200_000_000n, 500_000_000n, rng); const startBlock = getCurrentBlock() + 2; const createResult = createStream(wallet1, wallet2, Number(deposit), startBlock, Number(duration)); const streamId = (createResult.result as any).value.value as bigint; From 4e8a48e8f9085a9b52f97d2bdc56670597296c62 Mon Sep 17 00:00:00 2001 From: jayteemoney Date: Mon, 5 Oct 2026 07:35:26 +0100 Subject: [PATCH 5/6] fix(openclaw): correct the skill's decimals and block-time guidance SKILL.md told the assistant every amount has 8 decimals, the same mistake that showed a 1.2 USDA deposit as 0.012, and that blocks take 10 minutes (about 5 seconds since Nakamoto). It now uses the tokenDecimals field the API returns. The read API exists as Next route handlers and as the Express service that self-hosted skills run. A parity test pins their formatting helpers to the same output so the two copies cannot drift. --- openclaw-service/skill/SKILL.md | 6 ++--- tests/openclaw-parity.test.ts | 44 +++++++++++++++++++++++++++++++++ 2 files changed, 47 insertions(+), 3 deletions(-) create mode 100644 tests/openclaw-parity.test.ts diff --git a/openclaw-service/skill/SKILL.md b/openclaw-service/skill/SKILL.md index 313e0ca..875d1f0 100644 --- a/openclaw-service/skill/SKILL.md +++ b/openclaw-service/skill/SKILL.md @@ -14,7 +14,7 @@ You are an AI assistant for **StackStream**, a Bitcoin-native payroll streaming StackStream allows DAOs to stream SIP-010 tokens (like sBTC) to contributors block-by-block. Instead of lump-sum payments, tokens accrue continuously and recipients can claim at any time. Key concepts: - **Stream**: A continuous token transfer from sender to recipient over a block range -- **Block-based**: Amounts accrue each Stacks block (~10 minutes) +- **Block-based**: Amounts accrue each Stacks block (about 5 seconds since the Nakamoto upgrade) - **Claimable**: Recipients withdraw accrued tokens whenever they want - **Pausable**: Senders can pause/resume streams - **Cancellable**: Senders can cancel, refunding unstreamed tokens @@ -182,8 +182,8 @@ When asked about concepts: ## Important Notes -- All token amounts in the API are in micro-tokens (8 decimal places). Always convert for display: divide by 10^8. -- Stacks blocks are approximately 10 minutes each. +- Raw token amounts in the API are integers in the token's smallest unit, and tokens differ: sBTC and ALEX use 8 decimals, USDA uses 6. Never assume 8. Use the `tokenDecimals` field the API returns (divide by 10^tokenDecimals), or prefer the `…Formatted` fields. If `tokenDecimals` is missing, show the raw amount and say the scale could not be read. +- Stacks blocks arrive about every 5 seconds since the Nakamoto upgrade, so 720 blocks is roughly an hour. - Transaction parameters returned by the API are **not executed** — they must be signed by the user's wallet. - Always confirm destructive actions (cancel, pause) before building the transaction. - Stream IDs are sequential integers starting from 0. diff --git a/tests/openclaw-parity.test.ts b/tests/openclaw-parity.test.ts new file mode 100644 index 0000000..97dacae --- /dev/null +++ b/tests/openclaw-parity.test.ts @@ -0,0 +1,44 @@ +import { describe, it, expect } from "vitest"; + +// The read API exists twice: as Next.js route handlers (what stackstream.xyz +// serves) and as the standalone Express service that self-hosted OpenClaw +// skills run. Their formatting helpers are copies, so this pins them to the +// same output. If one changes and the other does not, this fails. +import * as service from "../openclaw-service/src/utils"; +import * as hosted from "../frontend/src/lib/openclaw-server"; + +const AMOUNTS: Array<[bigint, number]> = [ + [0n, 6], + [1n, 6], + [1_200_000n, 6], // 1.2 USDA, the amount the 8-decimal bug showed as 0.012 + [123_456_789n, 8], + [100_000_000_000n, 8], + [5n, 0], +]; + +describe("OpenClaw service and hosted API agree", () => { + it("format token amounts identically", () => { + for (const [raw, decimals] of AMOUNTS) { + expect(hosted.formatTokenAmount(raw, decimals)).toBe(service.formatTokenAmount(raw, decimals)); + } + }); + + it("label stream statuses identically", () => { + for (const code of [0, 1, 2, 3, 9]) { + expect(hosted.getStreamStatusLabel(code)).toBe(service.getStreamStatusLabel(code)); + } + }); + + it("compute stream progress identically", () => { + const cases: Array<[number, number, number, number]> = [ + [100, 200, 150, 0], + [100, 200, 150, 20], + [100, 100, 150, 0], + [100, 200, 50, 0], + [100, 200, 500, 0], + ]; + for (const args of cases) { + expect(hosted.getStreamProgress(...args)).toBe(service.getStreamProgress(...args)); + } + }); +}); From 86a86a6377a0d3ec8aa10431c9f3ef7422658bf8 Mon Sep 17 00:00:00 2001 From: jayteemoney Date: Mon, 5 Oct 2026 07:35:26 +0100 Subject: [PATCH 6/6] ci: typecheck, lint and build the frontend and service on every PR CI ran only the root vitest suite, so a type error, a lint error or a broken production build in either app could merge. Adds a frontend job (tsc, lint, webpack build) and a service typecheck, installs both apps' dependencies for the root suite (it imports their sources), and cancels superseded runs. --- .github/workflows/test.yml | 76 ++++++++++++++++++++++++++++++++------ 1 file changed, 65 insertions(+), 11 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 6757df5..41a4a02 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -1,28 +1,82 @@ -name: Tests +name: CI on: push: - branches: ["**"] + branches: [main] pull_request: - branches: ["**"] + +# A newer push to the same branch makes the older run pointless. +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read jobs: test: - name: Run test suite + name: Contract and unit tests runs-on: ubuntu-latest - steps: - - name: Checkout - uses: actions/checkout@v4 + - uses: actions/checkout@v4 - - name: Set up Node.js - uses: actions/setup-node@v4 + - uses: actions/setup-node@v4 with: node-version: "22" - cache: "npm" + cache: npm + cache-dependency-path: | + package-lock.json + frontend/package-lock.json + openclaw-service/package-lock.json + # The root suite imports frontend and service sources, which resolve their + # dependencies from their own node_modules. - name: Install dependencies - run: npm ci + run: | + npm ci + npm ci --prefix frontend + npm ci --prefix openclaw-service - name: Run tests run: npm test + + frontend: + name: Frontend typecheck, lint and build + runs-on: ubuntu-latest + defaults: + run: + working-directory: frontend + env: + NEXT_PUBLIC_NETWORK: mainnet + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: "22" + cache: npm + cache-dependency-path: frontend/package-lock.json + + - run: npm ci + - run: npx tsc --noEmit + - run: npm run lint + # Webpack, not Turbopack: Turbopack breaks @stacks/connect in production. + - run: npm run build + + openclaw-service: + name: OpenClaw service typecheck + runs-on: ubuntu-latest + defaults: + run: + working-directory: openclaw-service + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: "22" + cache: npm + cache-dependency-path: openclaw-service/package-lock.json + + - run: npm ci + - run: npx tsc --noEmit