From 3686790eeadc7df0c2fb3cdac3e643d2eac4a350 Mon Sep 17 00:00:00 2001 From: Pavel Grigorenko Date: Wed, 9 Sep 2026 15:49:21 +0200 Subject: [PATCH 1/8] fix(token-engine): a corrupt value envelope throws instead of reading as a valueless token MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `isSpherePaymentData` was `try { CborDeserializer.decodeTag(data).tag === CBOR_TAG } catch { return false }`, and both callers — `wrapToken` and `readMemo` — read `false` as "data token, no value". But `decodeTag` parses the tagged body and then asserts exhaustion, so it answers "untagged" for far more than a wrong tag: - a valid SpherePaymentData carrying one trailing byte - a truncated SpherePaymentData - a non-canonically encoded tag head (`da 00 00 98 8a` IS tag 39050, written with a 4-byte head where CborReader requires the minimal 2-byte one) - `tag(55799)` — RFC 8949 §3.4.6 self-described CBOR, which is semantically transparent and so MEANS the envelope Each carries real, readable coins and rendered as `value === null`, silently. A balance has no other error surface: showing zero is the one outcome from which a user cannot tell "this token has no coins" from "I cannot read this token's coins". Replaced by `token-engine/value-envelope.ts`, a structural classifier ported from wallet-api's §8.2 step 6 (`src/value-codec.ts`, wallet-api#141). It reads the outer item's major type and — via `CborReader.readLength`, newly re-exported from sdk.ts — the tag head ALONE, never "the decode threw, so it must be valueless". `wrapToken` moves here too; it uses no engine state, and `SphereTokenEngine.ts` sat 2 lines under its 800-line ceiling. The throw set stays a SUBSET of wallet-api's 422 set. Every token arriving over the mailbox already passed §8.2 at deposit, and `Receive.screen()` turns a decode throw into a terminal `rejectAck('invalid')` plus a durable seen-set write — so throwing where wallet-api accepts would lose the token outright. `SphereToken` gains `valueEnvelope`, which distinguishes the reasons `value` is null. `none_*` is a genuinely coinless token (wallet-api#140). `bare_collection` is the bridged-mint dialect wallet-api decodes and this SDK does not, so a zero there means "cannot read", not "carries none" — classified, deliberately not decoded, since widening acceptance is a separate change with its own accounting consequences. Two fail-closed guards, both before any chain op: - `split()` refuses a source whose value cannot be read. `TokenSplit.split` is handed `decodeSpherePaymentData`, so such a source previously died inside the SDK with a bare `CborError: Major type mismatch` naming neither token nor cause. Splitting a coinless token is impossible by construction, so the guard can never refuse a legitimate split. - `mintDataToken()` refuses opaque bytes that classification cannot frame. Since classification keys on the outer major type, raw binary starting in the CBOR array (0x80-0x9f) or tag (0xc0-0xdf) range must be well-formed canonical CBOR. `wrapToken` runs on that method's LAST line, after certification, so without this pre-flight the refusal arrives once the token already exists on-chain — stranding one this SDK can never decode and wallet-api would refuse at deposit anyway. The error names the escape hatch: wrap the bytes in a CBOR byte string, map, text string, or another tag. `FakeTokenEngine` held a byte-for-byte copy of the deleted predicate behind three callers; it now calls the real classifier, so payments-v2 tests stop modelling a pre-fix engine. Verified: 2237 unit/integration tests green; 28 classifier cases mirroring wallet-api's table by name; 7 new mutation probes. Refs #778. --- tests/mutation/probes.json | 70 ++++++ tests/unit/payments-v2/receive.test.ts | 1 + tests/unit/support/mock-token-engine.ts | 7 +- tests/unit/token-engine/FakeTokenEngine.ts | 35 ++- .../SphereTokenEngine.hardening.test.ts | 54 +++++ .../unit/token-engine/value-envelope.test.ts | 223 ++++++++++++++++++ token-engine/SphereTokenEngine.ts | 60 ++--- token-engine/sdk.ts | 5 + token-engine/types.ts | 10 + token-engine/value-envelope.ts | 170 +++++++++++++ 10 files changed, 578 insertions(+), 57 deletions(-) create mode 100644 tests/unit/token-engine/value-envelope.test.ts create mode 100644 token-engine/value-envelope.ts diff --git a/tests/mutation/probes.json b/tests/mutation/probes.json index 6e28da6c..97b01158 100644 --- a/tests/mutation/probes.json +++ b/tests/mutation/probes.json @@ -1214,5 +1214,75 @@ "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" + ] } ] diff --git a/tests/unit/payments-v2/receive.test.ts b/tests/unit/payments-v2/receive.test.ts index 1afd81b4..bfe165d3 100644 --- a/tests/unit/payments-v2/receive.test.ts +++ b/tests/unit/payments-v2/receive.test.ts @@ -62,6 +62,7 @@ class StubEngine implements ReceiveEngine { sdkToken: parsed as never, blob, value: { assets: parsed.assets.map((a) => ({ coinId: a.coinId, amount: BigInt(a.amount) })) }, + valueEnvelope: 'sphere', }; } diff --git a/tests/unit/support/mock-token-engine.ts b/tests/unit/support/mock-token-engine.ts index c9cb9c9b..58bd5fbd 100644 --- a/tests/unit/support/mock-token-engine.ts +++ b/tests/unit/support/mock-token-engine.ts @@ -6,7 +6,12 @@ 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', + }; } const hex = (b: Uint8Array) => Array.from(b, (x) => x.toString(16).padStart(2, '0')).join(''); diff --git a/tests/unit/token-engine/FakeTokenEngine.ts b/tests/unit/token-engine/FakeTokenEngine.ts index 79097d53..7dd31338 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,8 @@ 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 }); } // ── internals ────────────────────────────────────────────────────────────── @@ -238,7 +243,8 @@ 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 }; } /** Spent-tracking key = the per-state id (changes on every transfer). */ @@ -266,7 +272,7 @@ export function decodeFakeTokenAssets( ): { coinId: string; amount: bigint }[] | null { try { const state = decodeFakeState(tokenBytes); - if (!state.genesisData || !isSpherePaymentData(state.genesisData)) return null; + 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 })); } catch { @@ -309,20 +315,13 @@ 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; -} - -function isSpherePaymentData(data: Uint8Array): boolean { - try { - return CborDeserializer.decodeTag(data).tag === SpherePaymentData.CBOR_TAG; - } catch { - return false; - } +/** + * 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); } /** 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..737c59d5 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,15 @@ 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; } // ── 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..1a541bff --- /dev/null +++ b/token-engine/value-envelope.ts @@ -0,0 +1,170 @@ +/** + * 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 }; +} + +/** + * 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.`, + ); + } +} From 6abe50b73299a2ecc1556cb75d36541ac3872dd5 Mon Sep 17 00:00:00 2001 From: Pavel Grigorenko Date: Wed, 9 Sep 2026 16:42:26 +0200 Subject: [PATCH 2/8] feat(payments-v2): represent and expose coinless tokens MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `tokens()` reads `entry.assets[0]` and skips the entry when it is absent, so a token that names no coin was invisible to the wallet even once the backend admitted it (wallet-api#140/#141). It is held, verified, claimed, tombstone- recoverable and counted in held-state seeding — and shown nowhere. Coinless tokens surface through their OWN read rather than as zero-valued `Token`s. `Token` requires coinId, symbol, decimals and amount; a token with no coin has none of them, and filling sentinels would put untrue values in money-shaped fields that a consumer may sum or format. The two reads are DISJOINT — an active mirror entry is in exactly one — so every existing `tokens()`/`assets()` consumer is byte-identical. payments.coinless(): CoinlessToken[] payments.tokenData(tokenId): Promise `tokenData` is a separate call, not a field: the genesis payload IS an NFT's content, blobs are lazy under server custody, and a payload is unbounded, so a list read must never carry it. It is state-gated on the mirror's current stateHash, so a token that moved state refetches rather than serving stale bytes. The vocabulary is "coinless", not "non-fungible" (wallet-api#147): in Unicity every token is non-fungible by construction — each is a unique object keyed by tokenId — and what varies is whether it carries fungible assets inside its value envelope. "Non-fungible" names every token and distinguishes none. Consumed from the backend: - `tokenType` on `InventoryItemWire` / `InventoryItem`. It arrived at runtime already (the inventory response is `JSON.parse`d and type-asserted, never schema-validated), so this is a declaration, not transport work. - `MirrorEntry.coinless` is computed ONCE at apply time, where `status` is in hand. 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. - `tokenType` names the token's CLASS, not the instance. Two NFTs of one collection share a type and are told apart by id, so it is a display hint and never an identity or a spend gate. `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 incoming tokens saw nothing land. History for a coinless receipt posts `assets: []`. `recordReceived` flattened a receipt to scalars (`coinId: first?.coinId ?? ''`), which wallet-api#151 deliberately still refuses while now accepting an empty list — and `History.post` swallows the 422, so the row was lost with no error surface. §10 forbids a record naming neither assets nor a tokenId; `tokenId` is always set here. `TokenRegistry.getTypeDefinition()` resolves a coinless token's class. One registry file carries 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 `definitionsById` map cannot tell them apart. The new lookup is namespace-correct; `getDefinition` is left resolving either, because a test pins that and changing it is not this change's business. `SphereToken` gains `tokenType`, so the receive path can name an arrival without a second decode. Extracted to stay under the file-size ceilings the additions crossed: `inventory/token-data.ts` and `send-errors.ts` (the `partialize`/`stampTransferId` error shapers, moved verbatim). Verified: 2253 unit/integration tests green (16 new across inventory, receive and registry); typecheck, typecheck:tests and lint clean; 6 new mutation probes. Refs #777. --- impl/wallet-api-v2/client.ts | 11 ++ modules/payments-v2/PaymentsFacade.ts | 41 +++--- modules/payments-v2/api.ts | 6 +- modules/payments-v2/compose.ts | 4 +- modules/payments-v2/history/History.ts | 11 +- .../payments-v2/inventory/InventoryView.ts | 71 +++++++-- modules/payments-v2/inventory/token-data.ts | 26 ++++ modules/payments-v2/ports.ts | 2 + modules/payments-v2/receive/Receive.ts | 31 +++- modules/payments-v2/send-errors.ts | 31 ++++ registry/TokenRegistry.ts | 19 +++ tests/mutation/probes.json | 91 ++++++++++++ tests/unit/payments-v2/history.test.ts | 46 +++++- tests/unit/payments-v2/inventory.test.ts | 135 ++++++++++++++++++ tests/unit/payments-v2/receive.test.ts | 131 ++++++++++++++++- tests/unit/payments-v2/token-data.test.ts | 75 ++++++++++ tests/unit/registry/TokenRegistry.test.ts | 35 +++++ tests/unit/support/mock-token-engine.ts | 1 + tests/unit/token-engine/FakeTokenEngine.ts | 24 +++- token-engine/types.ts | 7 + token-engine/value-envelope.ts | 8 +- types/index.ts | 20 +++ 22 files changed, 774 insertions(+), 52 deletions(-) create mode 100644 modules/payments-v2/inventory/token-data.ts create mode 100644 modules/payments-v2/send-errors.ts create mode 100644 tests/unit/payments-v2/token-data.test.ts 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..bfede74c 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(); + } + + 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); 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..72a3d931 100644 --- a/modules/payments-v2/compose.ts +++ b/modules/payments-v2/compose.ts @@ -328,12 +328,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..a1a5395e 100644 --- a/modules/payments-v2/history/History.ts +++ b/modules/payments-v2/history/History.ts @@ -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; @@ -123,7 +128,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: input.assets.map((a) => ({ coinId: a.coinId, amount: a.amount })), ts: this.ts(input.timestamp), ...(input.transferId !== undefined ? { transferId: input.transferId } : {}), ...this.wireTokenId(input.tokenId), diff --git a/modules/payments-v2/inventory/InventoryView.ts b/modules/payments-v2/inventory/InventoryView.ts index 49aaa6ac..af0a2480 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,29 @@ 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(): CoinlessToken[] { + const out: CoinlessToken[] = []; + for (const [tokenId, entry] of this.mirror) { + if (entry.status !== 'active' || !entry.coinless) continue; + out.push({ + tokenId, + ...(entry.tokenType !== undefined ? { tokenType: entry.tokenType } : {}), + 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 +353,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/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..bd9e1e85 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,16 @@ 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. + */ + 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/mutation/probes.json b/tests/mutation/probes.json index 97b01158..44d11dd3 100644 --- a/tests/mutation/probes.json +++ b/tests/mutation/probes.json @@ -1284,5 +1284,96 @@ "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 receipt 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.", + "file": "modules/payments-v2/history/History.ts", + "find": " assets: input.assets.map((a) => ({ coinId: a.coinId, amount: a.amount })),", + "replace": " 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" + ] } ] diff --git a/tests/unit/payments-v2/history.test.ts b/tests/unit/payments-v2/history.test.ts index d46b0e2e..fe799461 100644 --- a/tests/unit/payments-v2/history.test.ts +++ b/tests/unit/payments-v2/history.test.ts @@ -216,15 +216,15 @@ 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); }); @@ -246,7 +246,7 @@ describe('History §5.9 — client POSTs and dedup keys', () => { h.history.recordSent({ transferId: 'b2222222-2222-4222-8222-222222222222', 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(); expect(h.log).toHaveBeenCalledTimes(3); @@ -256,7 +256,7 @@ describe('History §5.9 — client POSTs and dedup keys', () => { 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.recordReceived({ tokenId: TOKEN, stateHash: STATE_A, assets: [{ coinId: COIN, amount: '1' }] }); await h.history.recordMint({ tokenId: TOKEN, 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([ @@ -329,3 +329,39 @@ 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' }, + ]); + }); +}); diff --git a/tests/unit/payments-v2/inventory.test.ts b/tests/unit/payments-v2/inventory.test.ts index cdfd79c4..64eea8fe 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 } : {}), }; } @@ -480,3 +482,136 @@ 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()).toEqual([ + { + tokenId: 'N', + tokenType: NFT_TYPE, + 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().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(); + 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()).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()[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()[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()[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()[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()).toEqual([]); + expect(view.tokens(registry).map((t) => t.id)).toEqual(['C']); + expect(view.pool(COIN)).toEqual([{ tokenId: 'C', amount: 100n }]); + }); + + 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()).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 bfe165d3..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,8 +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) })) }, - valueEnvelope: 'sphere', + 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), }; } @@ -1086,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 58bd5fbd..e9fd383f 100644 --- a/tests/unit/support/mock-token-engine.ts +++ b/tests/unit/support/mock-token-engine.ts @@ -11,6 +11,7 @@ export function mockSphereToken(value: SphereValue | null = { assets: [] }): Sph blob, value, valueEnvelope: value === null ? 'none_absent' : 'sphere', + tokenType: 'aa'.repeat(4), }; } diff --git a/tests/unit/token-engine/FakeTokenEngine.ts b/tests/unit/token-engine/FakeTokenEngine.ts index 7dd31338..88129eff 100644 --- a/tests/unit/token-engine/FakeTokenEngine.ts +++ b/tests/unit/token-engine/FakeTokenEngine.ts @@ -222,7 +222,13 @@ export class FakeTokenEngine implements ITokenEngine { const state = decodeFakeState(blob.token); const normalized: TokenBlob = { ...blob, tokenId: HexConverter.encode(state.tokenId) }; const { envelope, value } = classify(state); - return Promise.resolve({ sdkToken: handleFor(blob.token), blob: normalized, value, valueEnvelope: envelope }); + return Promise.resolve({ + sdkToken: handleFor(blob.token), + blob: normalized, + value, + valueEnvelope: envelope, + tokenType: fakeTokenType(state), + }); } // ── internals ────────────────────────────────────────────────────────────── @@ -244,7 +250,13 @@ export class FakeTokenEngine implements ITokenEngine { token: stateBytes, }; const { envelope, value } = classify(state); - return { sdkToken: handleFor(stateBytes), blob, value, valueEnvelope: envelope }; + return { + sdkToken: handleFor(stateBytes), + blob, + value, + valueEnvelope: envelope, + tokenType: fakeTokenType(state), + }; } /** Spent-tracking key = the per-state id (changes on every transfer). */ @@ -324,6 +336,14 @@ function classify(state: FakeState): ClassifiedValue { return classifyValueEnvelope(state.genesisData); } +/** + * 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). */ function networkIdOf(n: number): NetworkId { if (n === NetworkId.MAINNET.id) return NetworkId.MAINNET; diff --git a/token-engine/types.ts b/token-engine/types.ts index 737c59d5..d955a8f3 100644 --- a/token-engine/types.ts +++ b/token-engine/types.ts @@ -84,6 +84,13 @@ export interface SphereToken { * 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 index 1a541bff..96b03add 100644 --- a/token-engine/value-envelope.ts +++ b/token-engine/value-envelope.ts @@ -151,7 +151,13 @@ export function wrapToken(sdkToken: Token): SphereToken { tokenId: HexConverter.encode(sdkToken.id.bytes), token: sdkToken.toCBOR(), }; - return { sdkToken, blob, value, valueEnvelope: envelope }; + return { + sdkToken, + blob, + value, + valueEnvelope: envelope, + tokenType: HexConverter.encode(sdkToken.type.bytes), + }; } /** diff --git a/types/index.ts b/types/index.ts index 320cafb3..4e3244aa 100644 --- a/types/index.ts +++ b/types/index.ts @@ -86,6 +86,24 @@ 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; + 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 +195,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; } From 77e42c85bb8fff68377caae78f56e6a6c0b62eef Mon Sep 17 00:00:00 2001 From: Pavel Grigorenko Date: Wed, 9 Sep 2026 18:09:00 +0200 Subject: [PATCH 3/8] fix(history): every record type can post an empty asset list, and a permanent reject says so MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit wallet-api#142 (merged as wallet-api#151) made §10 accept `assets: []` on every record type — that is how a coinless token's movement is logged. This client still could not write one. `recordSent` and `recordMint` took `coinId`/`amount` scalars and wrapped them unconditionally, so the only expressible shape was a one-element array. For a coinless token that meant `[{coinId: '', amount: '0'}]`, which wallet-api deliberately keeps refusing: accepting it would create two wire spellings of "no coin" and every consumer would have to handle both forever. Both inputs now take the asset list directly, so absence propagates instead of being flattened into empty strings. `recordReceived` was fixed in the coinless-token change; this completes the set, since a coinless token can equally be minted or sent on. `History.post` logged every failure as "retry safe (dedupKey makes retry safe)". A 4xx is not: it is the server refusing this record's SHAPE, permanently, and no retry can fix it. That indiscriminate swallow is precisely what let a refused receipt disappear with no error surface anywhere — the money path is unaffected, which is why nothing else noticed. The two are now named apart (408 and 429 stay transient). It still never throws: §5.9 keeps history off the money path. Verified: 2273 unit/integration tests green (7 new — empty lists on SENT/MINT, the "never emit coinId: ''" invariant across all three types, and the permanent-vs- transient split incl. 408/429); 3 new mutation probes. Closes #780. --- modules/payments-v2/PaymentsFacade.ts | 5 +- modules/payments-v2/compose.ts | 3 +- modules/payments-v2/history/History.ts | 41 ++++++++-- tests/mutation/probes.json | 31 ++++++++ tests/unit/payments-v2/history.test.ts | 100 +++++++++++++++++++++---- 5 files changed, 153 insertions(+), 27 deletions(-) diff --git a/modules/payments-v2/PaymentsFacade.ts b/modules/payments-v2/PaymentsFacade.ts index bfede74c..0eb5e66a 100644 --- a/modules/payments-v2/PaymentsFacade.ts +++ b/modules/payments-v2/PaymentsFacade.ts @@ -702,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/compose.ts b/modules/payments-v2/compose.ts index 72a3d931..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 } : {}), }); diff --git a/modules/payments-v2/history/History.ts b/modules/payments-v2/history/History.ts index a1a5395e..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; @@ -82,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}$/; @@ -114,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), @@ -128,7 +145,7 @@ export class History { dedupKey: `RECEIVED:${input.tokenId.toLowerCase()}:${input.stateHash.toLowerCase()}`, id: (this.deps.newId ?? randomUUID)(), type: 'RECEIVED' as const, - assets: input.assets.map((a) => ({ coinId: a.coinId, amount: a.amount })), + assets: wireAssets(input.assets), ts: this.ts(input.timestamp), ...(input.transferId !== undefined ? { transferId: input.transferId } : {}), ...this.wireTokenId(input.tokenId), @@ -142,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), })); @@ -155,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/tests/mutation/probes.json b/tests/mutation/probes.json index 44d11dd3..ea86497a 100644 --- a/tests/mutation/probes.json +++ b/tests/mutation/probes.json @@ -1375,5 +1375,36 @@ "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" + ] } ] diff --git a/tests/unit/payments-v2/history.test.ts b/tests/unit/payments-v2/history.test.ts index fe799461..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 }, @@ -231,8 +238,8 @@ describe('History §5.9 — client POSTs and dedup keys', () => { 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, 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.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, 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', }); @@ -365,3 +369,67 @@ describe('History — a coinless RECEIVED record (#777 / wallet-api#151)', () => ]); }); }); + +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(); + }); +}); From 930528b7f9db5604ad30237056b751e5c81c4d55 Mon Sep 17 00:00:00 2001 From: Pavel Grigorenko Date: Wed, 9 Sep 2026 18:17:52 +0200 Subject: [PATCH 4/8] test(payments-v2): pin that a restore re-pull cannot strand a stale coinless verdict MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `coinless` is computed at apply time, and `applyOne` early-returns on an unchanged row. The §5.4 restore protocol drops every cursor and does a FULL re-pull, so it re-applies rows the mirror already holds — which is exactly where an early return could leave a verdict behind. It cannot, and the test records why: the comparison includes `status`, so a rebuild that flips a row re-applies and recomputes. Stranding one would require `assets` to change at an identical seq, status, stateHash and tokenType, which wallet-api's §8.2 pure-widening rule excludes — every input that decoded to a non-empty asset set before decodes identically after. Raised as an analogous surface to the `recoverOne` status-flip finding. --- tests/unit/payments-v2/inventory.test.ts | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/tests/unit/payments-v2/inventory.test.ts b/tests/unit/payments-v2/inventory.test.ts index 64eea8fe..5b7564b5 100644 --- a/tests/unit/payments-v2/inventory.test.ts +++ b/tests/unit/payments-v2/inventory.test.ts @@ -603,6 +603,26 @@ describe('InventoryView — coinless tokens (#777)', () => { 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().map((t) => t.tokenId)).toEqual(['N']); + expect(view.tokens(registry).map((t) => t.id)).toEqual(['A']); + }); + 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), From 539ea7e843ec59da43e2530ce553001d769d00f9 Mon Sep 17 00:00:00 2001 From: Pavel Grigorenko Date: Wed, 9 Sep 2026 18:39:28 +0200 Subject: [PATCH 5/8] test(e2e): prove coinless tokens against live staging, not against a fake MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every other coinless test in this repo runs against `FakeTokenEngine` or a stub that round-trips whatever it was handed, so wrong CBOR passes them unnoticed — CLAUDE.md says as much about facade tests that swap an engine via `setEngine`. This leg mints a REAL testnet2-certified coinless token with the registry's canonical non-fungible type, indexes it through the deployed backend, and reads it back through the presigned GET. The assertion that needed a real service: `tokenData()` returns the genesis payload BYTE-IDENTICAL to what was minted, after CBOR encode → SHA-256 content addressing → S3 → presigned GET → decode. The stored blob is the whole `Token` CBOR rather than the payload, so a `tokenData` that returned the blob, or decoded the wrong shape, is caught only here. Alongside it: the token appears in `coinless()` carrying its type, never in `tokens()`, and moves no balance — the disjointness contract on a token that made a real round trip rather than on a fixture the mirror was handed. The empty-payload case records a distinction worth not rediscovering: zero bytes read back as an EMPTY payload, not null, because a typed array is truthy — and `mintDataToken` types `data` as required, so a genuinely absent payload is not expressible through this API at all. Out of CI by construction rather than convention: `vitest.config.ts` excludes `tests/e2e/**`, so `test:run` does not collect it, while `typecheck:tests` still covers it so it cannot rot silently. Dormant without `STAGING_AGGREGATOR_KEY`. Verified: 3/3 green against wallet-api staging f8e64bc on testnet2. --- tests/e2e/coinless-tokens.staging.e2e.test.ts | 170 ++++++++++++++++++ tests/mutation/probes.json | 6 +- 2 files changed, 173 insertions(+), 3 deletions(-) create mode 100644 tests/e2e/coinless-tokens.staging.e2e.test.ts 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 ea86497a..ad342c50 100644 --- a/tests/mutation/probes.json +++ b/tests/mutation/probes.json @@ -1327,10 +1327,10 @@ }, { "name": "history-received-flattens-to-empty-coin", - "note": "wallet-api#151: a coinless receipt 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.", + "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": " assets: input.assets.map((a) => ({ coinId: a.coinId, amount: a.amount })),", - "replace": " assets: [{ coinId: input.assets[0]?.coinId ?? '', amount: input.assets[0]?.amount ?? '0' }],", + "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" From 8e4a2bbfec7208baa22419a169d32e20462bd699 Mon Sep 17 00:00:00 2001 From: Pavel Grigorenko Date: Wed, 9 Sep 2026 18:47:50 +0200 Subject: [PATCH 6/8] docs: coinless tokens across the reference, design, migration and quickstarts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The contract changed in this branch and the docs still described the old one. - `docs/API.md` — `coinless()` and `tokenData()` entries, the `CoinlessToken` shape, the class-vs-instance meaning of `tokenType`, and the `getTypeDefinition()`-not-`getDefinition()` rule for resolving a display name across the registry's two id namespaces. - `docs/PAYMENTS-V2-DESIGN.md` — the facade surface, plus why `coinless` is computed at apply time (absent `assets` means tombstone OR coinless OR inherited), why the two reads are disjoint, and the two invariants that span functions: `applyOne`'s status comparison paired with `recoverOne`'s in-place flip, and the verdict being derived from wallet-api's §8.2 step-6 boundary. - `docs/MIGRATION-PAYMENTS-V2.md` — additive, nothing to migrate, but a "all my tokens" UI now needs both reads and a coinless arrival is named in `transfer:incoming.coinless`. - `CLAUDE.md` — surface, method table, `CoinlessToken`/`SphereToken` types, the event payload, and a Key Concepts section recording the vocabulary ("coinless", never "non-fungible"), the `value === null` ambiguity that `valueEnvelope` resolves, and the subset rule on the throw set. - `CHANGELOG.md` — Added/Fixed/Changed under Unreleased, including the internal port breaks (`Record*Input` taking an asset list; `SphereToken` gaining required fields). - Quickstarts and `docs/INTEGRATION.md` — a coinless read beside the existing `tokens()` example, so a token list is not read as complete. Every documented claim re-checked against the code: the `VALIDATION_ERROR` on an unheld token, the optional `IncomingTransfer.coinless`, `getTypeDefinition`'s existence, and that an EMPTY payload reads back empty rather than null. --- CHANGELOG.md | 64 +++++++++++++++++++++++++++++++++++ CLAUDE.md | 52 ++++++++++++++++++++++++++-- docs/API.md | 47 +++++++++++++++++++++++++ docs/INTEGRATION.md | 19 +++++++++++ docs/MIGRATION-PAYMENTS-V2.md | 25 ++++++++++++++ docs/PAYMENTS-V2-DESIGN.md | 26 ++++++++++++-- docs/QUICKSTART-BROWSER.md | 3 ++ docs/QUICKSTART-NODEJS.md | 3 ++ 8 files changed, 235 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9a0c3913..613319a4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,70 @@ 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. +Resolve display metadata with the new `TokenRegistry.getTypeDefinition()`, which reads 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 rejected or 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..00afab23 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,28 @@ 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 + 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 +733,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..5163d08a 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,52 @@ 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 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. Resolve a display name with +`TokenRegistry.getTypeDefinition(tokenType)` — **not** `getDefinition()`, which reads the coin-id +namespace. 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. + +### `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..dbfece24 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. + +Resolve an NFT's display metadata with `TokenRegistry.getTypeDefinition(tokenType)`, **not** +`getDefinition()`: one registry file holds two id namespaces discriminated by `assetKind`, and +`getDefinition` reads the coin one. An unrecognised type is legitimate — degrade, never hide. + ## 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); From 956ddc176ef88c782c5496aa75fbcf83fa4d1944 Mon Sep 17 00:00:00 2001 From: Pavel Grigorenko Date: Thu, 10 Sep 2026 15:04:42 +0200 Subject: [PATCH 7/8] fix: resolve coinless class metadata from the owned registry; stop the fake softening the classifier MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both from the automated review of `8e4a2bbf`. **The documented registry lookup reached the wrong registry.** `getTypeDefinition` was only usable through `TokenRegistry.getInstance()`, but a Sphere OWNS its registry (#767) and the process-global is separately configured — so a second Sphere on another network repoints it, and metadata for the first wallet's coinless tokens resolves to `undefined` or to the other network's colliding type. `_registry` is private with no accessor, so callers could not reach the right one at all. Resolved into the row instead, from the registry the facade already presents from: `CoinlessToken` carries `name`/`iconUrl` when the type is recognised, and callers never reach for a registry. `RegistryReader` gains an OPTIONAL `getTypeMeta` so the existing stubs keep compiling. An unrecognised type still renders — degraded, never hidden, since a minter may use its own. **`decodeFakeTokenAssets` swallowed a corrupt envelope.** Its catch wrapped the whole body, so a classification throw became `null` and `machine-harness`'s `?? []` indexed it as coinless. The fake is FakeWalletApi's §8.2 step-6 stand-in, so every payments-v2 test went on modelling the pre-#778 silent zero — the exact behaviour this branch exists to remove, preserved in the double that proves it. The catch now covers only "not fake-blob bytes at all"; a classification throw propagates, as the real backend's 422 does. Verified: full suite green; 2 new mutation probes; 6 new tests across the fake's classification (corrupt / valid / coinless / not-a-blob) and the owned-registry resolution (recognised and unrecognised types). --- modules/payments-v2/PaymentsFacade.ts | 2 +- .../payments-v2/inventory/InventoryView.ts | 5 +- modules/payments-v2/inventory/presentation.ts | 2 + registry/TokenRegistry.ts | 6 +++ tests/mutation/probes.json | 20 +++++++ tests/unit/payments-v2/inventory.test.ts | 52 +++++++++++++++---- .../unit/token-engine/FakeTokenEngine.test.ts | 48 ++++++++++++++++- tests/unit/token-engine/FakeTokenEngine.ts | 14 +++-- types/index.ts | 8 +++ 9 files changed, 138 insertions(+), 19 deletions(-) diff --git a/modules/payments-v2/PaymentsFacade.ts b/modules/payments-v2/PaymentsFacade.ts index 0eb5e66a..7f904184 100644 --- a/modules/payments-v2/PaymentsFacade.ts +++ b/modules/payments-v2/PaymentsFacade.ts @@ -226,7 +226,7 @@ export class PaymentsFacade implements PaymentsV2 { } coinless(): CoinlessToken[] { - return this.view.coinless(); + return this.view.coinless(this.deps.registry); } tokenData(tokenId: string): Promise { diff --git a/modules/payments-v2/inventory/InventoryView.ts b/modules/payments-v2/inventory/InventoryView.ts index af0a2480..914079d2 100644 --- a/modules/payments-v2/inventory/InventoryView.ts +++ b/modules/payments-v2/inventory/InventoryView.ts @@ -248,13 +248,16 @@ export class InventoryView { * 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(): CoinlessToken[] { + 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)) 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/registry/TokenRegistry.ts b/registry/TokenRegistry.ts index bd9e1e85..2d033cf1 100644 --- a/registry/TokenRegistry.ts +++ b/registry/TokenRegistry.ts @@ -692,6 +692,12 @@ export class TokenRegistry { * 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()); diff --git a/tests/mutation/probes.json b/tests/mutation/probes.json index ad342c50..b5c54f79 100644 --- a/tests/mutation/probes.json +++ b/tests/mutation/probes.json @@ -1406,5 +1406,25 @@ "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/inventory.test.ts b/tests/unit/payments-v2/inventory.test.ts index 5b7564b5..756f5d71 100644 --- a/tests/unit/payments-v2/inventory.test.ts +++ b/tests/unit/payments-v2/inventory.test.ts @@ -67,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( @@ -492,10 +497,12 @@ describe('InventoryView — coinless tokens (#777)', () => { page([], 5), ]); await view.fullPull(); - expect(view.coinless()).toEqual([ + 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), @@ -511,7 +518,7 @@ describe('InventoryView — coinless tokens (#777)', () => { ]); await view.fullPull(); expect(view.tokens(registry).map((t) => t.id)).toEqual(['A']); - expect(view.coinless().map((t) => t.tokenId)).toEqual(['N']); + 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 () => { @@ -527,7 +534,7 @@ describe('InventoryView — coinless tokens (#777)', () => { 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(); + const [row] = view.coinless(registry); expect(row?.tokenId).toBe('N'); expect(row?.tokenType).toBeUndefined(); }); @@ -538,7 +545,7 @@ describe('InventoryView — coinless tokens (#777)', () => { page([], 5), ]); await view.fullPull(); - expect(view.coinless()).toEqual([]); + expect(view.coinless(registry)).toEqual([]); }); it('carries a tokenType forward when a later delta omits it', async () => { @@ -549,19 +556,19 @@ describe('InventoryView — coinless tokens (#777)', () => { await view.fullPull(); queue.push(page([item('N', { seq: 2, state: 'S2', noAssets: true })], 6)); await view.delta(); - expect(view.coinless()[0]?.tokenType).toBe(NFT_TYPE); + 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()[0]?.tokenType).toBeUndefined(); + 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()[0]?.tokenType).toBe(NFT_TYPE); + 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 () => { @@ -576,7 +583,7 @@ describe('InventoryView — coinless tokens (#777)', () => { await view.delta(); expect(events.length).toBe(before); - expect(view.coinless()[0]?.tokenType).toBe(NFT_TYPE); + expect(view.coinless(registry)[0]?.tokenType).toBe(NFT_TYPE); }); it('a RECOVERED coin tombstone comes back as a coin, never as an NFT', async () => { @@ -598,7 +605,7 @@ describe('InventoryView — coinless tokens (#777)', () => { ); expect(recovered.recovered).toEqual(['C']); - expect(view.coinless()).toEqual([]); + expect(view.coinless(registry)).toEqual([]); expect(view.tokens(registry).map((t) => t.id)).toEqual(['C']); expect(view.pool(COIN)).toEqual([{ tokenId: 'C', amount: 100n }]); }); @@ -619,10 +626,33 @@ describe('InventoryView — coinless tokens (#777)', () => { ); await view.fullPull(); - expect(view.coinless().map((t) => t.tokenId)).toEqual(['N']); + 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), @@ -631,7 +661,7 @@ describe('InventoryView — coinless tokens (#777)', () => { await view.fullPull(); queue.push(page([item('N', { seq: 2, state: 'S2', amount: '5' })], 6)); await view.delta(); - expect(view.coinless()).toEqual([]); + expect(view.coinless(registry)).toEqual([]); expect(view.tokens(registry).map((t) => t.id)).toEqual(['N']); }); }); 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 88129eff..8d61ee97 100644 --- a/tests/unit/token-engine/FakeTokenEngine.ts +++ b/tests/unit/token-engine/FakeTokenEngine.ts @@ -282,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 || classify(state).envelope !== 'sphere') 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 })); } /** diff --git a/types/index.ts b/types/index.ts index 4e3244aa..01287404 100644 --- a/types/index.ts +++ b/types/index.ts @@ -95,6 +95,14 @@ export interface Token { 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; From d3be50293ea378c8c622272f0a0f9fa9c40fc438 Mon Sep 17 00:00:00 2001 From: Pavel Grigorenko Date: Thu, 10 Sep 2026 15:05:45 +0200 Subject: [PATCH 8/8] docs: correct the coinless registry advice MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The reference told consumers to resolve a token type through `TokenRegistry.getInstance()`. That is the process-global registry, which another Sphere's init repoints — so a second wallet on another network retargets it, and the advice sends callers to a registry that is not the one their facade reads. `name`/`iconUrl` now arrive resolved on the `CoinlessToken` row, from the registry the Sphere owns, so there is nothing for a caller to look up. Documented that way across API.md, CLAUDE.md, MIGRATION-PAYMENTS-V2.md and the CHANGELOG. --- CHANGELOG.md | 10 ++++++---- CLAUDE.md | 2 ++ docs/API.md | 13 +++++++++---- docs/MIGRATION-PAYMENTS-V2.md | 6 +++--- 4 files changed, 20 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 613319a4..0fdd7822 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,10 +24,12 @@ consumers sum or format. (#781 proposed widening `tokens()`; the divergence is r 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. -Resolve display metadata with the new `TokenRegistry.getTypeDefinition()`, which reads 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 rejected or hidden. +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 diff --git a/CLAUDE.md b/CLAUDE.md index 00afab23..2c523284 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -575,6 +575,8 @@ interface TokenBlob { 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; diff --git a/docs/API.md b/docs/API.md index 5163d08a..9d4c7423 100644 --- a/docs/API.md +++ b/docs/API.md @@ -422,6 +422,8 @@ and filling those with `''`/`'0'` would put untrue values in fields consumers su 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; @@ -435,10 +437,13 @@ 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. Resolve a display name with -`TokenRegistry.getTypeDefinition(tokenType)` — **not** `getDefinition()`, which reads the coin-id -namespace. 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. +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` diff --git a/docs/MIGRATION-PAYMENTS-V2.md b/docs/MIGRATION-PAYMENTS-V2.md index dbfece24..b455d733 100644 --- a/docs/MIGRATION-PAYMENTS-V2.md +++ b/docs/MIGRATION-PAYMENTS-V2.md @@ -112,9 +112,9 @@ of one kind shares it), `stateHash`, `transferring`, `suspectedSpent` and timest `transfer:incoming` gains an optional `coinless` array. If you render arrivals from `tokens`, a coinless arrival will look empty — read `coinless` too. -Resolve an NFT's display metadata with `TokenRegistry.getTypeDefinition(tokenType)`, **not** -`getDefinition()`: one registry file holds two id namespaces discriminated by `assetKind`, and -`getDefinition` reads the coin one. An unrecognised type is legitimate — degrade, never hide. +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