From 26a54d9ea21bf7af53d96cc470fb981e4f6db0f8 Mon Sep 17 00:00:00 2001 From: jayteemoney Date: Fri, 2 Oct 2026 13:35:09 +0100 Subject: [PATCH] feat(frontend): let teams stream their own SIP-010 token MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The create-stream selector was a hardcoded list of four curated tokens. `stream-manager.clar` is permissionless, so a team wanting to stream its own token had no way to select it without a code change — which blocks onboarding a protocol that already supports it. Discovery is now a separate concern from trust: - `lib/token-registry.ts` queries Hiro's token metadata API, which indexes every SIP-010 token (~5.6k). It is used ONLY to find and label tokens. - `verifySelection` is the single gate from a suggestion to a transaction. decimals and assetName are re-proven on-chain by the existing resolver and the registry's values are never used for a post-condition. I checked the registry's decimals against `get-decimals` on 25 indexed tokens and they agreed 25/25, but "has always agreed" is not a property a payment should depend on, and an indexer is one deploy behind the chain forever. - A resolver failure yields "unverifiable", never a fallback token. A flat list of the registry would not be shippable: `symbol=sBTC` returns 32 contracts including `buttcoin-stxcity`, so search flags any contract claiming a curated symbol as an impersonator, shows the full principal rather than a symbol, and requires an explicit confirm. Verified-but-new tokens stay frictionless — nothing legitimate is blocked. Three entry paths, in trust order: curated tokens pinned first, then search, then a contract id. The contract-id path exists because that is how users actually obtain one — a team puts `/dashboard/create?token=SP….x` in its own docs or Slack and nobody types 41 characters. It resolves from the chain, so it works for tokens the registry has not indexed yet and when the registry is down; the registry only supplies cosmetics there, since `/metadata/v1/search` does not return an asset identifier at all. Adds 43 network-free tests. Verified: 186 pass, tsc clean, build clean, lint unchanged at the pre-existing 14 errors / 4 warnings. --- frontend/src/app/dashboard/create/page.tsx | 74 +- .../src/components/stream/token-selector.tsx | 679 ++++++++++++++++++ frontend/src/hooks/use-token-search.ts | 149 ++++ frontend/src/lib/token-registry.ts | 599 +++++++++++++++ tests/token-registry.test.ts | 513 +++++++++++++ 5 files changed, 1984 insertions(+), 30 deletions(-) create mode 100644 frontend/src/components/stream/token-selector.tsx create mode 100644 frontend/src/hooks/use-token-search.ts create mode 100644 frontend/src/lib/token-registry.ts create mode 100644 tests/token-registry.test.ts diff --git a/frontend/src/app/dashboard/create/page.tsx b/frontend/src/app/dashboard/create/page.tsx index 95cb909..761465f 100644 --- a/frontend/src/app/dashboard/create/page.tsx +++ b/frontend/src/app/dashboard/create/page.tsx @@ -11,7 +11,6 @@ import { useStacksTx } from "@/hooks/use-stacks-tx"; import { useTokenBalance } from "@/hooks/use-token-balance"; import { buildCreateStreamTx } from "@/lib/stacks"; import { - SUPPORTED_TOKENS, DEFAULT_TOKEN, DURATION_UNITS, EXPLORER_BASE, @@ -19,9 +18,12 @@ import { toRawAmount, fromRawAmount, type DurationUnit, - type TokenConfig, } from "@/lib/constants"; import { formatTokenAmount, blocksToTimeString, blockToClockTime } from "@/lib/utils"; +import { + TokenSelector, + type TokenSelection, +} from "@/components/stream/token-selector"; import { toast } from "sonner"; import { Zap, ArrowRight, Info, Loader2, CheckCircle2 } from "lucide-react"; @@ -35,10 +37,28 @@ export default function CreateStreamPage() { const [durationValue, setDurationValue] = useState("30"); const [durationUnit, setDurationUnit] = useState("days"); const [memo, setMemo] = useState(""); - const [selectedToken, setSelectedToken] = useState(DEFAULT_TOKEN); + // The selection carries chain-verified metadata (assetName + decimals read + // from the contract), which is what the transaction builder needs. Starting + // on the curated default means the form is usable immediately; a `?token=` + // deep link or a search result replaces it once verified on-chain. + const [selectedToken, setSelectedToken] = useState(() => ({ + resolved: { + contractId: DEFAULT_TOKEN.contractId, + assetName: DEFAULT_TOKEN.assetName, + decimals: DEFAULT_TOKEN.decimals, + symbol: DEFAULT_TOKEN.symbol, + curated: true, + }, + decimals: DEFAULT_TOKEN.decimals, + symbol: DEFAULT_TOKEN.symbol, + name: DEFAULT_TOKEN.name, + icon: DEFAULT_TOKEN.icon, + description: DEFAULT_TOKEN.description, + trust: "curated", + })); const [errors, setErrors] = useState>({}); - const { balance, isLoading: isBalanceLoading } = useTokenBalance(selectedToken); + const { balance, isLoading: isBalanceLoading } = useTokenBalance(selectedToken.resolved); if (!isConnected) { return ( @@ -103,8 +123,8 @@ export default function CreateStreamPage() { const txOptions = buildCreateStreamTx({ recipient, - tokenContract: selectedToken.contractId, - token: selectedToken, + tokenContract: selectedToken.resolved.contractId, + token: selectedToken.resolved, depositAmount: amountRaw, startBlock: latestBlock + 120, durationBlocks, @@ -147,31 +167,25 @@ export default function CreateStreamPage() { disabled={isSubmitting} /> - {/* Token selector */} - {SUPPORTED_TOKENS.length > 1 && ( -
- - + {/* Token selector: verified tokens, registry search, and a contract + id for tokens the registry hasn't indexed yet. Every path ends in + an on-chain verification, so nothing unverified can be streamed. */} +
+ + { + setSelectedToken(selection); + // Reset the amount: raw units differ per token's decimals, and + // keeping the old number would silently reinterpret it. + setAmount(""); + }} + disabled={isSubmitting} + /> + {selectedToken.description && (

{selectedToken.description}

-
- )} + )} +
void; + disabled?: boolean; +} + +/** Shorten a principal for display without hiding its distinguishing tail. */ +function shortPrincipal(contractId: ContractId): string { + const [deployer, contract] = contractId.split("."); + if (!deployer || !contract) return contractId; + return `${deployer.slice(0, 6)}…${deployer.slice(-4)}.${contract}`; +} + +/** Full principal, wrapped, with a copy button. */ +function Principal({ contractId }: { contractId: ContractId }) { + const [copied, setCopied] = useState(false); + + const copy = async () => { + try { + await navigator.clipboard.writeText(contractId); + setCopied(true); + setTimeout(() => setCopied(false), 1500); + } catch { + // Clipboard can be denied; the text is on screen either way, so this is + // not worth interrupting the user over. + } + }; + + return ( +
+ + {contractId} + + +
+ ); +} + +/** + * The trust badge. + * + * Wording is deliberately neutral for unverified tokens. Teams are onboarding + * new tokens, and a hostile warning would just teach users to click past it — + * which is exactly the wrong instinct to build around a payment. The + * impersonator badge is the exception: that one is unambiguous and specific. + */ +function TrustBadge({ + token, + status, +}: { + token: DiscoveredToken; + status?: VerifyStatus; +}) { + if (token.trust === "impersonator") { + return ( + + + Impersonates a verified token + + ); + } + + if (status === "unverified") { + return ( + + + Unverified + + ); + } + + if (token.trust === "curated") { + return ( + + + Verified + + ); + } + + return ( + + Community + + ); +} + +/** A single row in the search results. */ +function ResultRow({ + token, + selected, + disabled, + onPick, +}: { + token: DiscoveredToken; + selected: boolean; + disabled?: boolean; + onPick: (token: DiscoveredToken) => void; +}) { + const unusable = token.trust === "unusable"; + return ( + + ); +} + +export function TokenSelector({ value, onChange, disabled }: TokenSelectorProps) { + const curated = useCuratedTokens(); + const [query, setQuery] = useState(""); + const [manual, setManual] = useState(""); + const [confirming, setConfirming] = useState(null); + const [manualError, setManualError] = useState(null); + const [verifying, setVerifying] = useState(false); + const [showManual, setShowManual] = useState(false); + + const search = useTokenSearch(query); + + const curatedIds = useMemo(() => curated.map((t) => t.contractId), [curated]); + const curatedSymbols = useMemo( + () => new Map(curated.map((t) => [t.symbol.toLowerCase(), t.contractId])), + [curated], + ); + + const toSelection = (resolved: ResolvedToken, token: DiscoveredToken): TokenSelection => ({ + resolved, + decimals: resolved.decimals, + symbol: resolved.symbol, + name: token.name, + icon: token.icon, + description: token.description, + trust: token.trust, + impersonates: token.impersonates, + }); + + /** Verify a candidate against the chain before it becomes the selection. */ + const verify = async (candidate: DiscoveredToken): Promise => { + setVerifying(true); + try { + const result = await verifySelection( + candidate, + curatedIds, + curatedSymbols, + resolveTokenMetadata, + ); + + if (result.status === "verified" && result.resolved) { + onChange(toSelection(result.resolved, candidate)); + setConfirming(null); + setShowManual(false); + setManual(""); + setManualError(null); + return true; + } + + // Unverifiable or contradictory: never select, and say exactly why. + setManualError( + result.reason ?? + "This token could not be verified on-chain, so it cannot be streamed safely.", + ); + return false; + } finally { + setVerifying(false); + } + }; + + /** + * A curated token is already chain-proven (the curated entry IS the source of + * truth for its asset name), so it selects without a confirm step. + */ + const pickCurated = (token: TokenConfig) => { + onChange({ + resolved: { + contractId: token.contractId, + assetName: token.assetName, + decimals: token.decimals, + symbol: token.symbol, + curated: true, + }, + decimals: token.decimals, + symbol: token.symbol, + name: token.name, + icon: token.icon, + description: token.description, + trust: "curated", + }); + setQuery(""); + setManualError(null); + }; + + /** + * A discovered token is untrusted until the chain says otherwise. Verified + * results apply immediately; anything else needs the user to confirm a + * specific, fully-shown principal. + */ + const pickDiscovered = (token: DiscoveredToken) => { + if (token.trust === "unverified" || token.trust === "impersonator") { + setConfirming(token); + setManualError(null); + return; + } + void verify(token); + }; + + /** + * Resolve a known contract id, from either the manual field or a deep link. + * + * Takes the id as an argument rather than reading the `manual` state, so the + * deep-link path can call it directly. Reading state here would race: the + * deep link sets the field and immediately calls this, and React has not + * committed the state update yet, so it would verify the previous value. + */ + const selectByContractId = async (contractId: ContractId) => { + setManualError(null); + setVerifying(true); + try { + // The chain is the authority here, and it is the only source: the + // registry's `/search` endpoint does not return an asset identifier, so + // `assetName` and `decimals` cannot come from a listing even in principle. + // A token deployed minutes ago that is not indexed still resolves, which + // is the whole reason this path exists. + const resolved = await resolveTokenMetadata(contractId); + if (!resolved) { + setManualError( + "Could not read this token's asset name and decimals from the contract, so it cannot be streamed safely.", + ); + return; + } + + // Cosmetics only, and safe to fail: the contract is already proven. + const decorations = await lookupDecorations(contractId); + const candidate = dedupeByContract( + classifyAll( + [candidateFromResolved(resolved, decorations)], + curatedIds, + curatedSymbols, + ), + )[0]!; + + if (candidate.trust === "unverified" || candidate.trust === "impersonator") { + setConfirming(candidate); + return; + } + // Curated tokens resolve to the curated entry, so the values are identical + // and there is nothing left to re-prove. + onChange( + toSelection( + { + contractId: candidate.contractId, + assetName: candidate.assetName, + decimals: candidate.decimals, + symbol: candidate.symbol, + curated: true, + }, + candidate, + ), + ); + setShowManual(false); + setManual(""); + setManualError(null); + } finally { + setVerifying(false); + } + }; + + /** Manual path: validate the shape, then verify on-chain. */ + const submitManual = async () => { + const parsed = parseTokenDeepLink(manual); + if (!isValidContractId(parsed)) { + setManualError( + "That is not a valid contract id. It should look like SP….contract-name.", + ); + return; + } + await selectByContractId(parsed); + }; + + // A `?token=` deep link is the intended way a team hands its token to a user: + // they put the link in their docs or Slack and nobody types 41 characters. + // + // Applied once on mount, and the param is then stripped from the URL so a + // refresh does not silently re-apply a stale selection over one the user has + // since changed. + // + // `selectByContractId` is held in a ref rather than listed as a dependency: + // the link must be consumed exactly once, while the function is re-created on + // every render. Listing it would re-run the effect and re-apply the link on + // each render, which is precisely the bug this guard prevents. + const selectByRef = useRef(selectByContractId); + selectByRef.current = selectByContractId; + const deepLinkApplied = useRef(false); + + useEffect(() => { + if (deepLinkApplied.current) return; + deepLinkApplied.current = true; + + const params = new URLSearchParams(window.location.search); + const linked = parseTokenDeepLink(params.get("token")); + if (!linked) return; + + setShowManual(true); + setManual(linked); + void selectByRef.current(linked); + + params.delete("token"); + const rest = params.toString(); + window.history.replaceState( + null, + "", + rest ? `${window.location.pathname}?${rest}` : window.location.pathname, + ); + }, []); + + const searchResults = search.results.slice(0, MAX_RESULTS); + + return ( +
+ {/* ---- Selected token ---- + The full principal is always shown, not just its symbol: a + mistyped-but-real contract id resolving to a different token is only + catchable by seeing the whole string. */} +
+
+ + {value.symbol || "(no symbol)"} + + {value.trust === "curated" ? ( + + + Verified + + ) : ( + Community + )} + + {value.decimals} decimals + +
+ {value.name &&
{value.name}
} + +
+ + {/* ---- Curated tokens ---- */} +
+ +
+ {curated.map((token) => ( + + ))} +
+
+ + {/* ---- Search ---- */} +
+ +
+ + setQuery(e.target.value)} + placeholder="Search any SIP-010 token…" + disabled={disabled} + autoComplete="off" + spellCheck={false} + className="pl-9" + /> +
+ + {search.isSearching && ( +
+ + Searching… +
+ )} + + {search.isSearchable && !search.isSearching && searchResults.length > 0 && ( +
+ {searchResults.map((token) => ( + + ))} +
+ )} + + {search.isSearchable && !search.isSearching && searchResults.length === 0 && ( +

+ No tokens matched. If yours was just deployed, use the contract id below — + new tokens can take a moment to be indexed. +

+ )} +
+ + {/* ---- Contract id: deep link or pasted ---- */} +
+ {!showManual ? ( + + ) : ( +
+ + { + setManual(e.target.value); + setManualError(null); + }} + placeholder="SP….contract-name" + disabled={disabled || verifying} + autoComplete="off" + spellCheck={false} + className="font-mono text-xs" + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault(); + void submitManual(); + } + }} + /> + +
+ )} + {manualError && ( +

+ + {manualError} +

+ )} +
+ + {/* ---- Explicit confirm for anything not hand-verified ---- */} + {confirming && ( +
+
+ +
+

+ Confirm {confirming.symbol || "this token"} +

+

+ {confirming.trust === "impersonator" + ? "A verified token uses this symbol, and this is a different contract. Check the contract id below before continuing." + : "This token is not on our verified list. Check the contract id below before continuing."} +

+
+
+ + + + {confirming.impersonates && ( +
+

Verified token with this symbol:

+ +
+ )} + +
+ + +
+
+ )} +
+ ); +} diff --git a/frontend/src/hooks/use-token-search.ts b/frontend/src/hooks/use-token-search.ts new file mode 100644 index 0000000..4aca672 --- /dev/null +++ b/frontend/src/hooks/use-token-search.ts @@ -0,0 +1,149 @@ +"use client"; + +/** + * Discovery hooks for the token selector. + * + * These wrap the registry search in `lib/token-registry.ts`. The registry is a + * DISCOVERY surface only — nothing it returns is trusted for a transaction. + * Selection always finishes through `verifySelection`, which reads decimals and + * the asset name from the chain. That split is what makes it safe to search a + * registry containing 5.6k contracts, 32 of which claim to be sBTC. + */ + +import { useQuery } from "@tanstack/react-query"; +import { useEffect, useMemo, useState } from "react"; +import { + classifyAll, + dedupeByContract, + searchRegistry, + MIN_SEARCH_LENGTH, + type ContractId, + type DiscoveredToken, +} from "@/lib/token-registry"; +import { getCuratedTokens } from "@/lib/token-metadata"; + +/** + * Ids and symbols of the hand-verified tokens, used to catch impersonators. + * + * Rebuilt only when the curated list actually changes — it is a module-level + * constant in practice, so this computes once per mount. + */ +function useCuratedIndex(): { + ids: readonly ContractId[]; + symbols: ReadonlyMap; +} { + return useMemo(() => { + const curated = getCuratedTokens(); + return { + ids: curated.map((t) => t.contractId), + // Lower-cased so a registry row saying "usbtc" still collides with "sBTC". + symbols: new Map(curated.map((t) => [t.symbol.toLowerCase(), t.contractId])), + }; + }, []); +} + +/** + * Debounce for the search box. + * + * The registry endpoint is a public API keyed on nothing in particular, so + * every keystroke is a request a stranger pays for. 300ms collapses a typed + * word into one call while still feeling immediate. + */ +const SEARCH_DEBOUNCE_MS = 300; + +/** + * Registry search results change only as contracts are deployed, so they are + * cached per query string for the session. Without this, backing out of a + * search and retyping it re-queries for no reason. + */ +const SEARCH_STALE_TIME = 10 * 60 * 1000; + +/** + * Delay a value until it stops changing. + * + * The registry endpoint is a public API keyed on nothing in particular, so + * every keystroke is a request a stranger pays for. Collapsing a typed word + * into one call keeps it feeling immediate while stopping the flood. + * + * The trailing value is returned, so the last keystroke always wins — an + * in-flight request for a stale prefix can never overwrite the results for + * what the user actually typed. + */ +function useDebouncedValue(value: T, delayMs: number): T { + const [debounced, setDebounced] = useState(value); + + useEffect(() => { + const timer = setTimeout(() => setDebounced(value), delayMs); + // Clearing on every change is what makes this a trailing debounce rather + // than a throttle. + return () => clearTimeout(timer); + }, [value, delayMs]); + + return debounced; +} + +export interface TokenSearchState { + results: DiscoveredToken[]; + isSearching: boolean; + isError: boolean; + /** True once the query is long enough to be worth sending. */ + isSearchable: boolean; +} + +/** + * Search the registry for tokens matching `query`. + * + * The query is debounced before it becomes a request. `isSearchable` reflects + * the RAW input, not the debounced value, so the UI can say "keep typing" + * immediately instead of flickering while the debounce settles. + * + * Short queries are never sent — `?name=a` matches thousands of rows and + * helps nobody. + */ +export function useTokenSearch(query: string): TokenSearchState { + const trimmed = query.trim(); + const isSearchable = trimmed.length >= MIN_SEARCH_LENGTH; + const debounced = useDebouncedValue(trimmed, SEARCH_DEBOUNCE_MS); + const curated = useCuratedIndex(); + + const result = useQuery({ + queryKey: ["token-search", debounced], + queryFn: () => searchRegistry(debounced), + enabled: debounced.length >= MIN_SEARCH_LENGTH, + staleTime: SEARCH_STALE_TIME, + refetchOnWindowFocus: false, + retry: 1, + }); + + // Classify here rather than inside the client, so the curated list the + // component would render comes from the same source the warnings do. A row + // the registry hands back unclassified is never displayed as trusted. + // Classify here rather than inside the client: trust depends on the curated + // list, which is app state, and a row is never displayed unclassified. + // dedupeByContract also applies the trust ordering, so an entry that cannot + // receive a stream cannot surface above a usable one. + const results = useMemo( + () => dedupeByContract(classifyAll(result.data ?? [], curated.ids, curated.symbols)), + [result.data, curated.ids, curated.symbols], + ); + + return { + results, + // True from the moment the input is long enough until results settle, so the + // spinner covers the debounce as well as the request. Otherwise the UI + // would briefly claim "no tokens matched" mid-type. + isSearching: isSearchable && (debounced !== trimmed || result.isLoading), + isError: result.isError, + isSearchable, + }; +} + +/** + * The hand-verified tokens, in display order. + * + * Always available regardless of registry health — if the registry is down or + * useless, the selector still offers the tokens we have verified ourselves. + */ +export function useCuratedTokens() { + return getCuratedTokens(); +} diff --git a/frontend/src/lib/token-registry.ts b/frontend/src/lib/token-registry.ts new file mode 100644 index 0000000..bb0bb06 --- /dev/null +++ b/frontend/src/lib/token-registry.ts @@ -0,0 +1,599 @@ +/** + * Token discovery over Hiro's Token Metadata API. + * + * WHY THIS EXISTS + * + * `stream-manager.clar` is permissionless, but the create-stream selector was a + * hardcoded list of four curated tokens. A team that wanted to stream its own + * SIP-010 token had no way to select it without a code change, which blocks + * onboarding even though the protocol already supports it. + * + * Hiro's Token Metadata API indexes every SIP-010 token that has ever been + * deployed (`/metadata/v1/ft`, ~5.6k entries). That makes it usable as a + * DISCOVERY surface. It is emphatically NOT usable as a trust surface, for two + * reasons that shaped this whole module: + * + * 1. It is a list of everything anyone ever deployed, not a list of + * streamable assets. A large fraction of rows have no symbol, no name and + * no supply. Sampling the default listing order showed 0% usable rows in + * the first few hundred — a raw dropdown would open on blank entries. + * 2. Symbols are trivially forgeable. `symbol=sBTC` returns 32 contracts + * including `buttcoin-stxcity` and `sBTC-mock-vpv-10`. Showing a + * user a flat list of 5.6k rows where "sBTC" appears 32 times, with no + * way to tell the canonical one apart, is how value gets sent to an + * impersonator. + * + * Therefore: this module is for FINDING tokens and LABELLING their risk. It is + * never the source of truth for a transaction. `assetName` and `decimals` used + * in a post-condition must come from the chain resolver in + * `token-metadata-client.ts`, which reads them from the contract itself. + * `verifySelection` below enforces that ordering at the one place a selection + * becomes an executable token. + * + * Splitting it this way means the registry can be wrong, stale, or completely + * down without any path to a misdirected payment — the worst it can do is + * suggest a token, or refuse to suggest one. + */ + +import type { ResolvedToken } from "./token-metadata"; + +/** Fully-qualified `deployer.contract-name`, e.g. "SP2C2…ZM.usda-token". */ +export type ContractId = string; + +/** Risk classification for a discovered token, surfaced in the UI. */ +export type TokenTrust = + /** Hand-verified in `token-metadata.ts`. Safe to proceed without extra steps. */ + | "curated" + /** Claims the symbol of a curated token but is not it. Suspicious by construction. */ + | "impersonator" + /** Indexed and usable, but not on the curated list. */ + | "unverified" + /** Indexed, but too incomplete to stream usefully. */ + | "unusable"; + +/** + * A token as offered by the registry, before chain verification. + * + * Every field is a hint. `assetName` and `decimals` MUST be re-proven on-chain + * before they reach a transaction — see `token-metadata-client.ts`. + */ +export interface DiscoveredToken { + contractId: ContractId; + /** Asset name as the registry indexed it. Advisory until chain-verified. */ + assetName: string; + /** Advisory until chain-verified against SIP-010 `get-decimals`. */ + decimals: number; + symbol: string; + name: string; + description?: string; + icon?: string; + totalSupply?: string; + /** Trust level for display. Derived, never trusted from the API. */ + trust: TokenTrust; + /** Populated when `trust` is "impersonator": the curated token it mimics. */ + impersonates?: ContractId; + /** Why this token is unusable, when `trust` is "unusable". */ + unusableReason?: string; +} + +// ============================================================================ +// Types +// ============================================================================ + +/** + * Display order, best first. + * + * Ordering is a safety property, not a cosmetic one: an entry that cannot + * receive a stream must never appear above a usable one, and an impersonator + * must be visibly below the token it is imitating. + */ +const TRUST_RANK: Record = { + curated: 0, + unverified: 1, + impersonator: 2, + unusable: 3, +}; + +/** Raw row shape from `/metadata/v1/ft`. Every field may be absent or empty. */ +export interface RegistryRow { + contract_principal?: string; + asset_identifier?: string; + name?: string; + symbol?: string; + decimals?: number; + total_supply?: string; + description?: string; + image_canonical_uri?: string; + image_uri?: string; +} + +interface RegistryListResponse { + limit?: number; + offset?: number; + total?: number; + results?: RegistryRow[]; +} + +// ============================================================================ +// Configuration +// ============================================================================ + +const API_BASE = + process.env.NEXT_PUBLIC_HIRO_API_BASE?.replace(/\/$/, "") ?? "https://api.hiro.so"; + +/** Registry page size. The API caps this at 60. */ +export const REGISTRY_PAGE_SIZE = 60; + +/** Below this many characters in a search query, don't bother the API. */ +export const MIN_SEARCH_LENGTH = 2; + +/** + * Upper bound on how many results we will render. + * + * 5.6k exists; showing it all helps nobody and makes the list unreadable. The + * curated tokens are always available regardless of this cap. + */ +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`. + * + * Kept separate from `RegistryRow` because the two endpoints genuinely disagree: + * `/ft` returns `contract_principal` + `asset_identifier`, while `/search` + * returns `contract_id` + `token_number` and NO asset identifier at all. The + * missing asset name is why there is no `lookupContract` here — see the note + * on `classifyResolved`. + */ +export interface RegistrySearchRow { + contract_id?: string; + token_number?: number; + token_type?: string; + name?: string; + symbol?: string; + decimals?: number; + total_supply?: string; + description?: string; + image_canonical_uri?: string; + image_uri?: string; +} + +/** + * Validate a contract id shape before it is used in a URL path or rendered. + * + * This is a sanity filter, not authentication — a contract id is not a + * 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); +} + +/** + * Split a registry `asset_identifier` into its principal and asset name. + * + * The registry returns `SP….contract-name::asset-name`. The asset name is the + * `define-fungible-token` name — the exact string a Clarity post-condition + * needs. USDA's is `usda`, NOT the `USDA` that `get-name` returns; conflating + * the two makes the wallet reject the transfer with no useful message. + * + * Returns null when the identifier is malformed or the asset part is missing, + * which is common in this registry and must not be papered over. + */ +export function parseAssetIdentifier( + identifier: string, +): { contractId: ContractId; assetName: string } | null { + const sep = identifier.indexOf("::"); + if (sep <= 0) return null; + const contractId = identifier.slice(0, sep); + const assetName = identifier.slice(sep + 2); + if (!isValidContractId(contractId)) return null; + if (assetName.length === 0) return null; + return { contractId, assetName }; +} + +/** + * Normalize a registry row into a `DiscoveredToken`, or null if it cannot be + * used to identify a contract at all. + * + * Returns null — rather than a partially-filled token — when the contract id + * or asset name is missing. A token we cannot name exactly is not selectable. + */ +export function normalizeRegistryRow(row: RegistryRow): Omit< + DiscoveredToken, + "trust" | "impersonates" | "unusableReason" +> | null { + const principal = row.contract_principal?.trim(); + if (!principal || !isValidContractId(principal)) return null; + + // Prefer asset_identifier (carries the asset name); fall back to the principal, + // in which case the asset name is unknown and must not be invented. + const parsed = row.asset_identifier ? parseAssetIdentifier(row.asset_identifier) : null; + const assetName = parsed?.contractId === principal ? parsed.assetName : ""; + if (!assetName) return null; + + const decimals = row.decimals; + if (!Number.isInteger(decimals) || (decimals as number) < 0 || (decimals as number) > 38) { + return null; + } + + return { + contractId: principal, + assetName, + decimals: decimals as number, + symbol: row.symbol?.trim() ?? "", + name: row.name?.trim() ?? "", + description: row.description?.trim() || undefined, + icon: row.image_canonical_uri || row.image_uri || undefined, + totalSupply: row.total_supply?.trim() || undefined, + }; +} + +/** True when supply is present and strictly positive. */ +export function hasPositiveSupply(token: Pick): boolean { + if (!token.totalSupply) return false; + try { + return BigInt(token.totalSupply) > 0n; + } catch { + return false; + } +} + +/** + * Distinguish "definitely has no supply" from "supply unknown". + * + * These are very different states and conflating them blocks real onboarding. + * A token reached via the deep-link or manual path carries no supply figure at + * all — Hiro's `/search` endpoint does not return one — and treating unknown as + * zero would make every unindexed token unselectable, which is precisely the + * case that path exists to serve. + */ +export function isConfirmedEmpty(token: Pick): boolean { + if (!token.totalSupply) return false; + try { + return BigInt(token.totalSupply) === 0n; + } catch { + return false; + } +} + +/** + * Classify a token for display. + * + * `curatedIds` and `curatedSymbols` come from the hand-verified list. A token + * that reuses a curated symbol but is not that token is the impersonation case + * we care most about — that is exactly how `buttcoin-stxcity` presents itself. + * + * "unusable" is deliberately limited to tokens that genuinely cannot receive a + * stream: no identity, or a confirmed empty supply. Unknown supply is NOT + * unusable. Everything else is merely "unverified", because a team deploying a + * token today must not hit a wall. + */ +export function classifyToken( + token: Pick, + curatedIds: readonly ContractId[], + curatedSymbols: ReadonlyMap, +): Pick { + if (curatedIds.includes(token.contractId)) return { trust: "curated" }; + + if (!token.symbol || !token.assetName) { + return { trust: "unusable", unusableReason: "No verified symbol or asset name" }; + } + if (isConfirmedEmpty(token)) { + return { trust: "unusable", unusableReason: "No supply — this token cannot receive a stream" }; + } + + const canonical = curatedSymbols.get(token.symbol.toLowerCase()); + if (canonical && canonical !== token.contractId) { + return { trust: "impersonator", impersonates: canonical }; + } + + return { trust: "unverified" }; +} + +/** + * Apply trust classification to a batch, preserving registry order. + * + * Curated tokens always sort first regardless of registry order, then verified + * assets, then impersonators and unusable entries last — an entry that cannot + * receive a stream should never sit above a real one. + */ +export function classifyAll( + rows: readonly Omit[], + curatedIds: readonly ContractId[], + curatedSymbols: ReadonlyMap, +): DiscoveredToken[] { + // Ordering is applied by dedupeByContract below, so it is enforced in exactly + // one place and cannot be forgotten by a caller. + return rows.map((row) => ({ ...row, ...classifyToken(row, curatedIds, curatedSymbols) })); +} + +/** + * Deduplicate by contract id, keeping the highest-trust entry for each. + * + * The registry can return the same contract more than once (one row per + * indexed asset), and a duplicate in a payment selector invites a wrong pick. + */ +export function dedupeByContract(tokens: readonly DiscoveredToken[]): DiscoveredToken[] { + const best = new Map(); + for (const token of tokens) { + const existing = best.get(token.contractId); + if (!existing || TRUST_RANK[token.trust] < TRUST_RANK[existing.trust]) { + best.set(token.contractId, token); + } + } + // Sort here rather than leaving it to callers. Ordering is a safety property, + // not a cosmetic one: an entry that cannot receive a stream must never sit + // above a real one, and a caller that forgets to sort would silently put it + // there. Equal ranks keep insertion order, so curated tokens stay in the + // curated list's order. + return [...best.values()].sort((a, b) => TRUST_RANK[a.trust] - TRUST_RANK[b.trust]); +} + +// ============================================================================ +// Deep links +// ============================================================================ + +/** + * Read a `?token=` deep link. + * + * A link is how users actually obtain a contract id — teams put + * `/dashboard/create?token=SP….usda-token` in their docs or Slack, and the + * recipient never types a 41-character string. So this path has to be as safe + * as manual entry, not a shortcut around it. + * + * Accepts a full principal, or an `SP….contract::asset` pair. Returns null for + * anything malformed; the caller then falls back to the curated default and + * lets the user choose deliberately. + */ +export function parseTokenDeepLink(raw: string | null | undefined): ContractId | null { + if (!raw) return null; + const value = raw.trim(); + if (!value) return null; + + // Tolerate `SP….contract::asset` — take the principal, which is what we + // verify; the asset name is re-proven on-chain rather than trusted from here. + const withoutAsset = value.split("::")[0] ?? value; + return isValidContractId(withoutAsset) ? withoutAsset : null; +} + +/** + * Build a candidate for the deep-link / manual path from CHAIN-proven metadata. + * + * This is how a token the registry cannot fully describe still gets impersonation + * checks. The registry's `/search` endpoint does not return an asset identifier, + * so `assetName` and `decimals` come from the resolver here — which is the + * authoritative source anyway — and only the cosmetic fields would have come + * from the registry. + * + * Using the chain-derived symbol for classification is what makes the deep-link + * path as safe as search: `buttcoin-stxcity` reports symbol `sBTC` on-chain too, + * so it is caught either way. + */ +export function candidateFromResolved( + resolved: ResolvedToken, + decorations: TokenDecorations = {}, +): RegistryCandidate { + return { + contractId: resolved.contractId, + assetName: resolved.assetName, + decimals: resolved.decimals, + symbol: resolved.symbol, + name: decorations.name ?? resolved.symbol, + description: decorations.description, + icon: decorations.icon, + }; +} + +// ============================================================================ +// Verification gate +// ============================================================================ + +/** + * The outcome of turning a registry suggestion into a streamable token. + * + * `status` is deliberately a three-state union rather than a boolean so the UI + * can distinguish "this token is fine but unverified" from "this token is not + * streamable" — the latter must never leave the user on a submit button that + * cannot succeed. + */ +export type VerifyStatus = + /** Curated, or uncurated but fully verified on-chain. */ + | "verified" + /** Chain verification disagrees with the registry; treated as unverified. */ + | "unverified" + /** Cannot be verified, so it must not be streamed. */ + | "unverifiable"; + +export interface VerifiedSelection { + status: VerifyStatus; + /** Chain-proven metadata. Null unless `status` is "verified". */ + resolved: ResolvedToken | null; + /** Human-readable reason, shown when status is not "verified". */ + reason?: string; + /** Populated when a curated symbol is claimed by a different contract. */ + impersonates?: ContractId; +} + +/** + * The single point where a discovered token becomes streamable. + * + * This exists so that no caller can skip it. The rule is one-directional and + * absolute: + * + * - `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. + * - 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. + * + * 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; + + if (discovered.trust === "unusable") { + return { + status: "unverifiable", + resolved: null, + reason: discovered.unusableReason ?? "This token cannot receive a stream", + impersonates, + }; + } + + const resolved = await resolve(discovered.contractId); + if (!resolved) { + return { + status: "unverifiable", + resolved: null, + reason: + "Could not verify this token's asset name and decimals on-chain. It cannot be streamed safely.", + impersonates, + }; + } + + const decimalsAgree = resolved.decimals === discovered.decimals; + 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). + return { + status: "unverified", + resolved: null, + reason: !assetAgrees + ? `Token listing reports asset "${discovered.assetName}" but the contract reports "${resolved.assetName}". Confirm before streaming.` + : `Token listing reports ${discovered.decimals} decimals but the contract reports ${resolved.decimals}. Confirm before streaming.`, + impersonates, + }; + } + + // 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 }; +} + +// ============================================================================ +// Network client +// ============================================================================ + +/** A normalized registry row, before trust classification. */ +export type RegistryCandidate = Omit< + DiscoveredToken, + "trust" | "impersonates" | "unusableReason" +>; + +/** + * Fetch candidate tokens from the registry. + * + * Discovery only, and deliberately returns UNCLASSIFIED rows. Trust depends on + * the curated list, which is app state rather than registry state — so it is + * applied by the caller via `classifyAll`, and a network function that baked in + * its own empty curated list would hand back "unverified" for everything and + * quietly defeat impersonation detection. + * + * Failures resolve to an empty list rather than throwing, so the selector can + * degrade to the curated set instead of breaking the page. + */ +export async function searchRegistry(query: string): Promise { + const trimmed = query.trim(); + if (trimmed.length < MIN_SEARCH_LENGTH) return []; + + // `name` is a prefix/substring match server-side. Cap the length so a long + // paste can't produce an unbounded query string. + const params = new URLSearchParams({ + name: trimmed.slice(0, 64), + limit: String(REGISTRY_PAGE_SIZE), + order_by: "symbol", + order: "asc", + }); + + let data: RegistryListResponse; + try { + const res = await fetch(`${API_BASE}/metadata/v1/ft?${params.toString()}`, { + headers: { Accept: "application/json" }, + }); + if (!res.ok) return []; + data = (await res.json()) as RegistryListResponse; + } catch { + // Upstream timeout, offline, malformed JSON — all equivalent here. + return []; + } + + if (!Array.isArray(data.results)) return []; + + return data.results + .map(normalizeRegistryRow) + .filter((r): r is RegistryCandidate => r !== null); +} + +/** Cosmetic-only metadata for a specific contract. */ +export interface TokenDecorations { + name?: string; + description?: string; + icon?: string; +} + +/** + * Fetch display metadata for one specific contract, for the deep-link and + * manual-entry paths. + * + * This deliberately returns NOTHING that a transaction depends on. The + * `/metadata/v1/search` endpoint does not return an asset identifier at all — + * it returns `contract_id` and `token_number` instead of `contract_principal` + * and `asset_identifier` — so the asset name for a post-condition cannot come + * from here even in principle. Returning only cosmetics keeps that structural + * limitation from ever becoming a source of a wrong post-condition: the caller + * has no field here it could be tempted to trust. + * + * Accepting an address as a query parameter and echoing back whatever came out + * is how lookup-by-address flows get turned into an open redirect, so the + * response is matched back to the requested contract and discarded on mismatch. + * + * Every failure returns an empty object rather than throwing, because a token + * that is not indexed yet must still be usable via the chain resolver. + */ +export async function lookupDecorations(contractId: ContractId): Promise { + if (!isValidContractId(contractId)) return {}; + + try { + const params = new URLSearchParams({ contract: contractId }); + const res = await fetch(`${API_BASE}/metadata/v1/search?${params.toString()}`, { + headers: { Accept: "application/json" }, + }); + if (!res.ok) return {}; + const data: unknown = await res.json(); + if (!Array.isArray(data)) return {}; + + const match = (data as RegistrySearchRow[]).find( + (row) => row.contract_id === contractId, + ); + if (!match) return {}; + + return { + name: match.name?.trim() || undefined, + description: match.description?.trim() || undefined, + icon: match.image_canonical_uri || match.image_uri || undefined, + }; + } catch { + return {}; + } +} diff --git a/tests/token-registry.test.ts b/tests/token-registry.test.ts new file mode 100644 index 0000000..45e3095 --- /dev/null +++ b/tests/token-registry.test.ts @@ -0,0 +1,513 @@ +import { describe, it, expect } from "vitest"; + +// The pure, network-free half of the token registry. The point of testing these +// directly is that discovery can be wrong, stale, or hostile without ever +// producing a misdirected payment — because `verifySelection` is the only path +// from a suggestion to a transaction, and it takes decimals/assetName from the +// chain rather than from the registry. +import { + candidateFromResolved, + classifyAll, + classifyToken, + dedupeByContract, + hasPositiveSupply, + isConfirmedEmpty, + isValidContractId, + normalizeRegistryRow, + parseAssetIdentifier, + parseTokenDeepLink, + verifySelection, + MAX_RESULTS, + type DiscoveredToken, + type RegistryRow, +} from "../frontend/src/lib/token-registry"; +import type { ResolvedToken } from "../frontend/src/lib/token-metadata"; + +// Real mainnet contracts. `buttcoin-stxcity` is a genuine row that Hiro's +// registry returns for `?symbol=sBTC` — one of 32 — and it is why symbol +// matching alone can never be trusted in a payment selector. +const USDA = "SP2C2YFP12AJZB4MABJBAJ55XECVS7E4PMMZ89YZR.usda-token"; +const SBTC = "SM3VDXK3WZZSA84XXFKAFAF15NNZX32CTSG82JFQ4.sbtc-token"; +const ALEX = "SP102V8P0F7JX67ARQ77WEA3D3CFB5XW39REDT0AM.token-alex"; +const FAKE_SBTC = "SPRFX4NGWZ2R8056122W037F8H8ST4V4D5BPG6AW.buttcoin-stxcity"; +const NEW_TOKEN = "SP3NEWNEWNEWNEWNEWNEWNEWNEWNEWNEWNEWNEWNE.nt"; + +const CURATED_IDS = [USDA, SBTC, ALEX]; +const CURATED_SYMBOLS = new Map([ + ["usda", USDA], + ["sbtc", SBTC], + ["alex", ALEX], +]); + +function row(over: Partial = {}): RegistryRow { + return { + contract_principal: USDA, + asset_identifier: `${USDA}::usda`, + name: "USDA", + symbol: "USDA", + decimals: 6, + total_supply: "1000000000000", + ...over, + }; +} + +function discovered(over: Partial = {}): DiscoveredToken { + return { + contractId: NEW_TOKEN, + assetName: "nt", + decimals: 6, + symbol: "NEWTOK", + name: "New Team Token", + totalSupply: "1000000", + trust: "unverified", + ...over, + }; +} + +function stubResolved(resolved: ResolvedToken | null) { + return async (): Promise => resolved; +} + +describe("isValidContractId", () => { + it("accepts mainnet and contract-address principals", () => { + expect(isValidContractId(USDA)).toBe(true); + expect(isValidContractId(SBTC)).toBe(true); + }); + + it("rejects a bare address with no contract name", () => { + expect(isValidContractId("SP2C2YFP12AJZB4MABJBAJ55XECVS7E4PMMZ89YZR")).toBe(false); + }); + + it("rejects non-base32c casing and non-strings", () => { + expect(isValidContractId("sp2c2yfp12ajzb4mabjbaj55xecvs7e4pmmz89yzr.usda-token")).toBe(false); + expect(isValidContractId(undefined)).toBe(false); + expect(isValidContractId(null)).toBe(false); + expect(isValidContractId("")).toBe(false); + expect(isValidContractId(42)).toBe(false); + }); + + it("rejects hostile strings that would otherwise reach a URL or the DOM", () => { + expect(isValidContractId("javascript:alert(1).foo")).toBe(false); + expect(isValidContractId("https://evil.example/token")).toBe(false); + expect(isValidContractId(`${USDA}\nSP3EVWKP0G9DNTB0FHWGHHTKNPMVJ0BB5Y9F8Z8E.x`)).toBe(false); + }); +}); + +describe("parseAssetIdentifier", () => { + it("splits a real registry identifier into principal and asset name", () => { + expect(parseAssetIdentifier(`${USDA}::usda`)).toEqual({ + contractId: USDA, + assetName: "usda", + }); + }); + + it("handles hyphens in both halves (sBTC)", () => { + expect(parseAssetIdentifier(`${SBTC}::sbtc-token`)).toEqual({ + contractId: SBTC, + assetName: "sbtc-token", + }); + }); + + it("preserves asset-name case exactly — post-conditions are case-sensitive", () => { + // Lowercasing "alex-Locked" here would produce a rejected transfer. + expect(parseAssetIdentifier(`${ALEX}::alex-Locked`)?.assetName).toBe("alex-Locked"); + }); + + it("returns null rather than inventing a missing asset name", () => { + expect(parseAssetIdentifier(USDA)).toBeNull(); + expect(parseAssetIdentifier(`${USDA}::`)).toBeNull(); + expect(parseAssetIdentifier("not-an-address::usda")).toBeNull(); + expect(parseAssetIdentifier("")).toBeNull(); + }); +}); + +describe("normalizeRegistryRow", () => { + it("normalizes a complete row", () => { + expect(normalizeRegistryRow(row())).toMatchObject({ + contractId: USDA, + assetName: "usda", + decimals: 6, + symbol: "USDA", + }); + }); + + it("prefers the canonical image URI", () => { + expect( + normalizeRegistryRow(row({ image_canonical_uri: "ipfs://canon", image_uri: "https://x" }))?.icon, + ).toBe("ipfs://canon"); + expect(normalizeRegistryRow(row({ image_uri: "https://fallback" }))?.icon).toBe( + "https://fallback", + ); + }); + + it("refuses a row with no asset_identifier instead of guessing the asset name", () => { + // The regression this guards: deriving the post-condition asset name from + // the symbol or contract name. USDA's get-name is "USDA" but its asset name + // is "usda", and conflating them makes the wallet reject the transfer. + expect(normalizeRegistryRow(row({ asset_identifier: undefined }))).toBeNull(); + }); + + it("refuses a row whose asset_identifier points at a different contract", () => { + expect(normalizeRegistryRow(row({ asset_identifier: `${SBTC}::sbtc-token` }))).toBeNull(); + }); + + it("rejects out-of-range decimals", () => { + expect(normalizeRegistryRow(row({ decimals: -1 }))).toBeNull(); + expect(normalizeRegistryRow(row({ decimals: 39 }))).toBeNull(); + expect(normalizeRegistryRow(row({ decimals: 6.5 }))).toBeNull(); + expect(normalizeRegistryRow(row({ decimals: undefined }))).toBeNull(); + }); + + it("accepts zero decimals, which is valid per SIP-010", () => { + expect(normalizeRegistryRow(row({ decimals: 0 }))?.decimals).toBe(0); + }); + + it("trims whitespace and treats blank strings as absent", () => { + const n = normalizeRegistryRow(row({ symbol: " USDA ", name: " " })); + expect(n?.symbol).toBe("USDA"); + expect(n?.name).toBe(""); + }); + + it("refuses a malformed contract principal", () => { + expect(normalizeRegistryRow(row({ contract_principal: "SPBAD.usda-token" }))).toBeNull(); + }); +}); + +describe("hasPositiveSupply", () => { + it("detects positive supply exactly, including beyond float precision", () => { + expect(hasPositiveSupply({ totalSupply: "1000" })).toBe(true); + expect(hasPositiveSupply({ totalSupply: "1000000000000000000000" })).toBe(true); + }); + + it("treats zero and missing supply as not positive", () => { + expect(hasPositiveSupply({ totalSupply: "0" })).toBe(false); + expect(hasPositiveSupply({ totalSupply: undefined })).toBe(false); + }); +}); + +describe("isConfirmedEmpty", () => { + it("separates unknown supply from confirmed-empty supply", () => { + // A deep-link candidate carries no supply figure at all. Treating unknown + // as zero would make every unindexed token unselectable — exactly the case + // the manual path exists to serve. + expect(isConfirmedEmpty({ totalSupply: "0" })).toBe(true); + expect(isConfirmedEmpty({ totalSupply: undefined })).toBe(false); + expect(isConfirmedEmpty({ totalSupply: "" })).toBe(false); + expect(isConfirmedEmpty({ totalSupply: "not-a-number" })).toBe(false); + }); + + it("keeps a token with unknown supply selectable", () => { + // The onboarding requirement, stated at the classification level: a token + // reached by contract id must not be blocked for something we never checked. + expect( + classifyToken( + { contractId: NEW_TOKEN, symbol: "NEW", totalSupply: undefined, assetName: "nt" }, + CURATED_IDS, + CURATED_SYMBOLS, + ).trust, + ).toBe("unverified"); + }); +}); + +describe("classifyToken", () => { + it("marks a hand-verified token as curated", () => { + expect( + classifyToken( + { contractId: USDA, symbol: "USDA", totalSupply: "1", assetName: "usda" }, + CURATED_IDS, + CURATED_SYMBOLS, + ).trust, + ).toBe("curated"); + }); + + it("flags a token claiming a curated symbol as an impersonator", () => { + // buttcoin-stxcity really is returned by ?symbol=sBTC. + const r = classifyToken( + { contractId: FAKE_SBTC, symbol: "sBTC", totalSupply: "21000000000000", assetName: "sBTC" }, + CURATED_IDS, + CURATED_SYMBOLS, + ); + expect(r.trust).toBe("impersonator"); + expect(r.impersonates).toBe(SBTC); + }); + + it("matches curated symbols case-insensitively", () => { + expect( + classifyToken( + { contractId: FAKE_SBTC, symbol: "sbtc", totalSupply: "1", assetName: "sBTC" }, + CURATED_IDS, + CURATED_SYMBOLS, + ).trust, + ).toBe("impersonator"); + }); + + it("never flags the canonical token as impersonating itself", () => { + expect( + classifyToken( + { contractId: SBTC, symbol: "sBTC", totalSupply: "1", assetName: "sbtc-token" }, + CURATED_IDS, + CURATED_SYMBOLS, + ).trust, + ).toBe("curated"); + }); + + it("marks tokens that cannot receive a stream as unusable", () => { + expect( + classifyToken( + { contractId: NEW_TOKEN, symbol: "", totalSupply: "1", assetName: "nt" }, + CURATED_IDS, + CURATED_SYMBOLS, + ).trust, + ).toBe("unusable"); + expect( + classifyToken( + { contractId: NEW_TOKEN, symbol: "NEW", totalSupply: "0", assetName: "nt" }, + CURATED_IDS, + CURATED_SYMBOLS, + ).trust, + ).toBe("unusable"); + expect( + classifyToken( + { contractId: NEW_TOKEN, symbol: "NEW", totalSupply: "1000", assetName: "" }, + CURATED_IDS, + CURATED_SYMBOLS, + ).trust, + ).toBe("unusable"); + }); + + it("keeps a brand-new team token selectable — warn, never block", () => { + // The onboarding requirement: deploying and streaming in the same session + // must not hit a wall. + expect( + classifyToken( + { contractId: NEW_TOKEN, symbol: "NEW", totalSupply: "1000", assetName: "nt" }, + CURATED_IDS, + CURATED_SYMBOLS, + ).trust, + ).toBe("unverified"); + }); +}); + +describe("dedupeByContract", () => { + it("keeps the highest-trust entry when one contract appears twice", () => { + const out = dedupeByContract([ + discovered({ contractId: USDA, trust: "impersonator" }), + discovered({ contractId: USDA, trust: "curated" }), + ]); + expect(out).toHaveLength(1); + expect(out[0]!.trust).toBe("curated"); + }); + + it("orders curated, unverified, impersonator, unusable", () => { + // An entry that cannot receive a stream must never sit above a real one. + const out = dedupeByContract([ + discovered({ contractId: "SP3AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA.u", trust: "unusable" }), + discovered({ contractId: FAKE_SBTC, trust: "impersonator" }), + discovered({ contractId: "SP3BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB.b", trust: "unverified" }), + discovered({ contractId: USDA, trust: "curated" }), + ]); + expect(out.map((t) => t.trust)).toEqual([ + "curated", + "unverified", + "impersonator", + "unusable", + ]); + }); +}); + +describe("parseTokenDeepLink", () => { + it("accepts a principal, which is how users actually get a contract id", () => { + expect(parseTokenDeepLink(USDA)).toBe(USDA); + expect(parseTokenDeepLink(SBTC)).toBe(SBTC); + }); + + it("tolerates an asset suffix and discards it for re-verification", () => { + expect(parseTokenDeepLink(`${USDA}::usda`)).toBe(USDA); + // A truncated `SP….::` keeps a usable principal. Safe to accept, because + // the asset name is re-proven on-chain rather than taken from the link. + expect(parseTokenDeepLink(`${USDA}::`)).toBe(USDA); + }); + + it("tolerates whitespace from a pasted link", () => { + expect(parseTokenDeepLink(` ${USDA} `)).toBe(USDA); + }); + + it("returns null for anything malformed so the caller falls back safely", () => { + expect(parseTokenDeepLink(null)).toBeNull(); + expect(parseTokenDeepLink(undefined)).toBeNull(); + expect(parseTokenDeepLink("")).toBeNull(); + expect(parseTokenDeepLink(" ")).toBeNull(); + expect(parseTokenDeepLink("usda")).toBeNull(); + expect(parseTokenDeepLink("../../etc/passwd")).toBeNull(); + expect(parseTokenDeepLink("https://evil.example/token")).toBeNull(); + }); +}); + +describe("verifySelection", () => { + it("uses chain decimals and assetName, and flags any disagreement", async () => { + // Registry says 6/usda, chain says 8/usda-real. The chain must win, and the + // discrepancy itself must reach the user — it can mean a renamed token, + // 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", + decimals: 8, + symbol: "USDA", + curated: false, + }), + ); + expect(out.status).toBe("unverified"); + expect(out.resolved).toBeNull(); + expect(out.reason).toContain("usda-real"); + }); + + 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", + decimals: 6, + symbol: "NEWTOK", + curated: false, + }), + ); + expect(out.status).toBe("verified"); + expect(out.resolved?.decimals).toBe(6); + }); + + 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 + // fallback here would silently stream sBTC. + const out = await verifySelection(discovered(), CURATED_IDS, CURATED_SYMBOLS, stubResolved(null)); + expect(out.status).toBe("unverifiable"); + expect(out.resolved).toBeNull(); + }); + + it("never calls the resolver for an unusable token", async () => { + let called = false; + const out = await verifySelection( + discovered({ trust: "unusable", unusableReason: "No supply — cannot receive a stream" }), + CURATED_IDS, + CURATED_SYMBOLS, + async () => { + called = true; + return null; + }, + ); + expect(called).toBe(false); + expect(out.status).toBe("unverifiable"); + expect(out.reason).toBe("No supply — cannot receive a stream"); + }); + + it("carries impersonation forward even when the chain verifies the contract", async () => { + // The contract genuinely is that token, so verification passes — but the UI + // must still be able to warn. + const out = await verifySelection( + discovered({ + contractId: FAKE_SBTC, + assetName: "sBTC", + symbol: "sBTC", + decimals: 6, + trust: "impersonator", + impersonates: SBTC, + }), + CURATED_IDS, + CURATED_SYMBOLS, + stubResolved({ + contractId: FAKE_SBTC, + assetName: "sBTC", + decimals: 6, + symbol: "sBTC", + curated: false, + }), + ); + expect(out.status).toBe("verified"); + expect(out.impersonates).toBe(SBTC); + }); + + 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", + decimals: 8, + symbol: "NEWTOK", + curated: false, + }), + ); + expect(out.status).toBe("unverified"); + expect(out.reason).toContain("decimals"); + }); + + it("keeps the display cap in a sane range", () => { + expect(MAX_RESULTS).toBeGreaterThanOrEqual(10); + expect(MAX_RESULTS).toBeLessThanOrEqual(100); + }); +}); + +describe("candidateFromResolved", () => { + // The deep-link/manual path cannot get assetName from the registry: Hiro's + // `/metadata/v1/search` endpoint returns `contract_id` and `token_number`, + // with no asset identifier at all. So these fields come from the chain, and + // these tests pin that the chain values are the ones carried forward. + const chainResolved: ResolvedToken = { + contractId: SBTC, + assetName: "sbtc-token", + decimals: 8, + symbol: "sBTC", + curated: true, + }; + + it("carries the chain asset name and decimals, not anything from a listing", () => { + const c = candidateFromResolved(chainResolved); + expect(c.contractId).toBe(SBTC); + expect(c.assetName).toBe("sbtc-token"); + expect(c.decimals).toBe(8); + }); + + it("defaults the display name to the proven symbol when the registry has none", () => { + // A token the registry has not indexed still needs a label. + expect(candidateFromResolved(chainResolved).name).toBe("sBTC"); + }); + + it("prefers registry cosmetics when they exist", () => { + const c = candidateFromResolved(chainResolved, { + name: "Synthetic Bitcoin", + description: "1:1 BTC-backed", + icon: "ipfs://icon", + }); + expect(c.name).toBe("Synthetic Bitcoin"); + expect(c.description).toBe("1:1 BTC-backed"); + expect(c.icon).toBe("ipfs://icon"); + // ...but never the fields a transaction depends on. + expect(c.assetName).toBe("sbtc-token"); + expect(c.decimals).toBe(8); + }); + + it("still gets caught as an impersonator when built from chain data alone", () => { + // buttcoin-stxcity reports symbol "sBTC" on-chain too, so the deep-link path + // is as protected as search — this is what makes that guarantee hold. + const fake: ResolvedToken = { + contractId: FAKE_SBTC, + assetName: "sBTC", + decimals: 6, + symbol: "sBTC", + curated: false, + }; + const classified = dedupeByContract( + classifyAll([candidateFromResolved(fake)], CURATED_IDS, CURATED_SYMBOLS), + )[0]!; + expect(classified.trust).toBe("impersonator"); + expect(classified.impersonates).toBe(SBTC); + }); +});