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); + }); +});