diff --git a/CHANGELOG.md b/CHANGELOG.md index 9a0c3913..0fdd7822 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,72 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added — coinless tokens (#777, #781; wallet-api#140/#141/#147) + +`payments.coinless(): CoinlessToken[]` and `payments.tokenData(tokenId): Promise`. + +A token whose genesis data carries no value envelope names no coin — an NFT. `tokens()` skipped +every such entry, so it was held, verified, claimed and tombstone-recoverable, and shown nowhere. + +The two reads are **disjoint**: an active inventory entry is in exactly one, so every existing +`tokens()`/`assets()` consumer is byte-identical and a coinless token joins no balance and no +coin-selection pool. It is deliberately not a `Token` — that type requires `coinId`, `symbol`, +`decimals` and `amount`, and filling them with `''`/`'0'` would put untrue values in fields +consumers sum or format. (#781 proposed widening `tokens()`; the divergence is recorded there.) + +`tokenData()` is a call rather than a field: the genesis payload is an NFT's actual content, it is +unbounded, and blobs are lazy under server custody, so a list read must never carry it. + +`tokenType` names the token's **class, not the instance** — every token of one kind shares a type. +Display metadata (`name`, `iconUrl`) is resolved onto the row from the registry the Sphere OWNS +(#767), so callers never reach for one: the process-global singleton is repointed by another +Sphere's init, which would retarget a second wallet on another network. `TokenRegistry` gains +`getTypeDefinition()`/`getTypeMeta()`, reading the token-type namespace — one registry file carries +both namespaces discriminated by `assetKind`, and the flat `getDefinition()` map cannot tell a type +from a coin id. An unrecognised type is legitimate and must never cause a token to be hidden. + +`transfer:incoming` now names an arriving coinless token in a disjoint `coinless` field; it +previously mapped over assets, so such an arrival announced `tokens: []` and a UI listening for +arrivals saw nothing land. + +### Fixed — a corrupt value envelope no longer reads as "no value" (#778) + +`isSpherePaymentData` was `try { decodeTag(d).tag === CBOR_TAG } catch { return false }`, and both +callers read `false` as "data token, no value". Since `decodeTag` parses the tagged body and asserts +exhaustion, a **valid** `SpherePaymentData` carrying one trailing byte, a truncated one, a +non-canonically encoded tag head, and a `tag(55799)`-wrapped envelope each rendered as `value = null` +— real coins shown as zero, silently, with no error surface. A balance has no other one: showing +zero is the outcome from which a user cannot tell "no coins" from "I cannot read the coins". + +Replaced by a structural classifier (`token-engine/value-envelope.ts`) ported from wallet-api's §8.2 +step 6, reading the outer major type and the tag head alone. `SphereToken` gains `valueEnvelope`, +which distinguishes *why* `value` is null: `none_*` is genuinely coinless, `bare_collection` is the +bridged dialect this SDK does not decode (so zero means "cannot read", not "carries none"). + +Two fail-closed guards, both before any chain op: `split()` refuses a source whose value cannot be +read (it previously died inside the SDK with a bare `CborError` naming neither token nor cause), and +`mintDataToken()` refuses opaque bytes classification cannot frame — that check runs *before* the +mint, because `wrapToken` runs after certification and would otherwise strand an on-chain token. + +### Fixed — history can record a coinless movement (#780; wallet-api#142/#151) + +`recordSent`/`recordMint`/`recordReceived` wrapped scalars unconditionally, so the only expressible +shape was a one-element array — `[{coinId: '', amount: '0'}]` for a coinless token, which wallet-api +refuses so there is never a second wire spelling of "no coin". All three now take the asset list +directly and post `assets: []`. + +`History.post` logged every failure as retry-safe. A 4xx is a permanent shape refusal that no retry +can fix, and that indiscriminate swallow is what let a refused receipt vanish with no error surface; +the two are now named apart (408/429 stay transient). It still never throws into the money path. + +### Changed (BREAKING, internal ports) + +`RecordSentInput`/`RecordMintInput`/`RecordReceivedInput` take `assets: {coinId, amount}[]` in place +of `coinId`/`amount` scalars. `SphereToken` gains required `valueEnvelope` and `tokenType`. +`InventoryItem`/`InventoryItemWire` gain optional `tokenType`. These are internal to the vertical and +the token-engine port; no root-export type changed shape except the additive `CoinlessToken` and +`IncomingTransfer.coinless`. + ## [0.16.0] - 2026-09-03 ### Removed (BREAKING) — the Sphere lifecycle globals (#766) diff --git a/CLAUDE.md b/CLAUDE.md index 8a5a772d..2c523284 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -147,6 +147,8 @@ console.log('Unicity ID:', identity.nametag); // alice const assets = await sphere.payments.assets(); // Asset[] grouped by coin const uct = await sphere.payments.assets(coinIdHex); // filter by coin const tokens = sphere.payments.tokens(); // individual Token[] (sync view) +const nfts = sphere.payments.coinless(); // CoinlessToken[] — DISJOINT from tokens() +const payload = await sphere.payments.tokenData(id); // an NFT's genesis bytes, or null const filtered = sphere.payments.tokens({ coinId: '...' }); // 6. Send tokens (L3). Recipient must have a PUBLISHED chain pubkey @@ -264,7 +266,9 @@ Typed RPC layer for dApp ↔ wallet communication. Full guide: [`docs/CONNECT.md | `Sphere.import(options)` | `Sphere` | Import from mnemonic/masterKey | | `Sphere.importFromLegacyFile(options)` | `Sphere` | Import a `.txt` / flat-JSON / bare-mnemonic backup | | `sphere.payments.assets(coinId?)` | `Promise` | Assets grouped by coin (server read-through) | -| `sphere.payments.tokens(filter?)` | `Token[]` | Individual tokens (sync inventory view) | +| `sphere.payments.tokens(filter?)` | `Token[]` | Individual COIN tokens (sync inventory view) | +| `sphere.payments.coinless()` | `CoinlessToken[]` | Coinless (NFT) holdings — disjoint from `tokens()` | +| `sphere.payments.tokenData(tokenId)` | `Promise` | A token's genesis payload (fetches the blob) | | `sphere.payments.send(request)` | `Promise` | Send L3 tokens (wallet-api vertical) | | `sphere.payments.mint(coinIdHex, amount)` | `Promise` | Self-mint via engine (journal-first, no faucet) | | `sphere.payments.receive()` | `Promise<{ transfers }>` | Explicit one-shot mailbox drain | @@ -299,7 +303,7 @@ The payments vertical emits exactly 8 events; identity/comms/groupchat events ri | Event | Payload | When | |-------|---------|------| -| `transfer:incoming` | `IncomingTransfer` (`{ senderPubkey, senderNametag?, tokens, memo?, receivedAt }`) | Tokens landed from the wallet-api mailbox (verified before entering balance) | +| `transfer:incoming` | `IncomingTransfer` (`{ senderPubkey, senderNametag?, tokens, coinless?, memo?, receivedAt }`) | Tokens landed from the wallet-api mailbox (verified before entering balance). A coinless arrival is named in `coinless`, NOT in `tokens` — read both | | `transfer:updated` | `TransferResult` | Outgoing transfer changed status (read `status` / `deliveryPending`) | | `transfer:attention` | `{ transferId, code, detail? }` | A transfer needs operator attention (stuck checkpoint, undeliverable, deferred) | | `inventory:updated` | `{}` | Inventory changed (send/receive/mint/resync) | @@ -565,10 +569,30 @@ interface TokenBlob { token: Uint8Array; // the SDK's own Token.toCBOR() bytes — no sphere envelope } +// A holding that names NO coin (wallet-api#140) — an NFT. Deliberately NOT a Token: +// that type requires coinId/symbol/decimals/amount, and sentinels would put untrue +// values in fields consumers sum. Disjoint from tokens(); joins no balance. +interface CoinlessToken { + tokenId: string; // the INSTANCE key + tokenType?: string; // the token's CLASS, lowercase hex — see the caveat below + name?: string; // resolved from the wallet's OWN registry when recognised + iconUrl?: string; + stateHash: string; + transferring: boolean; + suspectedSpent?: boolean; + createdAt: number; + updatedAt: number; +} + interface SphereToken { sdkToken: Token; // OPAQUE SDK handle — never touch outside token-engine/ blob: TokenBlob; // serializable form value: SphereValue | null; // decoded { assets: [{ coinId, amount: bigint }] } + // #778: WHY value is null. 'none_*' = genuinely coinless; 'bare_collection' = + // coins in the bridged dialect this SDK does not decode, so zero means "cannot + // read", NOT "has none". A corrupt envelope throws instead of reaching this. + valueEnvelope: 'sphere' | 'bare_collection' | 'none_tag' | 'none_other' | 'none_absent'; + tokenType: string; // genesis TokenType hex — the CLASS, never the instance } ``` @@ -711,6 +735,32 @@ authoritative for build success. chain op; a replay converges by idempotent same-seed re-call. Lets a fresh wallet top up on testnet2. +### Coinless tokens (#777/#778/#780/#781, wallet-api#140/#141/#147/#151) +- A token whose genesis data is **not a value envelope** names no coin. The word is **coinless**, + never "non-fungible": in Unicity every token is non-fungible by construction (each is a unique + object keyed by `tokenId`), so that term names every token and distinguishes none. +- Surfaced by `payments.coinless()`, **disjoint** from `tokens()` — an active entry is in exactly + one, so no coin consumer changes and an NFT joins no balance and no selector pool. It is not a + `Token`: that requires `coinId`/`symbol`/`decimals`/`amount`, and sentinels put untrue values in + fields consumers sum. `payments.tokenData(tokenId)` reads the genesis payload on demand. +- **`tokenType` is a CLASS, not an identity.** Every token of one kind shares a type; `tokenId` is + the instance key. Resolve names with `TokenRegistry.getTypeDefinition()` — the registry file + holds TWO id namespaces discriminated by `assetKind` (a `fungible` entry's id is a coin id, a + `non-fungible` entry's is a token type), and the flat `getDefinition()` map cannot tell them + apart. An unrecognised type is legitimate — never reject or hide a token for it. Do NOT build a + "group by type" UI for *valued* tokens: `mint()` and split outputs derive a type per operation. +- **`value === null` is ambiguous — read `valueEnvelope`.** `none_*` is genuinely coinless; + `bare_collection` is the bridged dialect this SDK does not decode, so zero there means "cannot + read", not "has none". Conflating them either hides real coins or invents a phantom NFT. +- A corrupt value envelope **throws** rather than reading as valueless (#778). The classifier's + throw set must stay a SUBSET of wallet-api's §8.2 422 set: everything arriving over the mailbox + already passed §8.2, and `Receive.screen()` turns a decode throw into a terminal + `rejectAck('invalid')`, so throwing where wallet-api accepts LOSES the token. +- History records `assets: []` for a coinless movement on every type. Never `coinId: ''` — + wallet-api keeps refusing that so there is only one wire spelling of "no coin". +- The coinless verdict is DERIVED from wallet-api's §8.2 step-6 boundary. Moving that boundary + needs the client verdict re-derived, not merely re-tested (recorded in wallet-api's §8.2 too). + ### Unicity IDs (nametags) - Human-readable aliases (e.g., `@alice`) for receiving payments. - **Registration = publishing the Nostr identity binding** (name ↔ chainPubkey, diff --git a/docs/API.md b/docs/API.md index 132ed195..9d4c7423 100644 --- a/docs/API.md +++ b/docs/API.md @@ -382,6 +382,7 @@ so a crash re-claims instead of losing. ```typescript const { transfers } = await sphere.payments.receive(); sphere.on('transfer:incoming', (t) => console.log('from', t.senderNametag)); +// A coinless (NFT) arrival is named in `t.coinless`, never in `t.tokens` — read both. ``` ### `assets(coinId?: string): Promise` @@ -408,6 +409,57 @@ const all = sphere.payments.tokens(); const uctOnly = sphere.payments.tokens({ coinId: coinIdHex }); ``` +### `coinless(): CoinlessToken[]` + +Synchronous view of holdings that name **no coin** — what a UI calls an NFT (wallet-api#140). + +**Disjoint from `tokens()`**: an active token is in exactly one of the two reads, so existing +`tokens()`/`assets()` consumers are unaffected and a coinless token contributes to no balance. +It is deliberately not a `Token`: `Token` requires `coinId`, `symbol`, `decimals` and `amount`, +and filling those with `''`/`'0'` would put untrue values in fields consumers sum or format. + +```typescript +interface CoinlessToken { + readonly tokenId: string; // genesis-stable INSTANCE key + readonly tokenType?: string; // the token's CLASS, lowercase hex — see below + readonly name?: string; // resolved from the OWNED registry, when recognised + readonly iconUrl?: string; + readonly stateHash: string; + readonly transferring: boolean; // reserved by a converging transfer + readonly suspectedSpent?: boolean; + readonly createdAt: number; + readonly updatedAt: number; +} + +const nfts = sphere.payments.coinless(); +``` + +`tokenType` names the token's **class, not the instance** — every token of one kind shares a type, +so two NFTs of a collection are told apart by `tokenId`. It is absent on rows the backend indexed +before it recorded types, and an unrecognised type is legitimate (a minter may use its own), so +never reject or hide a token for it. Do not build a "group by type" UI on it for *valued* tokens: +`mint()` and split outputs derive a type per operation, so there it is per-mint noise. + +`name` and `iconUrl` are resolved **for you**, from the registry this wallet owns, whenever the type +is recognised. Do not look the type up yourself through `TokenRegistry.getInstance()`: a Sphere owns +its registry (#767) and the process-global one is repointed by another Sphere's init, so a second +wallet on another network would retarget it. + +### `tokenData(tokenId: string): Promise` + +The token's genesis payload — an NFT's actual content — or `null` when it carries none. + +A call rather than a field on the row: the payload is unbounded and blobs are lazy under server +custody, so a list read must never carry it. Fetches the blob and decodes it. Throws +`VALIDATION_ERROR` for a token this wallet does not hold. + +```typescript +const bytes = await sphere.payments.tokenData(nft.tokenId); +``` + +Note an **empty** payload reads back as a zero-length `Uint8Array`, not `null` — only a genuinely +absent one is `null`. + ### `mint(coinIdHex: string, amount: bigint): Promise` Self-mint fungible tokens to this wallet via the token engine (no faucet). **Journal-first**: diff --git a/docs/INTEGRATION.md b/docs/INTEGRATION.md index c1a9d0b2..0fc28ea2 100644 --- a/docs/INTEGRATION.md +++ b/docs/INTEGRATION.md @@ -512,6 +512,25 @@ for (const token of tokens) { const uctTokens = sphere.payments.tokens({ coinId: coinIdHex }); ``` +### Get Coinless Tokens (NFTs) + +A token whose genesis data carries no value envelope names no coin. These are a **separate, +disjoint read** — never returned by `tokens()`, and contributing to no balance: + +```typescript +const nfts = sphere.payments.coinless(); + +for (const nft of nfts) { + console.log(`Token ${nft.tokenId}`); + // The CLASS of token, not its identity — every token of one kind shares a type. + console.log(` Type: ${nft.tokenType ?? '(unrecorded)'}`); +} + +// The payload — an NFT's actual content. A call, not a field: it is unbounded +// and the blob is fetched on demand. +const bytes = await sphere.payments.tokenData(nfts[0].tokenId); +``` + Lazy tokens (blob not yet downloaded) carry value metadata only; the blob is fetched on demand when the token is selected for a spend. diff --git a/docs/MIGRATION-PAYMENTS-V2.md b/docs/MIGRATION-PAYMENTS-V2.md index 261a7f0a..b455d733 100644 --- a/docs/MIGRATION-PAYMENTS-V2.md +++ b/docs/MIGRATION-PAYMENTS-V2.md @@ -91,6 +91,31 @@ Error contract is UNCHANGED and load-bearing: the typed codes `ProofUnconfirmedError.cause` carrying the raw network error all survive verbatim. Keep your PENDING_COMMIT handling exactly as it is. +### 2a. New in [Unreleased]: coinless tokens (NFTs) + +Nothing to migrate — purely additive — but worth knowing so a token list is not read as complete: + +| need | call | +|---|---| +| coin tokens | `tokens(filter?)` — **unchanged**, and still excludes coinless holdings | +| coinless (NFT) holdings | `coinless(): CoinlessToken[]` | +| an NFT's payload | `tokenData(tokenId): Promise` | + +The two reads are **disjoint**: an active token appears in exactly one, so `tokens()`, `assets()` +and every balance are byte-identical to before. A UI that shows "all my tokens" now needs both. + +A coinless token is deliberately not a `Token` — that type requires `coinId`, `symbol`, `decimals` +and `amount`, and populating them with `''`/`'0'` would put untrue values in fields UIs sum and +format. `CoinlessToken` carries `tokenId` (the instance), `tokenType` (the **class** — every token +of one kind shares it), `stateHash`, `transferring`, `suspectedSpent` and timestamps. + +`transfer:incoming` gains an optional `coinless` array. If you render arrivals from `tokens`, a +coinless arrival will look empty — read `coinless` too. + +An NFT's display metadata (`name`, `iconUrl`) is resolved for you, from the registry the Sphere +owns. Do not look the type up via `TokenRegistry.getInstance()`: a second Sphere's init repoints +that singleton (#767). An unrecognised type is legitimate — the row still renders, unnamed. + ## 3. Composition changes - **wallet-api is required** for money. `FileTokenStorageProvider` / diff --git a/docs/PAYMENTS-V2-DESIGN.md b/docs/PAYMENTS-V2-DESIGN.md index 15d46bd6..00832c14 100644 --- a/docs/PAYMENTS-V2-DESIGN.md +++ b/docs/PAYMENTS-V2-DESIGN.md @@ -124,6 +124,8 @@ interface Payments { // reads — views over the wallet-api record assets(coinId?: string): Promise; // inventory-mirror aggregation + registry metadata + fiat tokens(filter?: { coinId?: string }): Token[]; // sync read of the inventory view + coinless(): CoinlessToken[]; // #777: holdings naming no coin — DISJOINT from tokens() + tokenData(tokenId: string): Promise; // genesis payload, fetched on demand history(page?: { before?: string; limit?: number }): Promise; // money movement @@ -263,8 +265,28 @@ spent", keep the tombstone. Empty-import protection: never push a removal before successful inventory read; a token is removed only against a confirmed on-chain spend. Serves `assets()` (aggregated from the mirror, with registry + price — the server `/v1/balances` endpoint has no client consumer and the StoragePort exposes no balances member), `tokens()` -(elements enriched from the registry; status set is `'confirmed' | 'transferring'`), and the -selector's metadata pool. **In-flight exclusion +(elements enriched from the registry; status set is `'confirmed' | 'transferring'`), `coinless()`, +and the selector's metadata pool. + +**Coinless tokens (#777, wallet-api#140/#141).** A token whose genesis data is not a value envelope +names no coin. `MirrorEntry.coinless` is computed ONCE at apply time, where `status` is in hand, +because absent `assets` means two different things: a tombstone omits them for an unrelated reason, +and a delta that omits them INHERITS the previous entry's (which `recoverRemoved` depends on). Only +an ACTIVE row's absence states coinlessness — the §16 rule is *discriminate on `status`, never on +the presence of assets*. + +`tokens()` and `coinless()` are DISJOINT: an active entry is in exactly one, so no coin consumer +changes and a coinless token joins no balance and no selector pool. A coinless token is never a +`Token` — that type requires `coinId`/`symbol`/`decimals`/`amount`, and sentinels would put untrue +values in fields consumers sum. (sphere-sdk#781 proposed widening `tokens()` instead; the divergence +and its cost are recorded on that issue.) + +Two invariants that span functions, so neither file states them alone: +- `applyOne`'s unchanged-row early return compares `status` **and** the resolved `tokenType`, while + `recoverOne` flips `status` in place without recomputing `coinless`. Correct only together: + without the status comparison a recovered coin tombstone surfaces in `tokens()` AND `coinless()`. +- The verdict is DERIVED from wallet-api's §8.2 step-6 boundary. Moving that boundary needs this + re-derived, not merely re-tested — wallet-api's §8.2 now records the coupling from its side. **In-flight exclusion (#517/#32, re-homed):** sources reserved by an open transfer — including keep-open intents whose spend may be on-chain — are excluded from the selector pool AND reported outside the spendable total (`transferring*` fields, never `totalAmount`) until their machine settles or resume adopts diff --git a/docs/QUICKSTART-BROWSER.md b/docs/QUICKSTART-BROWSER.md index ad80e341..799b378e 100644 --- a/docs/QUICKSTART-BROWSER.md +++ b/docs/QUICKSTART-BROWSER.md @@ -346,6 +346,9 @@ for (const asset of assets) { // Individual tokens (synchronous inventory view) const tokens = sphere.payments.tokens(); +// Coinless tokens (NFTs) are a SEPARATE, disjoint read — never in tokens() (#777) +const nfts = sphere.payments.coinless(); +const payload = nfts[0] ? await sphere.payments.tokenData(nfts[0].tokenId) : null; // Total portfolio value in USD (price fields are null without PriceProvider) const totalUsd = assets.reduce((sum, a) => sum + (a.fiatValueUsd ?? 0), 0); diff --git a/docs/QUICKSTART-NODEJS.md b/docs/QUICKSTART-NODEJS.md index 46bf5d9d..70c3d113 100644 --- a/docs/QUICKSTART-NODEJS.md +++ b/docs/QUICKSTART-NODEJS.md @@ -277,6 +277,9 @@ for (const asset of assets) { // Individual tokens (synchronous inventory view) const tokens = sphere.payments.tokens(); +// Coinless tokens (NFTs) are a SEPARATE, disjoint read — never in tokens() (#777) +const nfts = sphere.payments.coinless(); +const payload = nfts[0] ? await sphere.payments.tokenData(nfts[0].tokenId) : null; // Total portfolio value in USD const totalUsd = assets.reduce((sum, a) => sum + (a.fiatValueUsd ?? 0), 0); diff --git a/impl/wallet-api-v2/client.ts b/impl/wallet-api-v2/client.ts index 9df3db5b..cc33293c 100644 --- a/impl/wallet-api-v2/client.ts +++ b/impl/wallet-api-v2/client.ts @@ -25,7 +25,18 @@ export interface InventoryItemWire { status: 'active' | 'removed'; seq: number; stateHash: string; + /** + * Omitted for a tombstone AND for an ACTIVE COINLESS token (wallet-api#140), + * so absence is never "removed" or "not loaded" — discriminate on `status`. + */ assets?: AssetWire[]; + /** + * Genesis `TokenType`, lowercase hex (1-64 bytes ⇒ 2-128 chars). Names the + * token's CLASS, not the instance (wallet-api#147). Absent on rows written + * before wallet-api migration 0015. An unrecognised type is legitimate: never + * reject or hide a token for it. + */ + tokenType?: string; } export interface InventoryPageWire { diff --git a/modules/payments-v2/PaymentsFacade.ts b/modules/payments-v2/PaymentsFacade.ts index c1e1f469..7f904184 100644 --- a/modules/payments-v2/PaymentsFacade.ts +++ b/modules/payments-v2/PaymentsFacade.ts @@ -15,9 +15,11 @@ import type { ITokenEngine } from '../../token-engine/engine'; import type { SphereToken } from '../../token-engine/types'; import type { Asset, IncomingTransfer, Token, TokenTransferDetail, TransferResult } from '../../types'; -import type { ConnectionStatus, HistoryPage, MintResult, PaymentsV2, PendingTransfer, SendRequest } from './api'; +import type { CoinlessToken, ConnectionStatus, HistoryPage, MintResult, PaymentsV2, PendingTransfer, SendRequest } from './api'; import { SerialChain, SingleFlight } from './async'; import { ConvergenceHeartbeat, Converger, derivePendingTransfers } from './convergence'; +import { readTokenData } from './inventory/token-data'; +import { partialize, stampTransferId } from './send-errors'; import { requireSameNetworkRecipient } from './recipient'; import { reseedAndReset, type RestoreDeps } from './restore'; import { mintParams } from './mint-params'; @@ -223,6 +225,15 @@ export class PaymentsFacade implements PaymentsV2 { return this.view.tokens(this.deps.registry, filter); } + coinless(): CoinlessToken[] { + return this.view.coinless(this.deps.registry); + } + + tokenData(tokenId: string): Promise { + const deps = { engine: this.engine(), view: this.view, storagePort: this.deps.storagePort }; + return readTokenData(deps, tokenId); + } + history(page?: { before?: string; limit?: number }): Promise { return this.historyStore.page(page ?? {}); } @@ -340,12 +351,12 @@ export class PaymentsFacade implements PaymentsV2 { try { ctx = await this.planAndMaterialize(recipient.chainPubkey, { ...request, amount: run.amount }, run); } catch (err) { - throw this.partialize(err, run); + throw partialize(err, run); } const disposition = await this.runAttempt(ctx); - if (disposition.kind === 'rethrow') throw this.partialize(disposition.error, run); + if (disposition.kind === 'rethrow') throw partialize(disposition.error, run); if (disposition.kind === 'retry-full') { - if (attempt >= MAX_RESELECT) throw this.partialize(disposition.error, run); + if (attempt >= MAX_RESELECT) throw partialize(disposition.error, run); continue; } if (disposition.kind === 'success') { @@ -409,7 +420,7 @@ export class PaymentsFacade implements PaymentsV2 { private async disposeFailedAttempt(ctx: AttemptCtx, err: unknown): Promise { if (isPossiblyCommittedSendOutcome(err)) { this.settleKeepOpen(ctx); - this.stampTransferId(err, ctx.transferId); + stampTransferId(err, ctx.transferId); return { kind: 'rethrow', error: err }; } const backstop = await this.machineStores.backstop.getByKey(ctx.transferId); @@ -445,7 +456,7 @@ export class PaymentsFacade implements PaymentsV2 { private async disposeConvergeFailure(ctx: AttemptCtx, err: unknown): Promise { if (isPossiblyCommittedSendOutcome(err)) { this.settleKeepOpen(ctx); - this.stampTransferId(err, ctx.transferId); + stampTransferId(err, ctx.transferId); return { kind: 'rethrow', error: err }; } if (classifyError(err) === 'conflict') { @@ -617,24 +628,6 @@ export class PaymentsFacade implements PaymentsV2 { } /** Nothing delivered → UNWRAPPED; after ≥1 delivered leg every failure surfaces as PartialSendConflictError over the settled set. */ - private partialize(err: unknown, run: SendRun): unknown { - if (run.delivered.length === 0) return err; - return new PartialSendConflictError( - 'Part of your payment was sent; the remaining amount could not be completed (see cause). The delivered portion is final — re-plan only the shortfall, never the full amount.', - run.firstPartialId ?? '', - run.delivered, - run.amount, - err - ); - } - - /** #441: possibly-committed errors must carry the transferId for the settling journal. */ - private stampTransferId(err: unknown, transferId: string): void { - if (err instanceof SphereError && isPossiblyCommittedSendOutcome(err) && err.transferId === undefined) { - err.transferId = transferId; - } - } - private async softAbort(transferId: string): Promise { try { await this.deps.client.abortIntent(transferId); @@ -709,7 +702,10 @@ export class PaymentsFacade implements PaymentsV2 { spent: [], added: [{ tokenId: token.blob.tokenId, key }], }); - await this.historyStore.recordMint({ tokenId: token.blob.tokenId, coinId, amount }); + await this.historyStore.recordMint({ + tokenId: token.blob.tokenId, + assets: [{ coinId, amount }], + }); await this.machineStores.mintJournal.removeByKey(mintId); this.heldStates.set(token.blob.tokenId, (await engine.deliveryKeys(bytes)).stateHash); this.trackTail(this.view.delta()); diff --git a/modules/payments-v2/api.ts b/modules/payments-v2/api.ts index 9ca94b11..e4518396 100644 --- a/modules/payments-v2/api.ts +++ b/modules/payments-v2/api.ts @@ -1,6 +1,6 @@ // §4 of docs/PAYMENTS-V2-DESIGN.md -import type { Asset, IncomingTransfer, Token, TransferResult } from '../../types'; +import type { Asset, CoinlessToken, IncomingTransfer, Token, TransferResult } from '../../types'; export interface SendRequest { recipient: string; @@ -96,6 +96,8 @@ export interface PaymentsV2 { discardPrewarm(): void; assets(coinId?: string): Promise; tokens(filter?: { coinId?: string }): Token[]; + coinless(): CoinlessToken[]; + tokenData(tokenId: string): Promise; history(page?: { before?: string; limit?: number }): Promise; send(req: SendRequest): Promise; @@ -126,3 +128,5 @@ export interface PaymentsV2Events { 'payment_request:updated': { id: string; status: PaymentRequestStatus }; 'connection:status': { status: ConnectionStatus }; } + +export type { CoinlessToken }; diff --git a/modules/payments-v2/compose.ts b/modules/payments-v2/compose.ts index 9ea8e7f4..79718747 100644 --- a/modules/payments-v2/compose.ts +++ b/modules/payments-v2/compose.ts @@ -282,10 +282,9 @@ function buildMachineDeps( recordHistory: async ({ transferId, payload, committedAmount }) => { await historyStore.recordSent({ transferId, - coinId: payload.coinId, // §5.9: the SETTLED amount (machine-computed from the certified // recipient blobs), never payload.amount — the plan. - amount: committedAmount, + assets: [{ coinId: payload.coinId, amount: committedAmount }], recipientPubkey: payload.recipient, ...(payload.memo !== undefined ? { memo: payload.memo } : {}), }); @@ -328,12 +327,10 @@ function buildReceive( } async function recordReceived(historyStore: History, record: ReceivedRecord): Promise { - const first = record.assets[0]; await historyStore.recordReceived({ tokenId: record.tokenId, stateHash: record.stateHash, - coinId: first?.coinId ?? '', - amount: first?.amount ?? '0', + assets: record.assets, ...(record.senderPubkey !== undefined ? { senderPubkey: record.senderPubkey } : {}), ...(record.senderNametag !== undefined ? { senderNametag: record.senderNametag } : {}), ...(record.memo !== undefined ? { memo: record.memo } : {}), diff --git a/modules/payments-v2/history/History.ts b/modules/payments-v2/history/History.ts index 1ff727f1..b756df26 100644 --- a/modules/payments-v2/history/History.ts +++ b/modules/payments-v2/history/History.ts @@ -54,8 +54,8 @@ export interface HistoryDeps { export interface RecordSentInput { transferId: string; - coinId: string; - amount: string; + /** EMPTY when nothing with a coin moved (#780). Every type accepts an empty list. */ + assets: readonly { coinId: string; amount: string }[]; recipientPubkey?: string; recipientNametag?: string; memo?: string; @@ -66,8 +66,13 @@ export interface RecordSentInput { export interface RecordReceivedInput { tokenId: string; stateHash: string; - coinId: string; - amount: string; + /** + * What arrived. EMPTY for a coinless token (#777) — wallet-api#151 accepts an + * empty list and keeps refusing `coinId: ''`, so a synthetic empty-coin entry + * 422s and the row is lost. §10 forbids a record naming neither assets nor a + * tokenId; `tokenId` is always set here, so an empty list is legal. + */ + assets: readonly { coinId: string; amount: string }[]; transferId?: string; senderPubkey?: string; senderNametag?: string; @@ -77,11 +82,28 @@ export interface RecordReceivedInput { export interface RecordMintInput { tokenId: string; - coinId: string; - amount: string; + assets: readonly { coinId: string; amount: string }[]; timestamp?: number; } +/** Copied, never aliased: the wire record must not share the caller's array. */ +function wireAssets( + assets: readonly { coinId: string; amount: string }[] +): { coinId: string; amount: string }[] { + return assets.map((a) => ({ coinId: a.coinId, amount: a.amount })); +} + +/** + * A 4xx other than 408/429 is the server refusing this record's SHAPE — permanent, + * so no retry can fix it. Duck-typed on `status` so the module keeps no dependency + * on a transport implementation. + */ +function isPermanentReject(err: unknown): boolean { + const status = (err as { status?: unknown } | null)?.status; + if (typeof status !== 'number') return false; + return status >= 400 && status < 500 && status !== 408 && status !== 429; +} + const WIRE_TOKEN_ID = /^(?:[0-9a-f]{2}){1,64}$/; const WIRE_PUBKEY = /^0[23][0-9a-f]{64}$/; @@ -109,7 +131,7 @@ export class History { dedupKey: input.transferId, id: (this.deps.newId ?? randomUUID)(), type: 'SENT' as const, - assets: [{ coinId: input.coinId, amount: input.amount }], + assets: wireAssets(input.assets), ts: this.ts(input.timestamp), transferId: input.transferId, ...this.wireTokenId(input.tokenId), @@ -123,7 +145,7 @@ export class History { dedupKey: `RECEIVED:${input.tokenId.toLowerCase()}:${input.stateHash.toLowerCase()}`, id: (this.deps.newId ?? randomUUID)(), type: 'RECEIVED' as const, - assets: [{ coinId: input.coinId, amount: input.amount }], + assets: wireAssets(input.assets), ts: this.ts(input.timestamp), ...(input.transferId !== undefined ? { transferId: input.transferId } : {}), ...this.wireTokenId(input.tokenId), @@ -137,7 +159,7 @@ export class History { dedupKey: `MINT:${input.tokenId.toLowerCase()}`, id: (this.deps.newId ?? randomUUID)(), type: 'MINT' as const, - assets: [{ coinId: input.coinId, amount: input.amount }], + assets: wireAssets(input.assets), ts: this.ts(input.timestamp), ...this.wireTokenId(input.tokenId), })); @@ -150,7 +172,15 @@ export class History { try { await this.deps.client.postHistory([record]); } catch (err) { - this.deps.log?.('history POST failed (dedupKey makes retry safe)', err); + // §5.9 keeps this off the money path, but a 4xx is PERMANENT: no retry can + // fix a shape the server refuses, and logging it as retry-safe is what let a + // coinless receipt vanish silently (#780). Name the two apart. + this.deps.log?.( + isPermanentReject(err) + ? 'history POST REJECTED — the record shape is refused; no retry can fix it' + : 'history POST failed (dedupKey makes retry safe)', + err + ); return; } // The recorded entry is in hand — emit it through the SAME read-through diff --git a/modules/payments-v2/inventory/InventoryView.ts b/modules/payments-v2/inventory/InventoryView.ts index 49aaa6ac..914079d2 100644 --- a/modules/payments-v2/inventory/InventoryView.ts +++ b/modules/payments-v2/inventory/InventoryView.ts @@ -5,6 +5,7 @@ import type { Asset, Token } from '../../../types'; import { SerialChain, SingleFlight } from '../async'; +import type { CoinlessToken } from '../api'; import type { InventoryAsset, InventoryItem, InventoryPage, StoragePort } from '../ports'; import { STORE_KEYS, type ScopedKV, type StreamCursor } from '../stores'; import { @@ -54,6 +55,14 @@ interface MirrorEntry { seq: number; status: 'active' | 'removed'; assets: readonly InventoryAsset[]; + /** + * Positive statement that this ACTIVE row names no coin (wallet-api#140). + * Computed here, where `status` is in hand, because absent `assets` means two + * different things: a tombstone omits them for an unrelated reason, and + * `assets` is INHERITED from the previous entry on a delta that omits them. + */ + coinless: boolean; + tokenType?: string; createdAt: number; updatedAt: number; } @@ -66,6 +75,31 @@ function stateKey(tokenId: string, stateHash: string): string { return `${tokenId}:${stateHash}`; } +/** + * Only an ACTIVE row's absent `assets` positively states coinlessness. A tombstone + * omits them for an unrelated reason, so it inherits rather than being reclassified + * (wallet-api sdk-changes S2: discriminate on `status`, never on assets-absent). + */ +/** + * A type is a function of tokenId so it never CHANGES, but it can appear late + * (wallet-api writes it on insert and reactivate; migration 0015 does not + * backfill). The RESOLVED value is compared: comparing `item.tokenType` would + * rewrite the entry on every delta that merely omits it. + */ +function unchanged(prev: MirrorEntry, item: InventoryItem, tokenType: string | undefined): boolean { + return ( + prev.seq === item.seq && + prev.status === item.status && + prev.stateHash === item.stateHash && + tokenType === prev.tokenType + ); +} + +function isCoinless(item: InventoryItem, prev: MirrorEntry | undefined): boolean { + if (item.status !== 'active') return prev?.coinless ?? false; + return (item.assets?.length ?? 0) === 0; +} + export class InventoryView { private readonly mirror = new Map(); private readonly suspected = new Set(); @@ -210,6 +244,32 @@ export class InventoryView { return out; } + /** + * Coinless holdings (#777) — DISJOINT from tokens(): an entry is coinless + * exactly when it is not, so no mirror row can appear in both reads. + */ + coinless(registry: RegistryReader): CoinlessToken[] { + const out: CoinlessToken[] = []; + for (const [tokenId, entry] of this.mirror) { + if (entry.status !== 'active' || !entry.coinless) continue; + const meta = entry.tokenType !== undefined ? registry.getTypeMeta?.(entry.tokenType) : null; + out.push({ + tokenId, + ...(entry.tokenType !== undefined ? { tokenType: entry.tokenType } : {}), + ...(meta ? { name: meta.name } : {}), + ...(meta?.iconUrl != null ? { iconUrl: meta.iconUrl } : {}), + stateHash: entry.stateHash, + transferring: this.held(tokenId), + ...(this.suspected.has(stateKey(tokenId, entry.stateHash)) + ? { suspectedSpent: true } + : {}), + createdAt: entry.createdAt, + updatedAt: entry.updatedAt, + }); + } + return out; + } + async assets(registry: RegistryReader, price?: PriceReader): Promise { const raw = aggregateAssets(this.activeEntries(), (tokenId) => this.held(tokenId), registry); if (!price || raw.length === 0) return raw; @@ -296,19 +356,17 @@ export class InventoryView { private applyOne(item: InventoryItem, now: number): boolean { const prev = this.mirror.get(item.tokenId); if (prev && item.seq < prev.seq) return false; - if ( - prev && - prev.seq === item.seq && - prev.status === item.status && - prev.stateHash === item.stateHash - ) { - return false; - } + const tokenType = item.tokenType ?? prev?.tokenType; + if (prev && unchanged(prev, item, tokenType)) return false; this.mirror.set(item.tokenId, { stateHash: item.stateHash, seq: item.seq, status: item.status, + // Left INHERITING deliberately: a tombstone omits assets, and recoverRemoved + // must still know the amount it is restoring (inventory.test.ts). assets: item.assets ?? prev?.assets ?? [], + coinless: isCoinless(item, prev), + ...(tokenType !== undefined ? { tokenType } : {}), createdAt: prev?.createdAt ?? now, updatedAt: now, }); diff --git a/modules/payments-v2/inventory/presentation.ts b/modules/payments-v2/inventory/presentation.ts index 8db35f8d..28f7619f 100644 --- a/modules/payments-v2/inventory/presentation.ts +++ b/modules/payments-v2/inventory/presentation.ts @@ -10,6 +10,8 @@ export interface RegistryReader { getName(coinId: string): string; getDecimals(coinId: string): number; getIconUrl(coinId: string): string | null; + /** Coinless token CLASS metadata, keyed by token type — a separate namespace. */ + getTypeMeta?(tokenType: string): { name: string; iconUrl: string | null } | null; } export interface PriceQuote { diff --git a/modules/payments-v2/inventory/token-data.ts b/modules/payments-v2/inventory/token-data.ts new file mode 100644 index 00000000..5202dd5e --- /dev/null +++ b/modules/payments-v2/inventory/token-data.ts @@ -0,0 +1,26 @@ +import { SphereError } from '../../../core/errors'; +import type { ITokenEngine } from '../../../token-engine/engine'; +import type { StoragePort } from '../ports'; + +import type { InventoryView } from './InventoryView'; + +export interface TokenDataDeps { + readonly engine: ITokenEngine; + readonly view: Pick; + readonly storagePort: Pick; +} + +/** A token's genesis payload, or null. Fetched on demand. */ +export async function readTokenData( + deps: TokenDataDeps, + tokenId: string +): Promise { + if (deps.view.stateHashOf(tokenId) === undefined) { + throw new SphereError(`Token ${tokenId} is not in inventory`, 'VALIDATION_ERROR'); + } + const bytes = (await deps.storagePort.getBlobs([tokenId])).get(tokenId); + if (bytes === undefined) { + throw new SphereError(`Token ${tokenId} has no blob in storage`, 'STORAGE_ERROR'); + } + return deps.engine.readTokenData(await deps.engine.decodeToken({ tokenId, token: bytes })); +} diff --git a/modules/payments-v2/ports.ts b/modules/payments-v2/ports.ts index 84fa0d02..0c70b099 100644 --- a/modules/payments-v2/ports.ts +++ b/modules/payments-v2/ports.ts @@ -10,7 +10,9 @@ export interface InventoryItem { status: 'active' | 'removed'; seq: number; stateHash: string; + /** Absent for a tombstone AND for an active COINLESS token — read `status`, not this. */ assets?: InventoryAsset[]; + tokenType?: string; } export interface InventoryPage { diff --git a/modules/payments-v2/receive/Receive.ts b/modules/payments-v2/receive/Receive.ts index 00381320..a67dfc28 100644 --- a/modules/payments-v2/receive/Receive.ts +++ b/modules/payments-v2/receive/Receive.ts @@ -25,6 +25,8 @@ export interface StoredIncoming { readonly tokenId: string; readonly stateHash: string; readonly assets: readonly IncomingAssetAmount[]; + /** Genesis type of an arrival that names no coin (#777) — its only display handle. */ + readonly tokenType?: string; } // Per-key seam over the inventory view (adapted by the facade in P9). @@ -38,6 +40,7 @@ export interface ReceivedRecord { readonly tokenId: string; readonly stateHash: string; readonly assets: readonly IncomingAssetAmount[]; + readonly tokenType?: string; readonly senderPubkey?: string; readonly senderNametag?: string; readonly memo?: string; @@ -265,7 +268,12 @@ async function screen(deps: ReceiveDeps, engine: ReceiveEngine, entry: IncomingD } return { kind: 'accept', - record: { tokenId: keys.tokenId, stateHash: keys.stateHash, assets: toAssetAmounts(token) }, + record: { + tokenId: keys.tokenId, + stateHash: keys.stateHash, + assets: toAssetAmounts(token), + ...(isCoinlessEnvelope(token.valueEnvelope) ? { tokenType: token.tokenType } : {}), + }, }; } @@ -281,6 +289,7 @@ async function announce( tokenId: record.tokenId, stateHash: record.stateHash, assets: record.assets, + ...(record.tokenType !== undefined ? { tokenType: record.tokenType } : {}), ...(entry.senderPubkey !== undefined ? { senderPubkey: entry.senderPubkey } : {}), ...(entry.senderNametag !== undefined ? { senderNametag: entry.senderNametag } : {}), ...(entry.memo !== undefined ? { memo: entry.memo } : {}), @@ -295,6 +304,21 @@ async function announce( senderPubkey: entry.senderPubkey ?? '', ...(entry.senderNametag !== undefined ? { senderNametag: entry.senderNametag } : {}), tokens: record.assets.map((asset) => toUiToken(record.tokenId, asset, deps.registry, receivedAt)), + // #777: named here rather than mapped from assets, which announced an EMPTY list. + ...(record.tokenType !== undefined + ? { + coinless: [ + { + tokenId: record.tokenId, + tokenType: record.tokenType, + stateHash: record.stateHash, + transferring: false, + createdAt: receivedAt, + updatedAt: receivedAt, + }, + ], + } + : {}), ...(entry.memo !== undefined ? { memo: entry.memo } : {}), receivedAt, }; @@ -421,6 +445,11 @@ function rejectAck(entry: IncomingDelivery, reason: 'invalid' | 'not-owned'): Pe return { deliveryId: entry.deliveryId, disposition: 'rejected', reason, cursor: entry.cursor }; } +/** Only `none_*` names no coin: `bare_collection` hides coins this SDK cannot read. */ +function isCoinlessEnvelope(envelope: SphereToken['valueEnvelope']): boolean { + return envelope.startsWith('none_'); +} + function toAssetAmounts(token: SphereToken): IncomingAssetAmount[] { return (token.value?.assets ?? []).map((asset) => ({ coinId: asset.coinId, diff --git a/modules/payments-v2/send-errors.ts b/modules/payments-v2/send-errors.ts new file mode 100644 index 00000000..e8e50c9f --- /dev/null +++ b/modules/payments-v2/send-errors.ts @@ -0,0 +1,31 @@ +import { isPossiblyCommittedSendOutcome, PartialSendConflictError, SphereError } from '../../core/errors'; + +/** The parts of a send run these shapers read. */ +export interface PartialRun { + readonly amount: string; + readonly delivered: readonly string[]; + readonly firstPartialId: string | undefined; +} + +/** Once anything was delivered, the failure is a SHORTFALL: re-plan the remainder, never the total. */ +export function partialize(err: unknown, run: PartialRun): unknown { + if (run.delivered.length === 0) return err; + return new PartialSendConflictError( + 'Part of your payment was sent; the remaining amount could not be completed (see cause). The delivered portion is final — re-plan only the shortfall, never the full amount.', + run.firstPartialId ?? '', + [...run.delivered], + run.amount, + err + ); +} + +/** #441: possibly-committed errors must carry the transferId for the settling journal. */ +export function stampTransferId(err: unknown, transferId: string): void { + if ( + err instanceof SphereError && + isPossiblyCommittedSendOutcome(err) && + err.transferId === undefined + ) { + err.transferId = transferId; + } +} diff --git a/registry/TokenRegistry.ts b/registry/TokenRegistry.ts index 5ad5ef17..2d033cf1 100644 --- a/registry/TokenRegistry.ts +++ b/registry/TokenRegistry.ts @@ -104,6 +104,8 @@ export class TokenRegistry { private static instance: TokenRegistry | null = null; private readonly definitionsById: Map; + /** Non-fungible definitions keyed by TOKEN TYPE — a separate namespace (#147). */ + private readonly definitionsByType: Map; private readonly definitionsBySymbol: Map; private readonly definitionsByName: Map; @@ -124,6 +126,7 @@ export class TokenRegistry { private constructor() { this.definitionsById = new Map(); + this.definitionsByType = new Map(); this.definitionsBySymbol = new Map(); this.definitionsByName = new Map(); } @@ -413,10 +416,16 @@ export class TokenRegistry { this.definitionsById.clear(); this.definitionsBySymbol.clear(); this.definitionsByName.clear(); + this.definitionsByType.clear(); + // ONE registry file, TWO id namespaces (wallet-api#147): a `fungible` entry's + // `id` is a COIN id, a `non-fungible` entry's is a TOKEN TYPE. The flat maps + // stay as they were (getDefinition resolves either, and is pinned that way); + // `definitionsByType` is the namespace-correct lookup a coinless token needs. for (const def of definitions) { const idLower = def.id.toLowerCase(); this.definitionsById.set(idLower, def); + if (def.assetKind === 'non-fungible') this.definitionsByType.set(idLower, def); if (def.symbol) { this.definitionsBySymbol.set(def.symbol.toUpperCase(), def); @@ -678,6 +687,22 @@ export class TokenRegistry { return Array.from(this.definitionsById.values()); } + /** + * Definition for a coinless token's genesis TOKEN TYPE (#777). Never falls back + * to `getDefinition`: a type and a coin id live in different namespaces, so a + * type that collided with a coin id would otherwise render as that coin. + */ + getTypeMeta(tokenType: string): { name: string; iconUrl: string | null } | null { + const def = this.getTypeDefinition(tokenType); + if (!def) return null; + return { name: def.name, iconUrl: def.icons?.[0]?.url ?? null }; + } + + getTypeDefinition(tokenType: string): TokenDefinition | undefined { + if (!tokenType) return undefined; + return this.definitionsByType.get(tokenType.toLowerCase()); + } + /** * Get all fungible token definitions * @returns Array of fungible token definitions diff --git a/tests/e2e/coinless-tokens.staging.e2e.test.ts b/tests/e2e/coinless-tokens.staging.e2e.test.ts new file mode 100644 index 00000000..811776e5 --- /dev/null +++ b/tests/e2e/coinless-tokens.staging.e2e.test.ts @@ -0,0 +1,170 @@ +/** + * Coinless tokens against LIVE staging — the assertions fakes cannot make. + * + * Every other coinless test in this repo runs against `FakeTokenEngine` or a stub + * that round-trips whatever it was handed, so wrong CBOR sails through them + * (CLAUDE.md: facade tests swap a fake engine via `setEngine` and would pass with + * the CBOR wrong). This leg mints a REAL testnet2-certified coinless token, indexes + * it through the deployed backend, and reads it back through the presigned GET. + * + * Requires `STAGING_AGGREGATOR_KEY`; dormant otherwise, and excluded from CI by + * `vitest.config.ts` (`exclude: ['tests/e2e/**']`) — run with `npm run test:e2e`. + */ + +import { sha256 } from '@noble/hashes/sha2.js'; +import { afterAll, describe, expect, it } from 'vitest'; + +import { bytesToHex, hexToBytes } from '../../core/crypto'; +import { randomUUID } from '../../core/uuid'; +import { WalletApiStoragePort } from '../../impl/wallet-api-v2/storage'; +import { CborSerializer } from '../../token-engine/sdk'; +import type { SphereToken } from '../../token-engine/types'; + +import { RUN_STAGING } from './support/staging'; +import { + logStep, + makeVerticalWallet, + shutdownVerticalWallets, + type VWallet, +} from './support/vertical'; + +/** + * The registry's canonical testnet2 non-fungible TYPE id (`assetKind: + * "non-fungible"` in unicity-ids.testnet2.json). Minting without one takes + * `TokenType.generate()`'s 32 random bytes, which is a valid token but not what a + * real NFT carries — and this leg exists to exercise the real shape. + */ +const TESTNET2_NFT_TYPE = '971a26eef0e3aeb22bd3e7d44c47ce963400037e8df42b50d4d44e1589f83826'; + +/** Non-null on purpose: byte-identity needs something to compare. */ +const NFT_PAYLOAD = CborSerializer.encodeTextString('kitty #1'); + +const HARNESS_COIN = 'a'.repeat(64); + +describe.skipIf(!RUN_STAGING)('coinless tokens — live staging', () => { + afterAll(async () => { + await shutdownVerticalWallets(); + }); + + /** Mint a real coinless token and index it through the deployed backend. */ + async function mintAndIndexCoinless(w: VWallet, data: Uint8Array): Promise { + const token = await w.engine.mintDataToken({ + recipientPubkey: hexToBytes(w.identity.chainPubkey), + data, + tokenType: hexToBytes(TESTNET2_NFT_TYPE), + }); + logStep(`minted coinless token ${token.blob.tokenId.slice(0, 12)}…`); + + // The stored blob is the WHOLE Token CBOR (§5.2 content-addressed), never the + // genesis payload — which is exactly why reading it back has to decode. + const bytes = token.blob.token; + const shaHex = bytesToHex(sha256(bytes)); + const storage = new WalletApiStoragePort(w.api); + const key = (await storage.uploadBlobs([{ sha256: shaHex, bytes }])).get(shaHex); + if (key === undefined) throw new Error('upload returned no key for the coinless blob'); + + await storage.applyDelta({ + transferId: randomUUID(), + spent: [], + added: [{ tokenId: token.blob.tokenId, key }], + }); + logStep(`indexed coinless token ${token.blob.tokenId.slice(0, 12)}…`); + return token; + } + + /** A fresh instance on the same identity/kv: its `start()` does a full pull. */ + async function reopen(w: VWallet): Promise { + await w.facade.stop().catch(() => undefined); + return makeVerticalWallet(w.tag, { identity: w.identity, kv: w.kv }); + } + + it( + 'a REAL coinless token surfaces in coinless() and never in tokens(), and its payload round-trips byte-identically', + async () => { + let w = await makeVerticalWallet('coinless-read'); + const token = await mintAndIndexCoinless(w, NFT_PAYLOAD); + w = await reopen(w); + + // 1. Present in the disjoint read, carrying the registry type. + const rows = w.facade.coinless(); + const row = rows.find((r) => r.tokenId === token.blob.tokenId); + expect(row).toBeDefined(); + expect(row?.tokenType).toBe(TESTNET2_NFT_TYPE); + + // 2. Absent from tokens(), on a token that made a real round trip through + // S3 and §8.2 rather than a fixture the mirror was handed. + expect(w.facade.tokens().map((t) => t.id)).not.toContain(token.blob.tokenId); + + // 3. Contributes to no balance. + expect(await w.facade.assets()).toEqual([]); + + // 4. THE assertion a fake cannot make: the payload survives CBOR encode → + // SHA-256 content addressing → S3 → presigned GET → CBOR decode. A fake + // engine returns whatever it was handed, so only this catches a + // `tokenData` that returned the blob instead of the genesis payload. + expect(await w.facade.tokenData(token.blob.tokenId)).toEqual(NFT_PAYLOAD); + }, + 600_000 + ); + + it( + 'an EMPTY genesis payload is coinless, and stays distinct from an absent one', + async () => { + let w = await makeVerticalWallet('coinless-null'); + const token = await w.engine.mintDataToken({ + recipientPubkey: hexToBytes(w.identity.chainPubkey), + data: new Uint8Array(0), + tokenType: hexToBytes(TESTNET2_NFT_TYPE), + }); + const bytes = token.blob.token; + const shaHex = bytesToHex(sha256(bytes)); + const storage = new WalletApiStoragePort(w.api); + const key = (await storage.uploadBlobs([{ sha256: shaHex, bytes }])).get(shaHex); + if (key === undefined) throw new Error('upload returned no key'); + await storage.applyDelta({ + transferId: randomUUID(), + spent: [], + added: [{ tokenId: token.blob.tokenId, key }], + }); + + w = await reopen(w); + + expect(w.facade.coinless().map((r) => r.tokenId)).toContain(token.blob.tokenId); + // Classified `none_absent` — coinless, not a failure. But it reads back as an + // EMPTY payload rather than null: a typed array is truthy, so zero bytes + // survive the round trip as data. (`mintDataToken` types `data` as required, + // so a genuinely absent payload is not expressible through this API at all.) + expect(await w.facade.tokenData(token.blob.tokenId)).toEqual(new Uint8Array(0)); + }, + 600_000 + ); + + it( + 'a valued token and a coinless one coexist: each in exactly one read, balance unaffected', + async () => { + let w = await makeVerticalWallet('coinless-mixed'); + const nft = await mintAndIndexCoinless(w, NFT_PAYLOAD); + + const mint = await w.facade.mint(HARNESS_COIN, 500n); + if (!mint.success) throw new Error(`valued mint failed: ${mint.error ?? 'unknown'}`); + const coinTokenId = mint.tokenId; + logStep(`minted valued token ${(coinTokenId ?? '').slice(0, 12)}…`); + + w = await reopen(w); + + const coinlessIds = w.facade.coinless().map((r) => r.tokenId); + const tokenIds = w.facade.tokens().map((t) => t.id); + + expect(coinlessIds).toContain(nft.blob.tokenId); + expect(coinlessIds).not.toContain(coinTokenId); + expect(tokenIds).toContain(coinTokenId); + expect(tokenIds).not.toContain(nft.blob.tokenId); + + // The coinless row changes no balance: the valued mint is the whole total. + const assets = await w.facade.assets(); + expect(assets).toHaveLength(1); + expect(assets[0]?.totalAmount).toBe('500'); + }, + 900_000 + ); +}); diff --git a/tests/mutation/probes.json b/tests/mutation/probes.json index 6e28da6c..b5c54f79 100644 --- a/tests/mutation/probes.json +++ b/tests/mutation/probes.json @@ -1214,5 +1214,217 @@ "tests": [ "tests/unit/impl/backing-store-id.test.ts" ] + }, + { + "name": "value-envelope-head-null-falls-through-to-coinless", + "note": "#778: an unreadable/non-canonical tag head must THROW, never bank as coinless. This is the exact naive fallback that silently zeroes a valid envelope with a trailing byte (value-envelope.test.ts reject table).", + "file": "token-engine/value-envelope.ts", + "find": " const tag = headTagNumber(genesisData);\n if (tag === null) {\n throw invalid(", + "replace": " const tag = headTagNumber(genesisData);\n if (false as boolean) {\n throw invalid(", + "tests": [ + "tests/unit/token-engine/value-envelope.test.ts" + ] + }, + { + "name": "value-envelope-decode-failure-reads-as-coinless", + "note": "#778 THE headline bug: a tag-39050 body that fails to decode must throw, not return a null value. Kills the `catch { return false }` shape the old predicate had.", + "file": "token-engine/value-envelope.ts", + "find": " throw invalid(`Failed to decode token payment data: ${describe(error)}`);", + "replace": " return { assets: [] } as SphereValue;", + "tests": [ + "tests/unit/token-engine/value-envelope.test.ts" + ] + }, + { + "name": "value-envelope-55799-over-value-accepted", + "note": "#778/wallet-api parity: tag(55799) hiding a real value envelope must be refused, or its coins are invisible with no error.", + "file": "token-engine/value-envelope.ts", + "find": " if (tag === SELF_DESCRIBED_CBOR_TAG && selfDescribedBodyCarriesValue(body)) {", + "replace": " if (false as boolean) {", + "tests": [ + "tests/unit/token-engine/value-envelope.test.ts" + ] + }, + { + "name": "value-envelope-array-some-becomes-every", + "note": "#778: `[uint(1), ]` is some=true/every=false \u2014 under .every it classifies coinless and a readable asset vanishes.", + "file": "token-engine/value-envelope.ts", + "find": " return items.some(isAssetShaped)\n ? { envelope: 'bare_collection', value: null }", + "replace": " return items.every(isAssetShaped)\n ? { envelope: 'bare_collection', value: null }", + "tests": [ + "tests/unit/token-engine/value-envelope.test.ts" + ] + }, + { + "name": "value-envelope-malformed-array-banked-as-coinless", + "note": "#778: an array that does not frame could be a CORRUPT collection; banking it as coinless is silent coin loss.", + "file": "token-engine/value-envelope.ts", + "find": " if (items === null) {\n throw invalid('Token value payload is not PaymentAssetCollection: malformed CBOR array');", + "replace": " if (items === null) {\n return { envelope: 'none_other', value: null };", + "tests": [ + "tests/unit/token-engine/value-envelope.test.ts" + ] + }, + { + "name": "engine-split-unreadable-value-guard-removed", + "note": "#778: splitting a source whose value this SDK cannot read must fail with a TYPED error before any chain op, not die inside TokenSplit with a bare CborError.", + "file": "token-engine/SphereTokenEngine.ts", + "find": " if (params.token.value === null) {\n throw new SphereError(", + "replace": " if (false as boolean) {\n throw new SphereError(", + "tests": [ + "tests/unit/token-engine/SphereTokenEngine.hardening.test.ts" + ] + }, + { + "name": "engine-mintdatatoken-preflight-removed", + "note": "#778: un-classifiable opaque bytes must be refused BEFORE the chain op. wrapToken runs on mintDataToken's last line, after certification, so without the pre-flight the refusal strands an on-chain token this SDK can never decode.", + "file": "token-engine/SphereTokenEngine.ts", + "find": " assertMintableData(params.data);", + "replace": " // mutant: pre-flight dropped", + "tests": [ + "tests/unit/token-engine/SphereTokenEngine.hardening.test.ts" + ] + }, + { + "name": "inventory-coinless-ignores-status", + "note": "#777: only an ACTIVE row's absent assets states coinlessness. A tombstone omits assets for an unrelated reason, so classifying it coinless would surface spent tokens as NFTs.", + "file": "modules/payments-v2/inventory/InventoryView.ts", + "find": " if (item.status !== 'active') return prev?.coinless ?? false;", + "replace": " // mutant: status ignored", + "tests": [ + "tests/unit/payments-v2/inventory.test.ts" + ] + }, + { + "name": "inventory-coinless-always-false", + "note": "#777: a coinless row must reach coinless(); always-false is the pre-fix behaviour where an NFT was invisible.", + "file": "modules/payments-v2/inventory/InventoryView.ts", + "find": " return (item.assets?.length ?? 0) === 0;", + "replace": " return false;", + "tests": [ + "tests/unit/payments-v2/inventory.test.ts" + ] + }, + { + "name": "inventory-coinless-not-disjoint", + "note": "#777: coinless() must skip non-coinless rows, or a coin token appears in BOTH reads.", + "file": "modules/payments-v2/inventory/InventoryView.ts", + "find": " if (entry.status !== 'active' || !entry.coinless) continue;", + "replace": " if (entry.status !== 'active') continue;", + "tests": [ + "tests/unit/payments-v2/inventory.test.ts" + ] + }, + { + "name": "receive-coinless-not-named", + "note": "#777: a coinless arrival must be NAMED on transfer:incoming. Dropping the tokenType makes the event announce an empty token list again \u2014 a UI sees nothing land.", + "file": "modules/payments-v2/receive/Receive.ts", + "find": " ...(isCoinlessEnvelope(token.valueEnvelope) ? { tokenType: token.tokenType } : {}),", + "replace": " // mutant: arrival type dropped", + "tests": [ + "tests/unit/payments-v2/receive.test.ts" + ] + }, + { + "name": "history-received-flattens-to-empty-coin", + "note": "wallet-api#151: a coinless RECEIVED record must post assets: []. Flattening to a synthetic first asset posts coinId:'' which the backend 422s and History.post swallows \u2014 the row is lost silently. Kept RECEIVED-specific alongside history-wire-assets-forces-one-entry, which guards the shared helper.", + "file": "modules/payments-v2/history/History.ts", + "find": " type: 'RECEIVED' as const,\n assets: wireAssets(input.assets),", + "replace": " type: 'RECEIVED' as const,\n assets: [{ coinId: input.assets[0]?.coinId ?? '', amount: input.assets[0]?.amount ?? '0' }],", + "tests": [ + "tests/unit/payments-v2/receive.test.ts", + "tests/unit/payments-v2/history.test.ts" + ] + }, + { + "name": "registry-type-lookup-falls-back-to-coins", + "note": "wallet-api#147: a token TYPE and a coin id are different namespaces in one registry file. Falling back to the coin map would render a coin as a token class.", + "file": "registry/TokenRegistry.ts", + "find": " return this.definitionsByType.get(tokenType.toLowerCase());", + "replace": " return this.definitionsByType.get(tokenType.toLowerCase()) ?? this.definitionsById.get(tokenType.toLowerCase());", + "tests": [ + "tests/unit/registry/TokenRegistry.test.ts" + ] + }, + { + "name": "inventory-unchanged-ignores-late-tokentype", + "note": "#777: a tokenType can appear late at an otherwise identical row (insert/reactivate write it; 0015 does not backfill). Dropping it from the unchanged check strands the type; comparing item.tokenType instead of the RESOLVED value churns an update on every delta that omits it.", + "file": "modules/payments-v2/inventory/InventoryView.ts", + "find": " tokenType === prev.tokenType", + "replace": " true", + "tests": [ + "tests/unit/payments-v2/inventory.test.ts" + ] + }, + { + "name": "receive-conflates-unreadable-with-coinless", + "note": "#778/#777: `value === null` is true for a coinless token AND for a bare_collection carrying coins this SDK cannot decode. Keying the NFT announcement on it hides real coins behind a token type.", + "file": "modules/payments-v2/receive/Receive.ts", + "find": " return envelope.startsWith('none_');", + "replace": " return envelope !== 'sphere';", + "tests": [ + "tests/unit/payments-v2/receive.test.ts" + ] + }, + { + "name": "token-data-skips-inventory-guard", + "note": "#777: tokenData must refuse a token the wallet does not hold BEFORE fetching a blob, rather than returning bytes for an arbitrary id.", + "file": "modules/payments-v2/inventory/token-data.ts", + "find": " if (deps.view.stateHashOf(tokenId) === undefined) {", + "replace": " if (false as boolean) {", + "tests": [ + "tests/unit/payments-v2/token-data.test.ts" + ] + }, + { + "name": "history-permanent-reject-logged-as-retry-safe", + "note": "#780: a 4xx is a permanent shape refusal that no retry can fix. Logging it as retry-safe is exactly what let a coinless receipt vanish silently.", + "file": "modules/payments-v2/history/History.ts", + "find": " return status >= 400 && status < 500 && status !== 408 && status !== 429;", + "replace": " return false;", + "tests": [ + "tests/unit/payments-v2/history.test.ts" + ] + }, + { + "name": "history-treats-429-as-permanent", + "note": "#780: 408/429 are transient. Calling them permanent would tell an operator a retryable failure is unfixable.", + "file": "modules/payments-v2/history/History.ts", + "find": " return status >= 400 && status < 500 && status !== 408 && status !== 429;", + "replace": " return status >= 400 && status < 500;", + "tests": [ + "tests/unit/payments-v2/history.test.ts" + ] + }, + { + "name": "history-wire-assets-forces-one-entry", + "note": "#780: every history type must be able to post an EMPTY asset list. Forcing a one-element array reintroduces the coinId:'' 422 on SENT and MINT.", + "file": "modules/payments-v2/history/History.ts", + "find": " return assets.map((a) => ({ coinId: a.coinId, amount: a.amount }));", + "replace": " return assets.length === 0 ? [{ coinId: '', amount: '0' }] : assets.map((a) => ({ coinId: a.coinId, amount: a.amount }));", + "tests": [ + "tests/unit/payments-v2/history.test.ts", + "tests/unit/payments-v2/receive.test.ts" + ] + }, + { + "name": "fake-swallows-corrupt-envelope", + "note": "#778 review: the fake is FakeWalletApi's \u00a78.2 step-6 stand-in. Catching a classification throw makes it index a corrupt envelope as coinless \u2014 every payments-v2 test then models the pre-fix silent zero.", + "file": "tests/unit/token-engine/FakeTokenEngine.ts", + "find": " if (!state.genesisData || classify(state).envelope !== 'sphere') return null;", + "replace": " try { if (!state.genesisData || classify(state).envelope !== 'sphere') return null; } catch { return null; }", + "tests": [ + "tests/unit/token-engine/FakeTokenEngine.test.ts" + ] + }, + { + "name": "coinless-row-drops-class-metadata", + "note": "#777 review: class metadata must resolve from the registry the WALLET owns. Dropping it forces callers to the process-global singleton, which another Sphere's init repoints (#767).", + "file": "modules/payments-v2/inventory/InventoryView.ts", + "find": " const meta = entry.tokenType !== undefined ? registry.getTypeMeta?.(entry.tokenType) : null;", + "replace": " const meta = null as { name: string; iconUrl: string | null } | null;", + "tests": [ + "tests/unit/payments-v2/inventory.test.ts" + ] } ] diff --git a/tests/unit/payments-v2/history.test.ts b/tests/unit/payments-v2/history.test.ts index d46b0e2e..91d6c8be 100644 --- a/tests/unit/payments-v2/history.test.ts +++ b/tests/unit/payments-v2/history.test.ts @@ -34,6 +34,8 @@ interface Harness { log: ReturnType; postResults: { inserted: number; deduped: number }[]; setFailPosts: (fail: boolean) => void; + /** Fail POSTs with a specific error (e.g. a 422 carrying `status`). */ + setPostError: (err: unknown) => void; serverRecords: () => Promise; } @@ -42,6 +44,7 @@ function makeHarness(): Harness { const caller: FakeCaller = { chainPubkey: PUB, network: 'testnet' }; const postResults: { inserted: number; deduped: number }[] = []; let failPosts = false; + let postError: unknown = null; // Thin client-shaped adapter over the fake (structural HistoryClient). const client: HistoryClient = { listHistory: async (options) => { @@ -53,6 +56,7 @@ function makeHarness(): Harness { }; }, postHistory: async (records) => { + if (postError !== null) throw postError; if (failPosts) throw new Error('wallet-api 503'); const result = await fake.appendHistory(caller, records); postResults.push(result); @@ -72,6 +76,9 @@ function makeHarness(): Harness { setFailPosts: (fail) => { failPosts = fail; }, + setPostError: (err) => { + postError = err; + }, serverRecords: async () => (await fake.listHistory(caller)).records, }; } @@ -177,7 +184,7 @@ describe('History §5.9 — read-through mapping', () => { it('amounts above 2^64 survive the round trip as exact decimal strings', async () => { const big = (BigInt(2) ** BigInt(128)).toString(); const h = makeHarness(); - await h.history.recordSent({ transferId: 'f0000000-0000-4000-8000-000000000001', coinId: COIN, amount: big }); + await h.history.recordSent({ transferId: 'f0000000-0000-4000-8000-000000000001', assets: [{ coinId: COIN, amount: big }] }); const [entry] = (await h.history.page()).entries; expect(entry.amount).toBe(big); expect(BigInt(entry.amount)).toBe(BigInt(2) ** BigInt(128)); @@ -186,7 +193,7 @@ describe('History §5.9 — read-through mapping', () => { it('passes before/limit through and pages by the server keyset cursor', async () => { const h = makeHarness(); for (let i = 0; i < 5; i++) { - await h.history.recordMint({ tokenId: `${i}${i}`.repeat(32), coinId: COIN, amount: '1', timestamp: NOW + i * 1000 }); + await h.history.recordMint({ tokenId: `${i}${i}`.repeat(32), assets: [{ coinId: COIN, amount: '1' }], timestamp: NOW + i * 1000 }); } const first = await h.history.page({ limit: 2 }); expect(first.entries).toHaveLength(2); @@ -203,8 +210,8 @@ describe('History §5.9 — client POSTs and dedup keys', () => { it('SENT dedup key is the transferId — a resumed re-POST is a server no-op (one record)', async () => { const h = makeHarness(); const transferId = 'a1111111-1111-4111-8111-111111111111'; - await h.history.recordSent({ transferId, coinId: COIN, amount: '100' }); - await h.history.recordSent({ transferId, coinId: COIN, amount: '100' }); + await h.history.recordSent({ transferId, assets: [{ coinId: COIN, amount: '100' }] }); + await h.history.recordSent({ transferId, assets: [{ coinId: COIN, amount: '100' }] }); expect(h.postResults).toEqual([ { inserted: 1, deduped: 0 }, { inserted: 0, deduped: 1 }, @@ -216,23 +223,23 @@ describe('History §5.9 — client POSTs and dedup keys', () => { it('RECEIVED dedup key is per (tokenId, stateHash): an A→B→A round-trip yields two records', async () => { const h = makeHarness(); - await h.history.recordReceived({ tokenId: TOKEN, stateHash: STATE_A, coinId: COIN, amount: '10' }); - await h.history.recordReceived({ tokenId: TOKEN, stateHash: STATE_B, coinId: COIN, amount: '10' }); + await h.history.recordReceived({ tokenId: TOKEN, stateHash: STATE_A, assets: [{ coinId: COIN, amount: '10' }] }); + await h.history.recordReceived({ tokenId: TOKEN, stateHash: STATE_B, assets: [{ coinId: COIN, amount: '10' }] }); const records = await h.serverRecords(); expect(records).toHaveLength(2); expect(new Set(records.map((r) => r.dedupKey))).toEqual( new Set([`RECEIVED:${TOKEN}:${STATE_A}`, `RECEIVED:${TOKEN}:${STATE_B}`]) ); // Redelivery of the same leg stays one record. - await h.history.recordReceived({ tokenId: TOKEN, stateHash: STATE_A, coinId: COIN, amount: '10' }); + await h.history.recordReceived({ tokenId: TOKEN, stateHash: STATE_A, assets: [{ coinId: COIN, amount: '10' }] }); expect(await h.serverRecords()).toHaveLength(2); }); it('MINT dedup key is MINT:tokenId — a replayed mint yields one record, lowercased on the wire', async () => { const h = makeHarness(); const upper = 'CC'.repeat(32); - await h.history.recordMint({ tokenId: upper, coinId: COIN, amount: '7' }); - await h.history.recordMint({ tokenId: upper, coinId: COIN, amount: '7' }); + await h.history.recordMint({ tokenId: upper, assets: [{ coinId: COIN, amount: '7' }] }); + await h.history.recordMint({ tokenId: upper, assets: [{ coinId: COIN, amount: '7' }] }); const records = await h.serverRecords(); expect(records).toHaveLength(1); expect(records[0].dedupKey).toBe(`MINT:${TOKEN}`); @@ -243,21 +250,21 @@ describe('History §5.9 — client POSTs and dedup keys', () => { const h = makeHarness(); h.setFailPosts(true); await expect( - h.history.recordSent({ transferId: 'b2222222-2222-4222-8222-222222222222', coinId: COIN, amount: '1' }) + h.history.recordSent({ transferId: 'b2222222-2222-4222-8222-222222222222', assets: [{ coinId: COIN, amount: '1' }] }) ).resolves.toBeUndefined(); await expect( - h.history.recordReceived({ tokenId: TOKEN, stateHash: STATE_A, coinId: COIN, amount: '1' }) + h.history.recordReceived({ tokenId: TOKEN, stateHash: STATE_A, assets: [{ coinId: COIN, amount: '1' }] }) ).resolves.toBeUndefined(); - await expect(h.history.recordMint({ tokenId: TOKEN, coinId: COIN, amount: '1' })).resolves.toBeUndefined(); + await expect(h.history.recordMint({ tokenId: TOKEN, assets: [{ coinId: COIN, amount: '1' }] })).resolves.toBeUndefined(); expect(h.log).toHaveBeenCalledTimes(3); expect(h.emit).not.toHaveBeenCalled(); }); it('emits history:updated after each successful POST, carrying the recorded client-shaped entry', async () => { const h = makeHarness(); - await h.history.recordSent({ transferId: 'c3333333-3333-4333-8333-333333333333', coinId: COIN, amount: '1' }); - await h.history.recordReceived({ tokenId: TOKEN, stateHash: STATE_A, coinId: COIN, amount: '1' }); - await h.history.recordMint({ tokenId: TOKEN, coinId: COIN, amount: '1' }); + await h.history.recordSent({ transferId: 'c3333333-3333-4333-8333-333333333333', assets: [{ coinId: COIN, amount: '1' }] }); + await h.history.recordReceived({ tokenId: TOKEN, stateHash: STATE_A, assets: [{ coinId: COIN, amount: '1' }] }); + await h.history.recordMint({ tokenId: TOKEN, assets: [{ coinId: COIN, amount: '1' }] }); expect(h.emit).toHaveBeenCalledTimes(3); expect(h.emit.mock.calls.map((c) => [c[0], (c[1] as { type: string }).type])).toEqual([ ['history:updated', 'SENT'], @@ -270,8 +277,7 @@ describe('History §5.9 — client POSTs and dedup keys', () => { const h = makeHarness(); await h.history.recordSent({ transferId: 'f6666666-6666-4666-8666-666666666666', - coinId: COIN, - amount: '450', + assets: [{ coinId: COIN, amount: '450' }], memo: 'lunch', recipientPubkey: PEER, recipientNametag: 'bob', @@ -298,8 +304,7 @@ describe('History §5.9 — client POSTs and dedup keys', () => { const h = makeHarness(); await h.history.recordSent({ transferId: 'd4444444-4444-4444-8444-444444444444', - coinId: COIN, - amount: '9', + assets: [{ coinId: COIN, amount: '9' }], memo: 'order #42', recipientPubkey: PEER, recipientNametag: 'bob', @@ -319,8 +324,7 @@ describe('History §5.9 — client POSTs and dedup keys', () => { const h = makeHarness(); await h.history.recordSent({ transferId: 'e5555555-5555-4555-8555-555555555555', - coinId: COIN, - amount: '2', + assets: [{ coinId: COIN, amount: '2' }], recipientPubkey: '@bob', recipientNametag: 'bob', }); @@ -329,3 +333,103 @@ describe('History §5.9 — client POSTs and dedup keys', () => { expect(String(raw.counterpartyNametag).startsWith('enc1.')).toBe(true); }); }); + +describe('History — a coinless RECEIVED record (#777 / wallet-api#151)', () => { + it('posts assets: [] verbatim, never a synthetic empty-coin entry', async () => { + const h = makeHarness(); + await h.history.recordReceived({ tokenId: TOKEN, stateHash: STATE_A, assets: [] }); + const [record] = await h.serverRecords(); + + // wallet-api#151 accepts an empty list and STILL refuses `coinId: ''`, so a + // flattened `[{coinId:'', amount:'0'}]` 422s — and History.post swallows the + // failure, so the row would vanish with no error surface anywhere. + expect(record?.assets).toEqual([]); + expect(record?.tokenId).toBe(TOKEN); + }); + + it('still carries a tokenId, which §10 requires of a record naming no assets', async () => { + const h = makeHarness(); + await h.history.recordReceived({ tokenId: TOKEN, stateHash: STATE_A, assets: [] }); + const [record] = await h.serverRecords(); + expect(record?.tokenId).toBeDefined(); + }); + + it('a multi-asset receipt posts every asset, not just the first', async () => { + const h = makeHarness(); + const OTHER_COIN = 'bb'.repeat(32); + await h.history.recordReceived({ + tokenId: TOKEN, + stateHash: STATE_A, + assets: [{ coinId: COIN, amount: '10' }, { coinId: OTHER_COIN, amount: '20' }], + }); + const [record] = await h.serverRecords(); + expect(record?.assets).toEqual([ + { coinId: COIN, amount: '10' }, + { coinId: OTHER_COIN, amount: '20' }, + ]); + }); +}); + +describe('History — every type accepts an empty asset list (#780)', () => { + it('SENT posts assets: [] for a coinless send', async () => { + const h = makeHarness(); + await h.history.recordSent({ transferId: 'd4444444-4444-4444-8444-444444444444', assets: [] }); + const [record] = await h.serverRecords(); + expect(record?.assets).toEqual([]); + expect(record?.type).toBe('SENT'); + }); + + it('MINT posts assets: [] for a coinless mint', async () => { + const h = makeHarness(); + await h.history.recordMint({ tokenId: TOKEN, assets: [] }); + const [record] = await h.serverRecords(); + expect(record?.assets).toEqual([]); + expect(record?.type).toBe('MINT'); + }); + + it('never emits coinId: "" — the one spelling wallet-api deliberately still refuses', async () => { + const h = makeHarness(); + await h.history.recordSent({ transferId: 'd5555555-5555-4555-8555-555555555555', assets: [] }); + await h.history.recordMint({ tokenId: TOKEN, assets: [] }); + await h.history.recordReceived({ tokenId: TOKEN, stateHash: STATE_A, assets: [] }); + for (const record of await h.serverRecords()) { + expect(record.assets.map((a) => a.coinId)).not.toContain(''); + } + }); +}); + +describe('History — a permanent reject is distinguishable from a transient failure (#780)', () => { + const reject422 = Object.assign(new Error('unprocessable'), { status: 422 }); + + it('names a 422 as refused rather than retry-safe: no retry can fix a shape the server rejects', async () => { + const h = makeHarness(); + h.setPostError(reject422); + await h.history.recordReceived({ tokenId: TOKEN, stateHash: STATE_A, assets: [] }); + expect(h.log).toHaveBeenCalledWith(expect.stringMatching(/REJECTED/), reject422); + }); + + it('still calls a 503 retry-safe', async () => { + const h = makeHarness(); + h.setFailPosts(true); + await h.history.recordReceived({ tokenId: TOKEN, stateHash: STATE_A, assets: [] }); + expect(h.log).toHaveBeenCalledWith(expect.stringMatching(/retry safe/), expect.anything()); + }); + + it('treats 429 and 408 as transient, not as a shape refusal', async () => { + for (const status of [408, 429]) { + const h = makeHarness(); + h.setPostError(Object.assign(new Error('slow down'), { status })); + await h.history.recordReceived({ tokenId: TOKEN, stateHash: STATE_A, assets: [] }); + expect(h.log).toHaveBeenCalledWith(expect.stringMatching(/retry safe/), expect.anything()); + } + }); + + it('neither kind throws into the money path, and neither emits history:updated', async () => { + const h = makeHarness(); + h.setPostError(reject422); + await expect( + h.history.recordReceived({ tokenId: TOKEN, stateHash: STATE_A, assets: [] }) + ).resolves.toBeUndefined(); + expect(h.emit).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/unit/payments-v2/inventory.test.ts b/tests/unit/payments-v2/inventory.test.ts index cdfd79c4..756f5d71 100644 --- a/tests/unit/payments-v2/inventory.test.ts +++ b/tests/unit/payments-v2/inventory.test.ts @@ -18,6 +18,7 @@ interface ItemOpts { status?: 'active' | 'removed'; amount?: string; noAssets?: boolean; + tokenType?: string; } function item(tokenId: string, opts: ItemOpts): InventoryItem { @@ -27,6 +28,7 @@ function item(tokenId: string, opts: ItemOpts): InventoryItem { stateHash: opts.state ?? 'S1', status: opts.status ?? 'active', ...(opts.noAssets ? {} : { assets: [{ coinId: COIN, amount: opts.amount ?? '100' }] }), + ...(opts.tokenType !== undefined ? { tokenType: opts.tokenType } : {}), }; } @@ -65,6 +67,11 @@ const registry: RegistryReader = { getName: (coinId) => (coinId === COIN ? 'Unicity' : 'Unknown'), getDecimals: () => 6, getIconUrl: (coinId) => (coinId === COIN ? 'https://icons/uct.png' : null), + // The TOKEN-TYPE namespace, distinct from coin ids (wallet-api#147). + getTypeMeta: (tokenType) => + tokenType === '971a26eef0e3aeb2' + ? { name: 'Unicity NFT', iconUrl: 'https://icons/nft.png' } + : null, }; function makeView( @@ -480,3 +487,181 @@ describe('releaseMany — one event for one logical change (#755)', () => { expect(events.filter((e) => e === 'inventory:updated').length).toBe(before); }); }); + +describe('InventoryView — coinless tokens (#777)', () => { + const NFT_TYPE = '971a26eef0e3aeb2'; + + it('surfaces an active coinless row in coinless(), with its token type', async () => { + const { view } = makeView([ + page([item('N', { seq: 1, noAssets: true, tokenType: NFT_TYPE })], 5), + page([], 5), + ]); + await view.fullPull(); + expect(view.coinless(registry)).toEqual([ + { + tokenId: 'N', + tokenType: NFT_TYPE, + name: 'Unicity NFT', + iconUrl: 'https://icons/nft.png', + stateHash: 'S1', + transferring: false, + createdAt: expect.any(Number), + updatedAt: expect.any(Number), + }, + ]); + }); + + it('keeps the two reads DISJOINT: a coinless row is never a Token, a coin row never coinless', async () => { + const { view } = makeView([ + page([item('N', { seq: 1, noAssets: true, tokenType: NFT_TYPE }), item('A', { seq: 1 })], 5), + page([], 5), + ]); + await view.fullPull(); + expect(view.tokens(registry).map((t) => t.id)).toEqual(['A']); + expect(view.coinless(registry).map((t) => t.tokenId)).toEqual(['N']); + }); + + it('contributes nothing to assets() or pool() — an NFT is not a balance and not a spend source', async () => { + const { view } = makeView([ + page([item('N', { seq: 1, noAssets: true, tokenType: NFT_TYPE })], 5), + page([], 5), + ]); + await view.fullPull(); + expect(await view.assets(registry)).toEqual([]); + expect(view.pool(COIN)).toEqual([]); + }); + + it('renders a coinless token whose type the server never recorded (pre-0015 rows)', async () => { + const { view } = makeView([page([item('N', { seq: 1, noAssets: true })], 5), page([], 5)]); + await view.fullPull(); + const [row] = view.coinless(registry); + expect(row?.tokenId).toBe('N'); + expect(row?.tokenType).toBeUndefined(); + }); + + it('a TOMBSTONE is not coinless: it omits assets for an unrelated reason', async () => { + const { view } = makeView([ + page([item('T', { seq: 1, status: 'removed', noAssets: true })], 5), + page([], 5), + ]); + await view.fullPull(); + expect(view.coinless(registry)).toEqual([]); + }); + + it('carries a tokenType forward when a later delta omits it', async () => { + const { view, queue } = makeView([ + page([item('N', { seq: 1, noAssets: true, tokenType: NFT_TYPE })], 5), + page([], 5), + ]); + await view.fullPull(); + queue.push(page([item('N', { seq: 2, state: 'S2', noAssets: true })], 6)); + await view.delta(); + expect(view.coinless(registry)[0]?.tokenType).toBe(NFT_TYPE); + }); + + it('adopts a tokenType that appears LATE at an otherwise unchanged row', async () => { + const { view, queue } = makeView([page([item('N', { seq: 1, noAssets: true })], 5), page([], 5)]); + await view.fullPull(); + expect(view.coinless(registry)[0]?.tokenType).toBeUndefined(); + + // Same seq, status and stateHash — only the type is newly supplied. The + // unchanged-row early return must not discard it. + queue.push(page([item('N', { seq: 1, noAssets: true, tokenType: NFT_TYPE })], 6)); + await view.delta(); + expect(view.coinless(registry)[0]?.tokenType).toBe(NFT_TYPE); + }); + + it('a delta that merely OMITS a known tokenType is still a no-op (no update churn)', async () => { + const { view, queue, events } = makeView([ + page([item('N', { seq: 1, noAssets: true, tokenType: NFT_TYPE })], 5), + page([], 5), + ]); + await view.fullPull(); + const before = events.length; + + queue.push(page([item('N', { seq: 1, noAssets: true })], 6)); + await view.delta(); + + expect(events.length).toBe(before); + expect(view.coinless(registry)[0]?.tokenType).toBe(NFT_TYPE); + }); + + it('a RECOVERED coin tombstone comes back as a coin, never as an NFT', async () => { + // recoverOne flips status in place without recomputing `coinless`, so the + // status guard in isCoinless is what keeps this right: a tombstone omits + // assets, and classifying THAT as coinless would resurrect a 100-coin token + // into coinless() while its inherited assets still put it in tokens() — + // present in both reads at once, shown as an NFT. + const { view, queue } = makeView([page([item('C', { seq: 1, amount: '100' })], 5), page([], 5)]); + await view.fullPull(); + queue.push(page([item('C', { seq: 2, status: 'removed', noAssets: true })], 6)); + await view.delta(); + expect(view.tokens(registry)).toEqual([]); + + const recovered = await view.recoverRemoved( + async () => new Map([['C', blob(1)]]), + async () => false, + async (): Promise => 'added' + ); + + expect(recovered.recovered).toEqual(['C']); + expect(view.coinless(registry)).toEqual([]); + expect(view.tokens(registry).map((t) => t.id)).toEqual(['C']); + expect(view.pool(COIN)).toEqual([{ tokenId: 'C', amount: 100n }]); + }); + + it('survives a §5.4 restore re-pull: a coinless row stays coinless, a coin row stays a coin', async () => { + // The restore protocol drops every cursor and does a FULL re-pull, which + // re-applies rows the mirror already holds. `coinless` is computed at apply + // time, so an unchanged-row early return must not leave a stale verdict. + const { view, queue } = makeView([ + page([item('N', { seq: 1, noAssets: true, tokenType: NFT_TYPE }), item('A', { seq: 1 })], 5), + page([], 5), + ]); + await view.fullPull(); + + queue.push( + page([item('N', { seq: 1, noAssets: true, tokenType: NFT_TYPE }), item('A', { seq: 1 })], 5), + page([], 5) + ); + await view.fullPull(); + + expect(view.coinless(registry).map((t) => t.tokenId)).toEqual(['N']); + expect(view.tokens(registry).map((t) => t.id)).toEqual(['A']); + }); + + it('resolves class metadata from the registry the WALLET owns, not a global singleton', async () => { + const { view } = makeView([ + page([item('N', { seq: 1, noAssets: true, tokenType: NFT_TYPE })], 5), + page([], 5), + ]); + await view.fullPull(); + const [row] = view.coinless(registry); + expect(row?.name).toBe('Unicity NFT'); + expect(row?.iconUrl).toBe('https://icons/nft.png'); + }); + + it('renders an UNRECOGNISED type without a name rather than hiding the token', async () => { + // A minter may use its own type; length is the only structural constraint. + const { view } = makeView([ + page([item('N', { seq: 1, noAssets: true, tokenType: 'ff'.repeat(8) })], 5), + page([], 5), + ]); + await view.fullPull(); + const [row] = view.coinless(registry); + expect(row?.tokenId).toBe('N'); + expect(row?.name).toBeUndefined(); + }); + + it('a row that GAINS assets stops being coinless', async () => { + const { view, queue } = makeView([ + page([item('N', { seq: 1, noAssets: true, tokenType: NFT_TYPE })], 5), + page([], 5), + ]); + await view.fullPull(); + queue.push(page([item('N', { seq: 2, state: 'S2', amount: '5' })], 6)); + await view.delta(); + expect(view.coinless(registry)).toEqual([]); + expect(view.tokens(registry).map((t) => t.id)).toEqual(['N']); + }); +}); diff --git a/tests/unit/payments-v2/receive.test.ts b/tests/unit/payments-v2/receive.test.ts index 1afd81b4..b8d3d50e 100644 --- a/tests/unit/payments-v2/receive.test.ts +++ b/tests/unit/payments-v2/receive.test.ts @@ -32,13 +32,33 @@ interface StubMeta { stateHash: string; owner: string; assets: { coinId: string; amount: string }[]; + tokenType?: string; + /** A value envelope this SDK cannot decode: null value, but REAL coins. */ + bridged?: boolean; } -const meta = (tokenId: string, stateHash: string, opts: { owner?: string; amount?: string } = {}): StubMeta => ({ +// `coinless` models wallet-api#140: genesis data that is not a value envelope, so +// the engine reports value === null and no assets at all. +const meta = ( + tokenId: string, + stateHash: string, + opts: { + owner?: string; + amount?: string; + coinless?: boolean; + tokenType?: string; + bridged?: boolean; + } = {} +): StubMeta => ({ tokenId, stateHash, owner: opts.owner ?? OWN, - assets: [{ coinId: COIN, amount: opts.amount ?? '1000' }], + assets: + opts.coinless === true || opts.bridged === true + ? [] + : [{ coinId: COIN, amount: opts.amount ?? '1000' }], + ...(opts.tokenType !== undefined ? { tokenType: opts.tokenType } : {}), + ...(opts.bridged === true ? { bridged: true } : {}), }); const blobOf = (m: StubMeta): Uint8Array => new TextEncoder().encode(JSON.stringify(m)); @@ -61,7 +81,17 @@ class StubEngine implements ReceiveEngine { return { sdkToken: parsed as never, blob, - value: { assets: parsed.assets.map((a) => ({ coinId: a.coinId, amount: BigInt(a.amount) })) }, + value: + parsed.assets.length === 0 + ? null + : { assets: parsed.assets.map((a) => ({ coinId: a.coinId, amount: BigInt(a.amount) })) }, + valueEnvelope: + parsed.bridged === true + ? 'bare_collection' + : parsed.assets.length === 0 + ? 'none_other' + : 'sphere', + tokenType: parsed.tokenType ?? 'aa'.repeat(4), }; } @@ -1085,3 +1115,97 @@ describe('payments-v2 Receive — every drain refreshes, not just the explicit o expect(h.refreshes).toEqual([]); }); }); + +describe('payments-v2 Receive — coinless arrivals (#777)', () => { + const NFT_TYPE = '971a26eef0e3aeb2'; + + it('NAMES the arriving coinless token instead of announcing an empty token list', async () => { + const h = makeHarness(); + h.delivery.add(meta(T(1), 'S1', { coinless: true, tokenType: NFT_TYPE })); + + const [transfer] = await h.receive.drainOnce(); + + // The bug: `tokens` is built by mapping over assets, so a coinless arrival + // announced `tokens: []` and a UI listening for arrivals saw nothing land. + expect(transfer?.tokens).toEqual([]); + expect(transfer?.coinless).toEqual([ + { + tokenId: T(1), + tokenType: NFT_TYPE, + stateHash: 'S1', + transferring: false, + createdAt: transfer?.receivedAt, + updatedAt: transfer?.receivedAt, + }, + ]); + }); + + it('stores, claims and verifies a coinless arrival exactly like a coin arrival', async () => { + const h = makeHarness(); + h.delivery.add(meta(T(1), 'S1', { coinless: true, tokenType: NFT_TYPE })); + + await h.receive.drainOnce(); + + expect(h.view.storeCalls).toEqual([ + expect.objectContaining({ tokenId: T(1), stateHash: 'S1', assets: [] }), + ]); + expect(h.delivery.ackLog).toEqual([expect.objectContaining({ disposition: 'claimed' })]); + }); + + it('posts history with an EMPTY asset list, never a synthetic empty-coin entry', async () => { + const h = makeHarness(); + h.delivery.add(meta(T(1), 'S1', { coinless: true, tokenType: NFT_TYPE })); + + await h.receive.drainOnce(); + + // wallet-api#151 accepts `assets: []` and still refuses `coinId: ''`, so a + // synthetic entry would 422 and History.post would swallow the row silently. + expect(h.historyLog).toEqual([ + expect.objectContaining({ tokenId: T(1), assets: [], tokenType: NFT_TYPE }), + ]); + }); + + it('a coin arrival is unchanged — no coinless field, assets intact', async () => { + const h = makeHarness(); + h.delivery.add(meta(T(1), 'S1')); + + const [transfer] = await h.receive.drainOnce(); + + expect(transfer?.coinless).toBeUndefined(); + expect(transfer?.tokens).toHaveLength(1); + expect(h.historyLog[0]?.assets).toEqual([{ coinId: COIN, amount: '1000' }]); + }); + + it('a coinless arrival whose type the minter never set still lands', async () => { + const h = makeHarness(); + h.delivery.add(meta(T(1), 'S1', { coinless: true })); + + const [transfer] = await h.receive.drainOnce(); + + expect(transfer?.coinless).toHaveLength(1); + expect(h.view.storeCalls).toHaveLength(1); + }); +}); + +describe('payments-v2 Receive — an unreadable value envelope is NOT an NFT (#778/#777)', () => { + it('never announces a bare_collection arrival as coinless: it carries coins this SDK cannot read', async () => { + const h = makeHarness(); + h.delivery.add(meta(T(1), 'S1', { bridged: true, tokenType: '971a26eef0e3aeb2' })); + + const [transfer] = await h.receive.drainOnce(); + + // `value === null` is true for BOTH a coinless token and a dialect this SDK + // cannot decode. Keying on it would hide real coins behind a token type. + expect(transfer?.coinless).toBeUndefined(); + }); + + it('still accepts and claims the bridged arrival — unreadable value is not a rejection', async () => { + const h = makeHarness(); + h.delivery.add(meta(T(1), 'S1', { bridged: true })); + + await h.receive.drainOnce(); + + expect(h.view.storeCalls).toHaveLength(1); + expect(h.delivery.ackLog).toEqual([expect.objectContaining({ disposition: 'claimed' })]); + }); +}); diff --git a/tests/unit/payments-v2/token-data.test.ts b/tests/unit/payments-v2/token-data.test.ts new file mode 100644 index 00000000..a875dfc5 --- /dev/null +++ b/tests/unit/payments-v2/token-data.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { SphereError } from '../../../core/errors'; +import { readTokenData } from '../../../modules/payments-v2/inventory/token-data'; +import type { ITokenEngine } from '../../../token-engine/engine'; +import type { SphereToken, TokenBlob } from '../../../token-engine/types'; + +const TOKEN = 'aa'.repeat(32); +const PAYLOAD = new TextEncoder().encode('kitty #1'); + +function engineWith(data: Uint8Array | null): ITokenEngine { + return { + decodeToken: vi.fn(async (blob: TokenBlob) => ({ blob }) as unknown as SphereToken), + readTokenData: vi.fn(() => data), + } as unknown as ITokenEngine; +} + +function deps(opts: { + stateHash?: string | undefined; + blobs?: Map; + data?: Uint8Array | null; + onFetch?: () => void; +}) { + return { + engine: engineWith(opts.data === undefined ? PAYLOAD : opts.data), + view: { stateHashOf: () => opts.stateHash }, + storagePort: { + getBlobs: vi.fn(async () => { + opts.onFetch?.(); + return opts.blobs ?? new Map([[TOKEN, new Uint8Array([1])]]); + }), + }, + }; +} + +describe('readTokenData', () => { + it('returns the genesis payload of a held token', async () => { + await expect(readTokenData(deps({ stateHash: 'S1' }), TOKEN)).resolves.toEqual(PAYLOAD); + }); + + it('returns null for a token that carries no payload', async () => { + await expect(readTokenData(deps({ stateHash: 'S1', data: null }), TOKEN)).resolves.toBeNull(); + }); + + it('refuses a token the wallet does not hold, without fetching a blob', async () => { + const onFetch = vi.fn(); + const d = deps({ stateHash: undefined, onFetch }); + await expect(readTokenData(d, TOKEN)).rejects.toBeInstanceOf(SphereError); + expect(onFetch).not.toHaveBeenCalled(); + }); + + it('refuses when the blob is missing rather than reporting an empty payload', async () => { + const d = deps({ stateHash: 'S1', blobs: new Map() }); + await expect(readTokenData(d, TOKEN)).rejects.toThrow(/no blob in storage/); + }); + + it('needs no post-fetch re-check: genesis is byte-identical in every blob of the chain, so a state advance in flight cannot change the payload', async () => { + // The presence check guards "do we hold this token", never freshness. The token + // advances state (and is even tombstoned) while getBlobs is in flight; the + // payload is fixed at mint, so the answer is still correct and returning it is + // not a stale read. + let stateHash: string | undefined = 'S1'; + const d = { + engine: engineWith(PAYLOAD), + view: { stateHashOf: () => stateHash }, + storagePort: { + getBlobs: vi.fn(async () => { + stateHash = undefined; // spent / tombstoned mid-flight + return new Map([[TOKEN, new Uint8Array([1])]]); + }), + }, + }; + await expect(readTokenData(d, TOKEN)).resolves.toEqual(PAYLOAD); + }); +}); diff --git a/tests/unit/registry/TokenRegistry.test.ts b/tests/unit/registry/TokenRegistry.test.ts index ad32c98b..252a266c 100644 --- a/tests/unit/registry/TokenRegistry.test.ts +++ b/tests/unit/registry/TokenRegistry.test.ts @@ -1373,3 +1373,38 @@ describe('Remote Refresh', () => { }); }); }); + +describe('getTypeDefinition — the token-type namespace (#777 / wallet-api#147)', () => { + it('resolves a coinless token type that getDefinition would also find', async () => { + await configureWithCache(); + const registry = TokenRegistry.getInstance(); + const byType = registry.getTypeDefinition(UNICITY_NFT_COIN_ID); + expect(byType?.assetKind).toBe('non-fungible'); + expect(byType?.id).toBe(UNICITY_NFT_COIN_ID); + }); + + it('refuses a FUNGIBLE coin id: a coin is not a token type', async () => { + await configureWithCache(); + const registry = TokenRegistry.getInstance(); + const coin = registry.getFungibleTokens()[0]; + expect(coin).toBeDefined(); + // getDefinition would happily return it — the two namespaces share one flat + // map. getTypeDefinition is the lookup that keeps a coin from rendering as a + // token class, which matters because the ids are minter-chosen and could collide. + expect(registry.getDefinition(coin!.id)?.id).toBe(coin!.id); + expect(registry.getTypeDefinition(coin!.id)).toBeUndefined(); + }); + + it('is case-insensitive and rejects an empty type', async () => { + await configureWithCache(); + const registry = TokenRegistry.getInstance(); + expect(registry.getTypeDefinition(UNICITY_NFT_COIN_ID.toUpperCase())?.id).toBe(UNICITY_NFT_COIN_ID); + expect(registry.getTypeDefinition('')).toBeUndefined(); + }); + + it('returns undefined for an unrecognised type rather than throwing — a minter may use its own', async () => { + await configureWithCache(); + const registry = TokenRegistry.getInstance(); + expect(registry.getTypeDefinition('ff'.repeat(32))).toBeUndefined(); + }); +}); diff --git a/tests/unit/support/mock-token-engine.ts b/tests/unit/support/mock-token-engine.ts index c9cb9c9b..e9fd383f 100644 --- a/tests/unit/support/mock-token-engine.ts +++ b/tests/unit/support/mock-token-engine.ts @@ -6,7 +6,13 @@ import type { /** Build a SphereToken stand-in for interaction tests (sdkToken/blob are inert). */ export function mockSphereToken(value: SphereValue | null = { assets: [] }): SphereToken { const blob: TokenBlob = { tokenId: '00'.repeat(32), token: new Uint8Array() }; - return { sdkToken: {} as SphereToken['sdkToken'], blob, value }; + return { + sdkToken: {} as SphereToken['sdkToken'], + blob, + value, + valueEnvelope: value === null ? 'none_absent' : 'sphere', + tokenType: 'aa'.repeat(4), + }; } const hex = (b: Uint8Array) => Array.from(b, (x) => x.toString(16).padStart(2, '0')).join(''); diff --git a/tests/unit/token-engine/FakeTokenEngine.test.ts b/tests/unit/token-engine/FakeTokenEngine.test.ts index e088c536..cc00d713 100644 --- a/tests/unit/token-engine/FakeTokenEngine.test.ts +++ b/tests/unit/token-engine/FakeTokenEngine.test.ts @@ -1,7 +1,9 @@ import { describe, expect, it } from 'vitest'; import { runEngineContract } from './engine-contract'; -import { FakeTokenEngine } from './FakeTokenEngine'; +import { CborSerializer } from '../../../token-engine/sdk'; +import { SpherePaymentData } from '../../../token-engine/SpherePaymentData'; +import { decodeFakeTokenAssets, FakeTokenEngine } from './FakeTokenEngine'; // The fake must satisfy the shared port contract. runEngineContract('FakeTokenEngine', () => new FakeTokenEngine({ chainPubkey: new Uint8Array(33).fill(0x02) })); @@ -34,3 +36,47 @@ describe('FakeTokenEngine specifics', () => { expect(e.balanceOf(t, COIN)).toBe(0n); }); }); + + +describe('decodeFakeTokenAssets — the fake must not soften the classifier (#778)', () => { + /** The fake blob shape `decodeFakeState` reads: [tokenId, stateId, owner, genesis, memo]. */ + const fakeState = (genesis: Uint8Array): Uint8Array => + CborSerializer.encodeArray( + CborSerializer.encodeByteString(new Uint8Array(32).fill(1)), + CborSerializer.encodeByteString(new Uint8Array(32).fill(2)), + CborSerializer.encodeByteString(new Uint8Array(33).fill(2)), + CborSerializer.encodeByteString(genesis), + CborSerializer.encodeNull() + ); + + it('PROPAGATES a corrupt-envelope throw instead of indexing it as coinless', async () => { + // The fake is FakeWalletApi's §8.2 step-6 stand-in. The real backend 422s a + // corrupt envelope, so returning null would model the pre-#778 silent zero and + // every payments-v2 test would keep asserting against a fixed bug. + const valid = await SpherePaymentData.fromValue({ + assets: [{ coinId: 'aa'.repeat(32), amount: 5n }], + }).encode(); + const trailing = new Uint8Array(valid.length + 1); + trailing.set(valid); + trailing[valid.length] = 0xf6; + + expect(() => decodeFakeTokenAssets(fakeState(trailing))).toThrow(/payment data/i); + }); + + it('reads a VALID envelope as its assets', async () => { + const valid = await SpherePaymentData.fromValue({ + assets: [{ coinId: 'aa'.repeat(32), amount: 5n }], + }).encode(); + expect(decodeFakeTokenAssets(fakeState(valid))).toEqual([ + { coinId: 'aa'.repeat(32), amount: 5n }, + ]); + }); + + it('reads a genuinely coinless payload as null, with no error', () => { + expect(decodeFakeTokenAssets(fakeState(CborSerializer.encodeTextString('kitty')))).toBeNull(); + }); + + it('still returns null for bytes that are not a fake blob at all', () => { + expect(decodeFakeTokenAssets(new Uint8Array([1, 2, 3]))).toBeNull(); + }); +}); diff --git a/tests/unit/token-engine/FakeTokenEngine.ts b/tests/unit/token-engine/FakeTokenEngine.ts index 79097d53..8d61ee97 100644 --- a/tests/unit/token-engine/FakeTokenEngine.ts +++ b/tests/unit/token-engine/FakeTokenEngine.ts @@ -26,6 +26,10 @@ import { TokenId, TokenSalt, } from '../../../token-engine/sdk'; +import { + type ClassifiedValue, + classifyValueEnvelope, +} from '../../../token-engine/value-envelope'; import type { CoinId, EngineIdentity, @@ -102,7 +106,7 @@ export class FakeTokenEngine implements ITokenEngine { public readMemo(token: SphereToken): Uint8Array | null { const state = decodeFakeState(token.blob.token); if (state.transferMemo) return state.transferMemo; - if (state.genesisData && isSpherePaymentData(state.genesisData)) { + if (state.genesisData && classify(state).envelope === 'sphere') { return SpherePaymentData.fromCBOR(state.genesisData).memo; } return null; @@ -217,7 +221,14 @@ export class FakeTokenEngine implements ITokenEngine { // passed alongside the bytes. const state = decodeFakeState(blob.token); const normalized: TokenBlob = { ...blob, tokenId: HexConverter.encode(state.tokenId) }; - return Promise.resolve({ sdkToken: handleFor(blob.token), blob: normalized, value: valueOf(state) }); + const { envelope, value } = classify(state); + return Promise.resolve({ + sdkToken: handleFor(blob.token), + blob: normalized, + value, + valueEnvelope: envelope, + tokenType: fakeTokenType(state), + }); } // ── internals ────────────────────────────────────────────────────────────── @@ -238,7 +249,14 @@ export class FakeTokenEngine implements ITokenEngine { tokenId: HexConverter.encode(state.tokenId), token: stateBytes, }; - return { sdkToken: handleFor(stateBytes), blob, value: valueOf(state) }; + const { envelope, value } = classify(state); + return { + sdkToken: handleFor(stateBytes), + blob, + value, + valueEnvelope: envelope, + tokenType: fakeTokenType(state), + }; } /** Spent-tracking key = the per-state id (changes on every transfer). */ @@ -264,14 +282,18 @@ export class FakeTokenEngine implements ITokenEngine { export function decodeFakeTokenAssets( tokenBytes: Uint8Array ): { coinId: string; amount: bigint }[] | null { + let state: FakeState; try { - const state = decodeFakeState(tokenBytes); - if (!state.genesisData || !isSpherePaymentData(state.genesisData)) return null; - const value = SpherePaymentData.fromCBOR(state.genesisData).toValue(); - return value.assets.map((a) => ({ coinId: a.coinId, amount: a.amount })); + state = decodeFakeState(tokenBytes); } catch { - return null; + return null; // not fake-blob bytes at all } + // A classification throw PROPAGATES: the real backend 422s a corrupt envelope + // (§8.2 step 6), so swallowing it here would let the fake index one as coinless + // and every payments-v2 test would keep modelling the pre-#778 silent zero. + if (!state.genesisData || classify(state).envelope !== 'sphere') return null; + const value = SpherePaymentData.fromCBOR(state.genesisData).toValue(); + return value.assets.map((a) => ({ coinId: a.coinId, amount: a.amount })); } /** @@ -309,20 +331,21 @@ function decodeFakeState(bytes: Uint8Array): FakeState { }; } -/** Derive the decoded value exactly as the real adapter does (SpherePaymentData envelope only). */ -function valueOf(state: FakeState): SphereValue | null { - if (state.genesisData && isSpherePaymentData(state.genesisData)) { - return SpherePaymentData.fromCBOR(state.genesisData).toValue(); - } - return null; +/** + * Classify exactly as the real adapter does — the SAME function, never a copy. + * A private copy of the old naive predicate is what let every payments-v2 test + * model a pre-#778 engine while the real one moved on. + */ +function classify(state: FakeState): ClassifiedValue { + return classifyValueEnvelope(state.genesisData); } -function isSpherePaymentData(data: Uint8Array): boolean { - try { - return CborDeserializer.decodeTag(data).tag === SpherePaymentData.CBOR_TAG; - } catch { - return false; - } +/** + * The fake carries no type field, so derive a STABLE one per token id — enough for + * a consumer to key a class on, without a fake-blob format change. + */ +function fakeTokenType(state: FakeState): string { + return HexConverter.encode(state.tokenId.slice(0, 8)); } /** Map the fake's numeric network to the SDK NetworkId instance (for TokenId.fromSalt). */ diff --git a/tests/unit/token-engine/SphereTokenEngine.hardening.test.ts b/tests/unit/token-engine/SphereTokenEngine.hardening.test.ts index 83c814fd..9923643c 100644 --- a/tests/unit/token-engine/SphereTokenEngine.hardening.test.ts +++ b/tests/unit/token-engine/SphereTokenEngine.hardening.test.ts @@ -74,6 +74,60 @@ describe('SphereTokenEngine — hardening / edge cases', () => { expect(outputs.reduce((sum, o) => sum + e.balanceOf(o, COIN_A), 0n)).toBe(100n); }, 30000); + it('refuses un-classifiable data-token bytes BEFORE minting, never after certifying (#778)', async () => { + // Classification reads the outer major type, so opaque bytes starting in the CBOR + // array/tag range must frame cleanly. `wrapToken` runs on the LAST line of + // mintDataToken, after the on-chain certification — so without a pre-flight this + // refusal would strand a token that exists on-chain and can never be decoded again. + const e = createTestEngine(); + const self = e.getIdentity().chainPubkey; + const submit = vi.spyOn( + e as unknown as { submitAndAwaitProof: (...a: unknown[]) => unknown }, + 'submitAndAwaitProof', + ); + + // 0x82 is an array header promising two elements that are not there. + const err = await e + .mintDataToken({ recipientPubkey: self, data: new Uint8Array([0x82]) }) + .then(() => null, (caught: unknown) => caught); + + expect(err).toBeInstanceOf(SphereError); + expect((err as SphereError).message).toMatch(/Cannot mint a data token/); + expect((err as SphereError).message).toMatch(/wrap them in a CBOR byte string/); + expect(submit).not.toHaveBeenCalled(); + }, 15000); + + it('still mints opaque data whose first byte is outside the array and tag ranges', async () => { + const e = createTestEngine(); + const self = e.getIdentity().chainPubkey; + // A PNG magic header starts 0x89 — inside the ARRAY range — so it must be wrapped; + // a text/byte-string payload is the documented escape hatch and mints unchanged. + const data = new TextEncoder().encode('kitty #1'); + const token = await e.mintDataToken({ recipientPubkey: self, data }); + expect(e.readTokenData(token)).toEqual(data); + expect(e.readValue(token)).toBeNull(); + }, 15000); + + it('refuses to split a COINLESS token with a typed error, before any chain op (#778)', async () => { + // A coinless token is the wallet-api#140 case: genesis data that is not a value + // envelope. `TokenSplit.split` is handed `decodeSpherePaymentData`, so without the + // guard this dies inside the SDK with a bare CborError naming neither token nor cause. + const e = createTestEngine(); + const self = e.getIdentity().chainPubkey; + const nft = await e.mintDataToken({ + recipientPubkey: self, + data: new TextEncoder().encode('kitty #1'), + }); + expect(e.readValue(nft)).toBeNull(); + + const err = await e + .split({ token: nft, outputs: [{ recipientPubkey: self, coinId: COIN_A, amount: 1n }] }) + .then(() => null, (caught: unknown) => caught); + expect(err).toBeInstanceOf(SphereError); + expect((err as SphereError).message).toMatch(/no value this SDK can read/); + expect((err as SphereError).message).toMatch(/transferred whole/); + }, 15000); + it('split preserves output ORDER across bounded-concurrency batches (#684)', async () => { // The mint legs are minted in parallel with a concurrency cap (MAX_MINT_CONCURRENCY=8), // so >8 outputs span MULTIPLE batches. Distinct amounts make the returned order diff --git a/tests/unit/token-engine/value-envelope.test.ts b/tests/unit/token-engine/value-envelope.test.ts new file mode 100644 index 00000000..8aaef243 --- /dev/null +++ b/tests/unit/token-engine/value-envelope.test.ts @@ -0,0 +1,223 @@ +/** + * #778 — a strictness failure must never be indistinguishable from "no value". + * + * The predicate this replaces was `try { decodeTag(d).tag === CBOR_TAG } catch + * { false }`, and both callers read `false` as "data token, no coins". Because + * `decodeTag` parses the body and asserts exhaustion, a VALID envelope carrying + * one trailing byte answered `false` — real coins rendered as none, silently, + * with no error surface anywhere. Every case below is named for the input, and + * the reject cases are the ones that used to be silently zeroed. + * + * Cases mirror ../wallet-api/tests/unit/value-codec.test.ts so the two stacks + * can be diffed by name. + */ + +import { describe, expect, it } from 'vitest'; + +import { SphereError } from '../../../core/errors'; +import { CborSerializer, HexConverter } from '../../../token-engine/sdk'; +import { SpherePaymentData } from '../../../token-engine/SpherePaymentData'; +import { classifyValueEnvelope } from '../../../token-engine/value-envelope'; + +const COIN_ID = 'aa'.repeat(32); + +const cat = (...parts: Uint8Array[]): Uint8Array => { + const out = new Uint8Array(parts.reduce((n, p) => n + p.length, 0)); + let at = 0; + for (const p of parts) { + out.set(p, at); + at += p.length; + } + return out; +}; + +const bstr = (hex: string): Uint8Array => + CborSerializer.encodeByteString(HexConverter.decode(hex)); + +/** `Asset.toCBOR()`'s shape: [AssetId, BigInteger], both byte strings. */ +const assetCbor = (idHex: string, amountHex: string): Uint8Array => + CborSerializer.encodeArray(bstr(idHex), bstr(amountHex)); + +const validSphere = await SpherePaymentData.fromValue({ + assets: [{ coinId: COIN_ID, amount: 1000n }], +}).encode(); + +/** The bridged dialect: an untagged array of assets. */ +const validCollection = CborSerializer.encodeArray(assetCbor(COIN_ID, '03e8')); + +const metadataMap = CborSerializer.encodeTag( + 55799n, + CborSerializer.encodeTextString('kitty #1'), +); + +describe('classifyValueEnvelope — a readable value envelope', () => { + it('decodes a valid SpherePaymentData', () => { + const { envelope, value } = classifyValueEnvelope(validSphere); + expect(envelope).toBe('sphere'); + expect(value).toEqual({ assets: [{ coinId: COIN_ID, amount: 1000n }] }); + }); +}); + +/** + * THE REGRESSION SET. Every payload here carries real, readable coins and was + * reported as `value === null` before this change. A throw is the only outcome a + * user can act on: a balance has no other error surface. + */ +describe('classifyValueEnvelope — a CORRUPT envelope throws instead of reading as coinless', () => { + const rejects: readonly [name: string, payload: Uint8Array, match: RegExp][] = [ + [ + 'a valid SpherePaymentData with one trailing byte (decodeTag asserts exhaustion, so the old predicate called this untagged)', + cat(validSphere, new Uint8Array([0xf6])), + /payment data/i, + ], + [ + 'a SpherePaymentData truncated by one byte', + validSphere.slice(0, -1), + /payment data/i, + ], + [ + 'a NON-CANONICALLY encoded tag-39050 head (da 00 00 98 8a IS 39050, in a 4-byte head where CborReader demands the 2-byte one)', + cat(new Uint8Array([0xda, 0x00, 0x00, 0x98, 0x8a]), validSphere.slice(3)), + /tag head/, + ], + ['a truncated 2-byte tag head', new Uint8Array([0xd9]), /tag head/], + ['a tag head missing its second length byte', new Uint8Array([0xd9, 0x98]), /tag head/], + [ + 'raw binary in the tag range that is not canonical CBOR (de ad be ef)', + new Uint8Array([0xde, 0xad, 0xbe, 0xef]), + /tag head/, + ], + [ + 'tag 39050 whose body is not a SpherePaymentData at all', + CborSerializer.encodeTag(SpherePaymentData.CBOR_TAG, CborSerializer.encodeTextString('nope')), + /payment data/i, + ], + ]; + + it.each(rejects)('rejects %s', (_name, payload, match) => { + expect(() => classifyValueEnvelope(payload)).toThrow(match); + }); + + it('throws a typed SphereError, so callers can classify it rather than string-match', () => { + expect(() => classifyValueEnvelope(validSphere.slice(0, -1))).toThrow(SphereError); + }); +}); + +/** + * RFC 8949 §3.4.6 makes tag 55799 semantically TRANSPARENT, so `tag(55799) X` + * means exactly `X` — but no pinned codec unwraps it. It therefore matters only + * when it hides real VALUE; wrapped app data is the shape that actually exists, + * since self-describe is an encoder-wide setting. + */ +describe('classifyValueEnvelope — the self-described CBOR wrapper (matches wallet-api)', () => { + it('refuses tag 55799 hiding a valid SpherePaymentData — its coins would be invisible', () => { + expect(() => classifyValueEnvelope(CborSerializer.encodeTag(55799n, validSphere))).toThrow(/55799/); + }); + + it('refuses tag 55799 hiding a bare collection (the bridged-minter case)', () => { + expect(() => classifyValueEnvelope(CborSerializer.encodeTag(55799n, validCollection))).toThrow(/55799/); + }); + + it('refuses a nested 55799 rather than walking an unbounded chain', () => { + const nested = CborSerializer.encodeTag(55799n, CborSerializer.encodeTag(55799n, CborSerializer.encodeNull())); + expect(() => classifyValueEnvelope(nested)).toThrow(/55799/); + }); + + it('accepts tag 55799 wrapping app data as coinless — refusing it would reject the token #140 exists to accept', () => { + expect(classifyValueEnvelope(metadataMap)).toEqual({ envelope: 'none_tag', value: null }); + }); +}); + +/** A tagged item must frame completely, so the tag and array branches agree. */ +describe('classifyValueEnvelope — tag framing', () => { + it('refuses a tag head with no body at all', () => { + expect(() => classifyValueEnvelope(new Uint8Array([0xc1]))).toThrow(/malformed or carries trailing bytes/); + }); + + it('refuses a well-formed non-value tag followed by trailing garbage', () => { + const payload = cat( + CborSerializer.encodeTag(1n, CborSerializer.encodeUnsignedInteger(5n)), + new Uint8Array([0xff]), + ); + expect(() => classifyValueEnvelope(payload)).toThrow(/malformed or carries trailing bytes/); + }); +}); + +/** + * The tokens wallet-api#140 exists to accept. Each must stay `value === null` + * with NO error — an NFT is not a failure. + */ +describe('classifyValueEnvelope — a genuinely coinless token', () => { + const coinless: readonly [name: string, payload: Uint8Array | null, envelope: string][] = [ + ['null genesis data (the plainest coinless token)', null, 'none_absent'], + ['a zero-length payload', new Uint8Array(0), 'none_absent'], + ['an empty CBOR array', new Uint8Array([0x80]), 'none_other'], + [ + 'an array of text strings', + CborSerializer.encodeArray(CborSerializer.encodeTextString('a'), CborSerializer.encodeTextString('b')), + 'none_other', + ], + ['a CBOR text string of app metadata', CborSerializer.encodeTextString('kitty #1'), 'none_other'], + ['a CBOR byte string of opaque app data', CborSerializer.encodeByteString(new Uint8Array([1, 2, 3])), 'none_other'], + ['a tag that is not a value envelope', CborSerializer.encodeTag(1n, CborSerializer.encodeUnsignedInteger(5n)), 'none_tag'], + ]; + + it.each(coinless)('reads %s as coinless, with no error', (_name, payload, envelope) => { + expect(classifyValueEnvelope(payload)).toEqual({ envelope, value: null }); + }); +}); + +/** + * The ONE deliberate divergence from wallet-api, recorded so it reads as a + * decision. wallet-api DECODES this dialect and indexes its coins; this SDK does + * not, so the server can report a balance the wallet cannot see. Classifying it + * apart from `none_*` is what lets callers say "cannot read" instead of "has + * none". Decoding it is an acceptance widening, tracked separately. + */ +describe('classifyValueEnvelope — the bridged dialect is classified, not decoded', () => { + it('labels a bare PaymentAssetCollection `bare_collection`, distinctly from coinless', () => { + expect(classifyValueEnvelope(validCollection)).toEqual({ envelope: 'bare_collection', value: null }); + }); + + it('uses .some, never .every: an integer followed by a valid asset still claims the dialect', () => { + const payload = CborSerializer.encodeArray( + CborSerializer.encodeUnsignedInteger(1n), + assetCbor(COIN_ID, '07'), + ); + expect(classifyValueEnvelope(payload).envelope).toBe('bare_collection'); + }); + + it('refuses an array that does not frame — a truncation could be a corrupt collection', () => { + expect(() => classifyValueEnvelope(new Uint8Array([0x82]))).toThrow(/PaymentAssetCollection/); + }); + + it('refuses an indefinite-length array', () => { + expect(() => classifyValueEnvelope(new Uint8Array([0x9f, 0xff]))).toThrow(/PaymentAssetCollection/); + }); + + it('returns promptly on a huge declared element count rather than pre-allocating', () => { + const payload = new Uint8Array([0x9a, 0x00, 0xff, 0xff, 0xff]); + expect(() => classifyValueEnvelope(payload)).toThrow(/PaymentAssetCollection/); + }); +}); + +/** + * The safety direction. Anything reaching this client over the mailbox already + * passed wallet-api's §8.2 at deposit, so a sphere-only THROW would refuse a + * token the server accepted — and `Receive.screen()` makes that terminal. + */ +describe('the #778 funds invariant', () => { + it('never reports a readable value envelope as coinless', () => { + const carriesCoins = [validSphere, cat(validSphere, new Uint8Array([0xf6])), validSphere.slice(0, -1)]; + for (const payload of carriesCoins) { + let zeroed = false; + try { + const { envelope, value } = classifyValueEnvelope(payload); + zeroed = value === null && envelope.startsWith('none_'); + } catch { + zeroed = false; + } + expect(zeroed).toBe(false); + } + }); +}); diff --git a/token-engine/SphereTokenEngine.ts b/token-engine/SphereTokenEngine.ts index 9e3d1029..7378842b 100644 --- a/token-engine/SphereTokenEngine.ts +++ b/token-engine/SphereTokenEngine.ts @@ -37,7 +37,6 @@ import { deriveDeliveryKeys } from './blob-keys'; import { deriveRealization } from './realization'; import { burntTokenFromCheckpoint, encodeCheckpoint } from './split-checkpoint'; import { - CborDeserializer, CertificationData, CertificationStatus, EncodedPredicate, @@ -72,6 +71,7 @@ import { type ITokenVerifier, } from './sdk'; import { decodeSpherePaymentData, SpherePaymentData, sphereAssetToSdk } from './SpherePaymentData'; +import { assertMintableData, classifyValueEnvelope, wrapToken } from './value-envelope'; import type { EngineOpOptions, ITokenEngine } from './engine'; import type { CoinId, @@ -242,8 +242,10 @@ export class SphereTokenEngine implements ITokenEngine { return sdkToken.latestTransaction.data; } // A minted output (e.g. a split output) carries the memo in its value envelope. + // Classify structurally: a CORRUPT envelope throws rather than reading as a + // memo-less data token (#778), and only `'sphere'` guarantees fromCBOR succeeds. const data = sdkToken.genesis.data; - if (data && this.isSpherePaymentData(data)) { + if (data && classifyValueEnvelope(data).envelope === 'sphere') { return SpherePaymentData.fromCBOR(data).memo; } return null; @@ -286,10 +288,11 @@ export class SphereTokenEngine implements ITokenEngine { ); const certified = await mintTx.toCertifiedTransaction(this.deps.trustBase, this.deps.predicateVerifier, this.deps.unicityCertificateVerifier, proof); const token = await Token.mint(certified, this.deps.verificationContext); - return this.wrapToken(token); + return wrapToken(token); } public async mintDataToken(params: MintDataTokenParams, options?: EngineOpOptions): Promise { + assertMintableData(params.data); const recipient = SignaturePredicate.create(params.recipientPubkey); const tokenType = params.tokenType ? new TokenType(params.tokenType) : TokenType.generate(); // A deterministic salt yields a stable, terms-derived tokenId (TokenId.fromSalt). @@ -309,7 +312,7 @@ export class SphereTokenEngine implements ITokenEngine { ); const certified = await mintTx.toCertifiedTransaction(this.deps.trustBase, this.deps.predicateVerifier, this.deps.unicityCertificateVerifier, proof); const token = await Token.mint(certified, this.deps.verificationContext); - return this.wrapToken(token); + return wrapToken(token); } public async transfer(params: TransferParams, options?: EngineOpOptions): Promise { @@ -337,7 +340,7 @@ export class SphereTokenEngine implements ITokenEngine { ); const certified = await transferTx.toCertifiedTransaction(this.deps.trustBase, this.deps.predicateVerifier, this.deps.unicityCertificateVerifier, proof); const transferred = await params.token.sdkToken.transfer(certified, this.deps.verificationContext); - return this.wrapToken(transferred); + return wrapToken(transferred); } public async split(params: SplitParams, options?: EngineOpOptions): Promise { @@ -345,6 +348,18 @@ export class SphereTokenEngine implements ITokenEngine { if (params.outputs.length === 0) { throw new SphereError('Split requires at least one output', 'VALIDATION_ERROR'); } + // `TokenSplit.split` below is handed `decodeSpherePaymentData`, which reads only + // the tag-39050 envelope; without this an unreadable source dies inside the SDK + // with a bare CborError naming neither token nor cause. A coinless token cannot + // be split by construction, so this never refuses a legitimate split. + if (params.token.value === null) { + throw new SphereError( + `Cannot split token ${params.token.blob.tokenId}: its genesis payload carries no ` + + `value this SDK can read (envelope: ${params.token.valueEnvelope}). A coinless ` + + 'token can only be transferred whole.', + 'VALIDATION_ERROR', + ); + } const transferId = this.resolveTransferId(options); // E.1 deterministic realization: per-output HKDF salts make every output's @@ -536,7 +551,7 @@ export class SphereTokenEngine implements ITokenEngine { const proof = await this.submitSplitMintLeg(certData, mintTx, options); const certified = await mintTx.toCertifiedTransaction(this.deps.trustBase, this.deps.predicateVerifier, this.deps.unicityCertificateVerifier, proof); const token = await Token.mint(certified, this.deps.verificationContext); - return this.wrapToken(token); + return wrapToken(token); } /** @@ -617,7 +632,7 @@ export class SphereTokenEngine implements ITokenEngine { 'VALIDATION_ERROR', ); } - return this.wrapToken(sdkToken); + return wrapToken(sdkToken); } // ── internals ──────────────────────────────────────────────────────────────── @@ -756,37 +771,6 @@ export class SphereTokenEngine implements ITokenEngine { } } - /** Wrap an SDK token into a SphereToken: cache its blob (incl. stable tokenId) + decoded value. */ - private wrapToken(sdkToken: Token): SphereToken { - const data = sdkToken.genesis.data; - let value: SphereValue | null = null; - // Only value tokens carry a SpherePaymentData envelope; data tokens (e.g. invoices) - // leave value === null. A corrupt value envelope still errors loudly. - if (data && this.isSpherePaymentData(data)) { - try { - value = SpherePaymentData.fromCBOR(data).toValue(); - } catch (err) { - throw new SphereError( - `Failed to decode token payment data: ${err instanceof Error ? err.message : String(err)}`, - 'VALIDATION_ERROR', - ); - } - } - const blob: TokenBlob = { - tokenId: HexConverter.encode(sdkToken.id.bytes), - token: sdkToken.toCBOR(), - }; - return { sdkToken, blob, value }; - } - - /** True if the bytes are a SpherePaymentData envelope (value token) vs a raw data token. */ - private isSpherePaymentData(data: Uint8Array): boolean { - try { - return CborDeserializer.decodeTag(data).tag === SpherePaymentData.CBOR_TAG; - } catch { - return false; - } - } /** * Terminate the verification worker pool, if this engine was configured with * one. Idempotent — the pool's dispose only touches workers it spawned. diff --git a/token-engine/sdk.ts b/token-engine/sdk.ts index 27b1d52c..f5ca185f 100644 --- a/token-engine/sdk.ts +++ b/token-engine/sdk.ts @@ -72,6 +72,11 @@ export { DataHasherFactory } from '@unicitylabs/state-transition-sdk/lib/crypto/ export { CborSerializer } from '@unicitylabs/state-transition-sdk/lib/serialization/cbor/CborSerializer.js'; export { CborDeserializer } from '@unicitylabs/state-transition-sdk/lib/serialization/cbor/CborDeserializer.js'; export { CborError } from '@unicitylabs/state-transition-sdk/lib/serialization/cbor/CborError.js'; +// Head-only CBOR reads, for classifying a value envelope WITHOUT parsing its body +// (`value-envelope.ts`): `CborDeserializer.decodeTag` asserts exhaustion, so it +// cannot tell a corrupt envelope from a payload that carries no envelope at all. +export { CborReader } from '@unicitylabs/state-transition-sdk/lib/serialization/cbor/CborReader.js'; +export { MajorType } from '@unicitylabs/state-transition-sdk/lib/serialization/cbor/MajorType.js'; // ── payment / value / split ───────────────────────────────────────────────── export type { IPaymentData } from '@unicitylabs/state-transition-sdk/lib/payment/IPaymentData.js'; diff --git a/token-engine/types.ts b/token-engine/types.ts index 52c77986..d955a8f3 100644 --- a/token-engine/types.ts +++ b/token-engine/types.ts @@ -14,6 +14,7 @@ */ import type { Token } from './sdk'; +import type { ValueEnvelope } from './value-envelope'; // ── identity / recipients ───────────────────────────────────────────────────── @@ -74,6 +75,22 @@ export interface SphereToken { readonly blob: TokenBlob; /** Decoded value (cached); null when the token carries no sphere payment data. */ readonly value: SphereValue | null; + /** + * Which value envelope the genesis payload carried (#778). Distinguishes the + * reasons `value` is null, which the old boolean predicate collapsed: + * `'none_*'` means the token genuinely names no coin — a COINLESS token — while + * `'bare_collection'` means it carries coins in the bridged dialect this SDK + * does not decode, so a zero here is "cannot read", not "has none". A corrupt + * envelope never reaches this field: it throws during classification. + */ + readonly valueEnvelope: ValueEnvelope; + /** + * Genesis `TokenType`, lowercase hex. The token's CLASS, never its instance — + * `blob.tokenId` is the instance key (wallet-api#147). Only as meaningful as its + * minter made it: `mint()` and split outputs derive one per operation, so for + * value tokens it is per-mint noise. Never a spend gate. + */ + readonly tokenType: string; } // ── operation params (sphere-domain in, SphereToken out) ────────────────────── diff --git a/token-engine/value-envelope.ts b/token-engine/value-envelope.ts new file mode 100644 index 00000000..96b03add --- /dev/null +++ b/token-engine/value-envelope.ts @@ -0,0 +1,176 @@ +/** + * Genesis value-envelope classification: the client half of wallet-api's §8.2 + * step 6. This throw set stays a SUBSET of wallet-api's 422 set — throwing where + * it accepts loses a token (`Receive.screen()` acks a decode throw as invalid). + */ +import { SphereError } from '../core/errors'; + +import { + CborDeserializer, + CborReader, + HexConverter, + MajorType, + type Token, +} from './sdk'; +import { SpherePaymentData } from './SpherePaymentData'; +import type { SphereToken, SphereValue, TokenBlob } from './types'; + +/** `none_*` = coinless; `bare_collection` = a dialect this SDK cannot read. */ +export type ValueEnvelope = + | 'sphere' + | 'bare_collection' + | 'none_tag' + | 'none_other' + | 'none_absent'; + +export interface ClassifiedValue { + readonly envelope: ValueEnvelope; + /** Populated only for `'sphere'`; null for every other envelope. */ + readonly value: SphereValue | null; +} + +const SELF_DESCRIBED_CBOR_TAG = 55799n; +const CBOR_MAJOR_TYPE_MASK = 0b1110_0000; + +const describe = (error: unknown): string => + error instanceof Error ? error.message : String(error); + +const invalid = (message: string): SphereError => new SphereError(message, 'VALIDATION_ERROR'); + +function majorTypeOf(bytes: Uint8Array): MajorType | null { + const first = bytes.at(0); + return first === undefined ? null : first & CBOR_MAJOR_TYPE_MASK; +} + +/** Tag number from the HEAD ALONE; `null` = truncated or non-canonical head. */ +function headTagNumber(bytes: Uint8Array): bigint | null { + try { + return new CborReader(bytes).readLength(MajorType.TAG); + } catch { + return null; + } +} + +/** `Asset.toCBOR()`'s signature: a 2-array of byte strings. Arity and types only. */ +function isAssetShaped(item: Uint8Array): boolean { + try { + return CborDeserializer.decodeArray(item, 2).every( + (field) => majorTypeOf(field) === MajorType.BYTE_STRING, + ); + } catch { + return false; + } +} + +function arrayItemsOrNull(bytes: Uint8Array): readonly Uint8Array[] | null { + try { + return CborDeserializer.decodeArray(bytes); + } catch { + return null; + } +} + +function decodeSphere(genesisData: Uint8Array): SphereValue { + try { + return SpherePaymentData.fromCBOR(genesisData).toValue(); + } catch (error) { + throw invalid(`Failed to decode token payment data: ${describe(error)}`); + } +} + +/** Tagged items must frame cleanly, so raw binary in the tag range is refused. */ +function taggedItemBody(genesisData: Uint8Array): Uint8Array { + try { + return CborDeserializer.decodeTag(genesisData).data; + } catch (error) { + throw invalid( + `Token value payload is a CBOR tag whose item is malformed or carries trailing bytes: ${describe(error)}`, + ); + } +} + +/** Does a 55799 wrapper hide a value envelope? Nested wrappers are refused, not walked. */ +function selfDescribedBodyCarriesValue(body: Uint8Array): boolean { + const major = majorTypeOf(body); + if (major === MajorType.TAG) { + const inner = headTagNumber(body); + return inner === SpherePaymentData.CBOR_TAG || inner === SELF_DESCRIBED_CBOR_TAG; + } + if (major === MajorType.ARRAY) { + const items = arrayItemsOrNull(body); + return items !== null && items.some(isAssetShaped); + } + return false; +} + +/** Tag 39050 decodes; 55799-over-value is refused; any other tag is coinless. */ +function classifyTagged(genesisData: Uint8Array): ClassifiedValue { + const tag = headTagNumber(genesisData); + if (tag === null) { + throw invalid( + 'Token value payload has an unreadable or non-canonically encoded CBOR tag head — it could be a malformed SpherePaymentData envelope', + ); + } + if (tag === SpherePaymentData.CBOR_TAG) { + return { envelope: 'sphere', value: decodeSphere(genesisData) }; + } + const body = taggedItemBody(genesisData); + if (tag === SELF_DESCRIBED_CBOR_TAG && selfDescribedBodyCarriesValue(body)) { + throw invalid( + 'Token value payload is a value envelope wrapped in the self-described CBOR tag 55799 (RFC 8949 §3.4.6), which no codec here unwraps — its coins would be invisible. Emit the envelope without the self-describe prefix', + ); + } + return { envelope: 'none_tag', value: null }; +} + +/** Must frame cleanly (a truncation may be a corrupt collection). `.some`, never `.every`. */ +function classifyArray(genesisData: Uint8Array): ClassifiedValue { + const items = arrayItemsOrNull(genesisData); + if (items === null) { + throw invalid('Token value payload is not PaymentAssetCollection: malformed CBOR array'); + } + return items.some(isAssetShaped) + ? { envelope: 'bare_collection', value: null } + : { envelope: 'none_other', value: null }; +} + +/** Not a value envelope ⇒ null value (coinless); claims one and fails to decode ⇒ throws. */ +export function classifyValueEnvelope(genesisData: Uint8Array | null): ClassifiedValue { + if (genesisData === null) return { envelope: 'none_absent', value: null }; + const major = majorTypeOf(genesisData); + if (major === null) return { envelope: 'none_absent', value: null }; + if (major === MajorType.TAG) return classifyTagged(genesisData); + if (major === MajorType.ARRAY) return classifyArray(genesisData); + return { envelope: 'none_other', value: null }; +} + +/** Wrap an SDK token: blob (incl. stable tokenId), classified envelope, decoded value. */ +export function wrapToken(sdkToken: Token): SphereToken { + const { envelope, value } = classifyValueEnvelope(sdkToken.genesis.data); + const blob: TokenBlob = { + tokenId: HexConverter.encode(sdkToken.id.bytes), + token: sdkToken.toCBOR(), + }; + return { + sdkToken, + blob, + value, + valueEnvelope: envelope, + tokenType: HexConverter.encode(sdkToken.type.bytes), + }; +} + +/** + * Pre-flight for `mintDataToken`'s opaque payload, BEFORE any chain op — otherwise + * the refusal surfaces from `wrapToken` after the mint certified, stranding a token + * this SDK can no longer decode and wallet-api would refuse at deposit anyway. + */ +export function assertMintableData(data: Uint8Array): void { + try { + classifyValueEnvelope(data); + } catch (error) { + throw invalid( + `Cannot mint a data token with this payload: ${describe(error)}. Raw bytes starting in the CBOR array (0x80-0x9f) or tag (0xc0-0xdf) range must be well-formed canonical CBOR; wrap them in a CBOR byte string, map, text string, or a tag other than 39050/55799.`, + ); + } +} diff --git a/types/index.ts b/types/index.ts index 320cafb3..01287404 100644 --- a/types/index.ts +++ b/types/index.ts @@ -86,6 +86,32 @@ export interface Token { suspectedSpent?: boolean; } +/** + * A holding that names no coin (wallet-api#140) — an NFT. Deliberately NOT a + * `Token`: no amount, decimals or symbol, and never returned by `tokens()` or + * counted in `assets()`. `tokenType` names the token's CLASS and `tokenId` the + * instance (wallet-api#147). The payload is read with `payments.tokenData()`. + */ +export interface CoinlessToken { + readonly tokenId: string; + readonly tokenType?: string; + /** + * Class metadata resolved from the registry THIS wallet owns, when the type is + * recognised. Resolved here so callers never reach for a registry themselves: + * the process-global singleton is repointable by another Sphere's init, so a + * second wallet on another network would retarget it (#767). + */ + readonly name?: string; + readonly iconUrl?: string; + readonly stateHash: string; + /** #737: reserved by a converging transfer — not spendable right now. */ + readonly transferring: boolean; + /** #625: proven spent on-chain; excluded from spend selection. */ + readonly suspectedSpent?: boolean; + readonly createdAt: number; + readonly updatedAt: number; +} + export interface Asset { readonly coinId: string; readonly symbol: string; @@ -177,6 +203,8 @@ export interface IncomingTransfer { readonly senderPubkey: string; readonly senderNametag?: string; readonly tokens: Token[]; + /** Arrivals that name no coin (#777). Disjoint from `tokens`, never a zero Token. */ + readonly coinless?: CoinlessToken[]; readonly memo?: string; readonly receivedAt: number; }