@@ -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",