Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions frontend/src/components/stream/token-selector.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -232,6 +232,11 @@ function ResultRow({
{unusable && token.unusableReason && (
<div className="mt-1 text-[11px] text-amber-500/80">{token.unusableReason}</div>
)}
{token.warnings?.map((warning) => (
<div key={warning} className="mt-1 text-[11px] text-zinc-500">
{warning}
</div>
))}
</div>
</button>
);
Expand Down Expand Up @@ -647,6 +652,16 @@ export function TokenSelector({ value, onChange, disabled }: TokenSelectorProps)
</div>
)}

{confirming.warnings?.length ? (
<ul className="space-y-1">
{confirming.warnings.map((warning) => (
<li key={warning} className="text-[11px] text-zinc-400">
{warning}
</li>
))}
</ul>
) : null}

<div className="flex gap-2">
<Button
type="button"
Expand Down
80 changes: 66 additions & 14 deletions frontend/src/lib/token-registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,14 @@ export interface DiscoveredToken {
impersonates?: ContractId;
/** Why this token is unusable, when `trust` is "unusable". */
unusableReason?: string;
/**
* Non-blocking cautions, shown next to the token.
*
* These deliberately do NOT prevent selection. A team deploying a token today
* must not hit a wall because its registry row is incomplete, and an indexer
* lagging a fresh mint is a fact about the indexer, not about the token.
*/
warnings?: readonly string[];
}

// ============================================================================
Expand All @@ -94,6 +102,24 @@ const TRUST_RANK: Record<TokenTrust, number> = {
unusable: 3,
};

/**
* Trust level that is never offered for selection.
*
* "unusable" is not a trust judgement, it is the absence of an identity. There
* is nothing to warn about when there is no asset name: the row cannot name the
* contract it claims to be, so no amount of confirmation makes it safe to put
* in a post-condition. Every other shortcoming is a warning.
*
* Note what is deliberately NOT here: a missing symbol, and a reported supply
* of zero. Neither can misroute funds. A wrong asset name or decimals is the
* thing that moves money, and that is settled by the chain resolver in
* `verifySelection`, never by a listing.
*/
const UNUSABLE: Pick<DiscoveredToken, "trust" | "unusableReason"> = {
trust: "unusable",
unusableReason: "No asset name — this listing cannot identify the token it describes",
};

/** Raw row shape from `/metadata/v1/ft`. Every field may be absent or empty. */
export interface RegistryRow {
contract_principal?: string;
Expand Down Expand Up @@ -205,7 +231,7 @@ export function parseAssetIdentifier(
*/
export function normalizeRegistryRow(row: RegistryRow): Omit<
DiscoveredToken,
"trust" | "impersonates" | "unusableReason"
"trust" | "impersonates" | "unusableReason" | "warnings"
> | null {
const principal = row.contract_principal?.trim();
if (!principal || !isValidContractId(principal)) return null;
Expand Down Expand Up @@ -268,31 +294,53 @@ export function isConfirmedEmpty(token: Pick<DiscoveredToken, "totalSupply">): b
* 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.
* POLICY: warn, never block. An incomplete listing is a fact about the
* indexer's record of the token, not proof that the token is unsafe to stream.
*
* - No asset name is the sole exception, because it is not a warning, it is an
* identity. See `UNUSABLE`.
* - A missing symbol is a warning. The asset name and decimals still come from
* the chain, so a token that publishes no ticker is fully streamable; the
* chain resolver can supply the symbol from `get-symbol`.
* - A reported supply of zero is a warning. Supply is a hint, it lags, and a
* transfer that cannot execute fails loudly on-chain and costs a fee. It
* cannot send funds to the wrong place. Blocking here would also fail the
* exact case this feature exists to serve: a team whose token was deployed
* minutes ago and has not been indexed with a supply yet.
*
* The one thing that does move money — asset name and decimals — is settled on
* chain in `verifySelection`, after this function runs. Classification decides
* what the user is *told*; verification decides what they can *spend*.
*/
export function classifyToken(
token: Pick<DiscoveredToken, "contractId" | "symbol" | "totalSupply" | "assetName">,
curatedIds: readonly ContractId[],
curatedSymbols: ReadonlyMap<string, ContractId>,
): Pick<DiscoveredToken, "trust" | "impersonates" | "unusableReason"> {
): Pick<DiscoveredToken, "trust" | "impersonates" | "unusableReason" | "warnings"> {
if (curatedIds.includes(token.contractId)) return { trust: "curated" };

if (!token.symbol || !token.assetName) {
return { trust: "unusable", unusableReason: "No verified symbol or asset name" };
if (!token.assetName) return { ...UNUSABLE };

const warnings: string[] = [];
if (!token.symbol) {
warnings.push("This listing publishes no ticker. The symbol is read from the contract.");
}
if (isConfirmedEmpty(token)) {
return { trust: "unusable", unusableReason: "No supply — this token cannot receive a stream" };
warnings.push(
"This listing reports no supply. It may just be unindexed — the transfer will fail if the contract cannot move it.",
);
}

const canonical = curatedSymbols.get(token.symbol.toLowerCase());
const canonical = token.symbol ? curatedSymbols.get(token.symbol.toLowerCase()) : undefined;
if (canonical && canonical !== token.contractId) {
return { trust: "impersonator", impersonates: canonical };
return {
trust: "impersonator",
impersonates: canonical,
...(warnings.length ? { warnings } : {}),
};
}

return { trust: "unverified" };
return warnings.length ? { trust: "unverified", warnings } : { trust: "unverified" };
}

/**
Expand All @@ -303,7 +351,7 @@ export function classifyToken(
* receive a stream should never sit above a real one.
*/
export function classifyAll(
rows: readonly Omit<DiscoveredToken, "trust" | "impersonates" | "unusableReason">[],
rows: readonly Omit<DiscoveredToken, "trust" | "impersonates" | "unusableReason" | "warnings">[],
curatedIds: readonly ContractId[],
curatedSymbols: ReadonlyMap<string, ContractId>,
): DiscoveredToken[] {
Expand Down Expand Up @@ -446,6 +494,10 @@ export async function verifySelection(
): Promise<VerifiedSelection> {
const impersonates = discovered.impersonates;

// `warnings` are deliberately not consulted here. They describe the listing,
// not the asset, and the asset is what this function is about to prove. Only
// a missing identity short-circuits, and only because there is nothing to
// resolve.
if (discovered.trust === "unusable") {
return {
status: "unverifiable",
Expand Down Expand Up @@ -498,7 +550,7 @@ export async function verifySelection(
/** A normalized registry row, before trust classification. */
export type RegistryCandidate = Omit<
DiscoveredToken,
"trust" | "impersonates" | "unusableReason"
"trust" | "impersonates" | "unusableReason" | "warnings"
>;

/**
Expand Down
101 changes: 83 additions & 18 deletions tests/token-registry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -251,21 +251,10 @@ describe("classifyToken", () => {
).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");
it("marks only a token with no identity as unusable", () => {
// No asset name means the row cannot say which asset it is describing.
// There is nothing to warn about and nothing to resolve, so this is the one
// case that is not offered for selection.
expect(
classifyToken(
{ contractId: NEW_TOKEN, symbol: "NEW", totalSupply: "1000", assetName: "" },
Expand All @@ -275,6 +264,57 @@ describe("classifyToken", () => {
).toBe("unusable");
});

it("warns but never blocks on a missing ticker", () => {
// The symbol is display metadata; asset name and decimals come from the
// chain, so a token that publishes no ticker is fully streamable.
const result = classifyToken(
{ contractId: NEW_TOKEN, symbol: "", totalSupply: "1", assetName: "nt" },
CURATED_IDS,
CURATED_SYMBOLS,
);
expect(result.trust).toBe("unverified");
expect(result.warnings).toHaveLength(1);
expect(result.unusableReason).toBeUndefined();
});

it("warns but never blocks on a reported supply of zero", () => {
// Supply is a hint that lags. Blocking here would fail the exact case this
// feature exists to serve: a token deployed minutes ago whose registry row
// has no supply yet.
const result = classifyToken(
{ contractId: NEW_TOKEN, symbol: "NEW", totalSupply: "0", assetName: "nt" },
CURATED_IDS,
CURATED_SYMBOLS,
);
expect(result.trust).toBe("unverified");
expect(result.warnings).toHaveLength(1);
expect(result.unusableReason).toBeUndefined();
});

it("does not mistake a missing ticker for a curated-symbol impersonation", () => {
// Guard against the empty string matching an empty curated symbol key.
const result = classifyToken(
{ contractId: NEW_TOKEN, symbol: "", totalSupply: "1", assetName: "nt" },
CURATED_IDS,
CURATED_SYMBOLS,
);
expect(result.trust).not.toBe("impersonator");
expect(result.impersonates).toBeUndefined();
});

it("still separates an impersonator that is also incomplete", () => {
// Both signals are reported; the impersonation must not be masked by the
// missing ticker.
const result = classifyToken(
{ contractId: FAKE_SBTC, symbol: "sBTC", totalSupply: "0", assetName: "sBTC" },
CURATED_IDS,
CURATED_SYMBOLS,
);
expect(result.trust).toBe("impersonator");
expect(result.impersonates).toBe(SBTC);
expect(result.warnings).toHaveLength(1);
});

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.
Expand Down Expand Up @@ -390,10 +430,13 @@ describe("verifySelection", () => {
expect(out.resolved).toBeNull();
});

it("never calls the resolver for an unusable token", async () => {
it("never calls the resolver for a token with no identity", async () => {
let called = false;
const out = await verifySelection(
discovered({ trust: "unusable", unusableReason: "No supply — cannot receive a stream" }),
discovered({
trust: "unusable",
unusableReason: "No asset name — this listing cannot identify the token it describes",
}),
CURATED_IDS,
CURATED_SYMBOLS,
async () => {
Expand All @@ -403,7 +446,29 @@ describe("verifySelection", () => {
);
expect(called).toBe(false);
expect(out.status).toBe("unverifiable");
expect(out.reason).toBe("No supply — cannot receive a stream");
});

it("verifies normally when the token only carries warnings", async () => {
// The policy in one assertion: warnings describe the listing, so they must
// not change the verification outcome. A warned token that the chain
// proves is still proven.
const out = await verifySelection(
discovered({
symbol: "",
trust: "unverified",
warnings: ["This listing publishes no ticker."],
}),
CURATED_IDS,
CURATED_SYMBOLS,
stubResolved({
contractId: NEW_TOKEN,
assetName: "nt",
decimals: 6,
symbol: "NT",
}),
);
expect(out.status).toBe("verified");
expect(out.resolved?.assetName).toBe("nt");
});

it("carries impersonation forward even when the chain verifies the contract", async () => {
Expand Down
Loading