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 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/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/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/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/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}.` 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/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/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, }; } 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/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)); + } + }); +}); 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; 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 69c9065..9c0eb0b 100644 Binary files a/tests/token-metadata.test.ts and b/tests/token-metadata.test.ts differ 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",