Skip to content

feat: coinless tokens — classify, hold, show and record them - #779

Merged
MastaP merged 8 commits into
mainfrom
feat/coinless-tokens-777-778
Sep 11, 2026
Merged

feat: coinless tokens — classify, hold, show and record them#779
MastaP merged 8 commits into
mainfrom
feat/coinless-tokens-777-778

Conversation

@MastaP

@MastaP MastaP commented Sep 9, 2026

Copy link
Copy Markdown
Contributor

Closes #778. Closes #777. Closes #780. Closes #781.

Coinless tokens, end to end: decode them without lying about their value, hold and show them,
and record what happened to them. Four issues, one wire story — wallet-api#140/#141 made a
coinless token real, #142/#151 made its history line recordable, and #147 corrected what its
type means. This is the client side of all of it.

Vocabulary, per wallet-api#147: "coinless", not "non-fungible". 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; the property needing a word is the absence of a value envelope.


1. 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". decodeTag parses the tagged body and asserts
exhaustion, so it answered "untagged" for far more than a wrong tag:

genesis data before now
valid SpherePaymentData + one trailing byte value = null throws, naming the codec
valid SpherePaymentData, truncated value = null throws
non-canonical tag headda 00 00 98 8a IS tag 39050 in a 4-byte head where CborReader demands the 2-byte one value = null throws
tag(55799)<valid envelope> — RFC 8949 §3.4.6, semantically transparent, so it means the envelope value = null throws
genuinely not a value envelope (app metadata, null data) value = null unchanged, no error

Each of the first four carries real, readable coins and rendered as zero, 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, ported from wallet-api's §8.2 step 6. It reads the
outer major type and the tag head alone (CborReader.readLength, newly re-exported from
sdk.ts — the only file the ESLint boundary lets import the base SDK), never "the decode threw,
so it must be valueless".

The invariant is a SUBSET, not equality. 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.
Accepting where it throws is inert: such a token could never be custodied.

SphereToken.valueEnvelope records which reason value is null. none_* is genuinely coinless;
bare_collection is the bridged dialect wallet-api decodes and this SDK does not, so a zero there
means cannot read, never carries none. Classified deliberately, not decoded — widening
acceptance has its own accounting consequences and is tracked separately.

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. A coinless token cannot be
    split by construction, so this can never refuse a legitimate split.
  • mintDataToken() refuses opaque bytes classification cannot frame. wrapToken runs on that
    method's last line, after certification — so without a 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 same escape hatch wallet-api documents.

2. Coinless tokens are represented and exposed (#777, #781)

payments.coinless(): CoinlessToken[]
payments.tokenData(tokenId): Promise<Uint8Array | null>

#781 proposed widening tokens(); this takes the other branch, deliberately. Token requires
coinId, symbol, decimals and amount; a coinless token has none of them, and sentinel-filling
puts untrue values in money-shaped fields 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, and #781's filter.coinId question answers itself: a coinless token is
not in tokens() at all. It is visible, which is what #781 asks for; it is visible in its own read.

tokenData is a call, not a field: the genesis payload IS the NFT's content, blobs are lazy under
server custody, and a payload is unbounded, so a list read must never carry it.

Consumed from the backend: tokenType on InventoryItemWire / InventoryItem (it already arrived
at runtime — the inventory response is JSON.parsed 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, since 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.

Per #147, tokenType names the token's class, never the instance — two NFTs of one collection
share a type — so it is a display hint, 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
arrivals saw nothing land.

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; changing it is not this PR's business.

3. History can record a coinless movement (#780)

recordSent and recordMint took coinId/amount scalars and wrapped them unconditionally, so
the only expressible shape was a one-element array — [{coinId: '', amount: '0'}] for a coinless
token, which wallet-api deliberately keeps refusing (accepting it would create two wire spellings
of "no coin"). All three inputs now take the asset list directly, so absence propagates.

History.post logged every failure as "retry safe". A 4xx is not — it is the server refusing this
record's shape, permanently — and that indiscriminate swallow is exactly what let a refused receipt
vanish with no error surface. The two are now named apart (408/429 stay transient). It still never
throws: §5.9 keeps history off the money path.


4. Proven against live staging, not against a fake

tests/e2e/coinless-tokens.staging.e2e.test.ts mints a REAL testnet2-certified coinless token with
the registry's canonical non-fungible type, indexes it through deployed staging, and reads it back
through the presigned GET. Every other coinless test here runs against a fake engine that
round-trips whatever it was handed, so wrong CBOR passes them unnoticed.

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 is in coinless()
with its type, never in tokens(), and moves no balance — the disjointness contract on a token that
made a real round trip rather than a fixture.

It also pinned a distinction worth not rediscovering: an empty payload reads back empty, 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.

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.

Verification

  • npm run test:run2274 tests / 131 files green.
  • npm run test:e2e (this leg) — 3/3 green against wallet-api staging f8e64bc on testnet2.
  • npm run test:mutation140/140 KILLED, including 17 new probes. The pre-existing 121 all
    still die, so none of the new guards shadows an older one. One probe went STALE when a refactor
    moved its target line; the runner flagged it rather than passing silently, and it was updated to
    the new code rather than dropped.
  • typecheck, typecheck:tests, lint (0 errors; the 2 remaining warnings in
    SphereTokenEngine.ts predate this branch), build.
  • codex exec review on each commit. Three findings, all fixed and pinned by tests + probes:
    the mintDataToken post-certification stranding (P1); the receive path keying on
    value === null, which would announce a bridged arrival as an NFT and hide its coins (P1);
    and a tokenType arriving late at an otherwise unchanged mirror row (P2).
  • Two probes survived on the first run and were real gaps, not bad probes. Fixing the second
    found a live bug: recoverOne flips status in place without recomputing coinless, so a
    recovered coin tombstone would have appeared in tokens() and coinless() at once.

Not covered: no tests/aggregator/ leg for the classifier itself. It is pure byte-level logic
with no aggregator contract, and the corrupt inputs it exists for cannot be produced by a real mint.

Still open from #777: transferring a coinless token — sendCoinless, the discriminated durable
intent, and the Connect send_nft intent with its own nft:transfer scope. Built in #782,
stacked on this one; deliberately separate because it changes the money path, where this PR does not.

… as a valueless token

`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)<valid envelope>` — 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.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 3686790eea

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

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 {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Propagate corrupt-envelope failures from the fake decoder

When classify(state) throws for a malformed tag-39050 envelope, this broad catch still converts the failure to null; fakeDecodeBlobFor then converts that to an empty asset list with decodeFakeTokenAssets(bytes) ?? []. Consequently, payments-v2 integration tests continue modeling the original silent-zero behavior and let the fake wallet API accept corrupt value tokens instead of rejecting them, so they cannot exercise the server/client rejection invariant this change is intended to establish.

Useful? React with 👍 / 👎.

`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<Uint8Array | null>

`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.
…ermanent reject says so

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.
…oinless verdict

`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.
…fake

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.
@MastaP MastaP changed the title fix(token-engine): a corrupt value envelope throws instead of reading as a valueless token feat: coinless tokens — classify, hold, show and record them Sep 9, 2026
@MastaP

MastaP commented Sep 9, 2026

Copy link
Copy Markdown
Contributor Author

@codex review

This PR grew substantially since it was opened as a #778-only change. It now folds in #777, #780
and #781 — all the coinless-token work, since #778's valueEnvelope is what the others derive
from and splitting them made the dependency chain span three PRs.

Worth focusing on, since these are where the risk sits:

  • token-engine/value-envelope.ts — the classifier's throw set must stay a subset of
    wallet-api's §8.2 step-6 422 set. Throwing where wallet-api accepts is a lost token:
    Receive.screen() turns a decode throw into a terminal rejectAck('invalid') plus a durable
    seen-set write, and everything arriving over the mailbox already passed §8.2 at deposit.
  • The coinless/bare_collection distinction. value === null is true for both a token that
    names no coin and one carrying coins in a dialect this SDK cannot decode. Anywhere that
    conflates them either hides real coins or invents a phantom NFT — a local review already caught
    one such site in the receive path.
  • InventoryView.applyOne — an invariant spans two functions: the unchanged-row early return
    compares status, and recoverOne flips status in place without recomputing coinless. They
    are correct only together, and neither file says so.
  • History.post's reject classification — a 4xx is a permanent shape refusal, 408/429 are not.
    Getting this backwards restores the silent-loss behaviour history: a coinless receipt posts [{coinId:"", amount:"0"}] and is silently dropped #780 is about.

Three earlier local codex exec review findings are fixed and pinned by tests plus mutation
probes: the mintDataToken post-certification stranding, the receive-path conflation above, and a
tokenType arriving late at an otherwise unchanged mirror row. The most recent
codex exec review --base main came back clean, so this is a second pair of eyes on the assembled
branch rather than a re-run of the same pass.

Verification on the current head: 2274 unit/integration tests, 140/140 mutation probes killed, and
the new staging leg 3/3 green against wallet-api f8e64bc on testnet2.

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Something went wrong. Try again later by commenting “@codex review”.

Provided git ref 539ea7e843ec59da43e2530ce553001d769d00f9 does not exist
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

…ckstarts

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.
@MastaP

MastaP commented Sep 9, 2026

Copy link
Copy Markdown
Contributor Author

@codex review

Re-triggering: the earlier automated review targeted 3686790e, which was this PR's original
#778-only state. The branch has since grown four more commits and now closes #777, #780 and #781 as
well. Current head is 8e4a2bbf:

  • 6abe50b7 — the coinless read surface (coinless(), tokenData(), registry type lookup)
  • 77e42c85 — history: empty asset lists on every type, permanent-vs-transient reject
  • 930528b7 — the restore re-pull invariant test
  • 539ea7e8 — the live staging e2e leg
  • 8e4a2bbf — documentation

The four areas worth the attention are in my earlier comment; the load-bearing one is that
value === null is true both for a token that names no coin and for one carrying coins in a
dialect this SDK cannot decode, so anything keying on it rather than on valueEnvelope either
hides real coins or invents a phantom NFT.

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Something went wrong. Try again later by commenting “@codex review”.

Provided git ref 8e4a2bbfec7208baa22419a169d32e20462bd699 does not exist
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@MastaP

MastaP commented Sep 10, 2026

Copy link
Copy Markdown
Contributor Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 8e4a2bbfec

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread registry/TokenRegistry.ts
Comment on lines +695 to +697
getTypeDefinition(tokenType: string): TokenDefinition | undefined {
if (!tokenType) return undefined;
return this.definitionsByType.get(tokenType.toLowerCase());

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Resolve token types with the owning Sphere registry

When two Sphere instances use different networks, the documented TokenRegistry.getInstance().getTypeDefinition(nft.tokenType) lookup is retargeted by whichever Sphere initializes last (Sphere.configureTokenRegistry), while each payments facade deliberately uses its own private registry (core/Sphere.ts:863-865, 4315-4318). Because callers cannot access that owned registry, metadata for the earlier Sphere's coinless tokens can resolve to undefined or to the other network's colliding type; expose an instance-scoped type resolver through the facade/Sphere instead of making this new lookup usable only through the process-global registry.

Useful? React with 👍 / 👎.

…e fake softening the classifier

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).
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.
@MastaP
MastaP merged commit 218c672 into main Sep 11, 2026
3 checks passed
@MastaP
MastaP deleted the feat/coinless-tokens-777-778 branch September 11, 2026 10:02
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment