feat: coinless tokens — classify, hold, show and record them - #779
Conversation
… 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.
There was a problem hiding this comment.
💡 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 { |
There was a problem hiding this comment.
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.
|
@codex review This PR grew substantially since it was opened as a #778-only change. It now folds in #777, #780 Worth focusing on, since these are where the risk sits:
Three earlier local Verification on the current head: 2274 unit/integration tests, 140/140 mutation probes killed, and |
|
Codex Review: Something went wrong. Try again later by commenting “@codex review”. ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
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.
|
@codex review Re-triggering: the earlier automated review targeted
The four areas worth the attention are in my earlier comment; the load-bearing one is that |
|
Codex Review: Something went wrong. Try again later by commenting “@codex review”. ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
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". |
|
@codex review |
There was a problem hiding this comment.
💡 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".
| getTypeDefinition(tokenType: string): TokenDefinition | undefined { | ||
| if (!tokenType) return undefined; | ||
| return this.definitionsByType.get(tokenType.toLowerCase()); |
There was a problem hiding this comment.
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.
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 iswhether 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)
isSpherePaymentDatawastry { decodeTag(d).tag === CBOR_TAG } catch { return false }, and bothcallers read
falseas "data token, no value".decodeTagparses the tagged body and assertsexhaustion, so it answered "untagged" for far more than a wrong tag:
SpherePaymentData+ one trailing bytevalue = nullSpherePaymentData, truncatedvalue = nullda 00 00 98 8aIS tag 39050 in a 4-byte head whereCborReaderdemands the 2-byte onevalue = nulltag(55799)<valid envelope>— RFC 8949 §3.4.6, semantically transparent, so it means the envelopevalue = nullnulldata)value = nullEach 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 theouter major type and the tag head alone (
CborReader.readLength, newly re-exported fromsdk.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 terminalrejectAck('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.valueEnveloperecords which reasonvalueis null.none_*is genuinely coinless;bare_collectionis the bridged dialect wallet-api decodes and this SDK does not, so a zero theremeans 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.splitis handeddecodeSpherePaymentData, so such a source previously died inside the SDK with a bareCborError: Major type mismatchnaming neither token nor cause. A coinless token cannot besplit by construction, so this can never refuse a legitimate split.
mintDataToken()refuses opaque bytes classification cannot frame.wrapTokenruns on thatmethod'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)
#781 proposed widening
tokens(); this takes the other branch, deliberately.TokenrequirescoinId,symbol,decimalsandamount; a coinless token has none of them, and sentinel-fillingputs 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.coinIdquestion answers itself: a coinless token isnot in
tokens()at all. It is visible, which is what #781 asks for; it is visible in its own read.tokenDatais a call, not a field: the genesis payload IS the NFT's content, blobs are lazy underserver custody, and a payload is unbounded, so a list read must never carry it.
Consumed from the backend:
tokenTypeonInventoryItemWire/InventoryItem(it already arrivedat runtime — the inventory response is
JSON.parsed and type-asserted, never schema-validated, sothis is a declaration, not transport work).
MirrorEntry.coinlessis computed once at applytime, where
statusis in hand: absentassetsmeans two different things, since a tombstoneomits them for an unrelated reason and a delta that omits them inherits the previous entry's
(which
recoverRemoveddepends on). Only an active row's absence states coinlessness.Per #147,
tokenTypenames the token's class, never the instance — two NFTs of one collectionshare a type — so it is a display hint, never an identity or a spend gate.
transfer:incomingnow names an arriving coinless token in a disjointcoinlessfield; itpreviously mapped over assets, so such an arrival announced
tokens: []and a UI listening forarrivals saw nothing land.
TokenRegistry.getTypeDefinition()resolves a coinless token's class. One registry file carriestwo id namespaces discriminated by
assetKind— afungibleentry's id is a coin id, anon-fungibleentry's is a token type — and the flatdefinitionsByIdmap cannot tell them apart.The new lookup is namespace-correct.
getDefinitionis left resolving either, because a test pinsthat; changing it is not this PR's business.
3. History can record a coinless movement (#780)
recordSentandrecordMinttookcoinId/amountscalars and wrapped them unconditionally, sothe only expressible shape was a one-element array —
[{coinId: '', amount: '0'}]for a coinlesstoken, 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.postlogged every failure as "retry safe". A 4xx is not — it is the server refusing thisrecord'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.tsmints a REAL testnet2-certified coinless token withthe 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-identicalto what was minted, after CBOR encode → SHA-256 content addressing → S3 → presigned GET → decode.
The stored blob is the whole
TokenCBOR rather than the payload, so atokenDatathat returned theblob, 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 thatmade 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
mintDataTokentypesdataas required, so a genuinely absentpayload is not expressible through this API.
Out of CI by construction rather than convention:
vitest.config.tsexcludestests/e2e/**sotest:rundoes not collect it, whiletypecheck:testsstill covers it so it cannot rot silently.Dormant without
STAGING_AGGREGATOR_KEY.Verification
npm run test:run— 2274 tests / 131 files green.npm run test:e2e(this leg) — 3/3 green against wallet-api staging f8e64bc on testnet2.npm run test:mutation— 140/140 KILLED, including 17 new probes. The pre-existing 121 allstill 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 inSphereTokenEngine.tspredate this branch),build.codex exec reviewon each commit. Three findings, all fixed and pinned by tests + probes:the
mintDataTokenpost-certification stranding (P1); the receive path keying onvalue === null, which would announce a bridged arrival as an NFT and hide its coins (P1);and a
tokenTypearriving late at an otherwise unchanged mirror row (P2).found a live bug:
recoverOneflipsstatusin place without recomputingcoinless, so arecovered coin tombstone would have appeared in
tokens()andcoinless()at once.Not covered: no
tests/aggregator/leg for the classifier itself. It is pure byte-level logicwith 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 durableintent, and the Connect
send_nftintent with its ownnft:transferscope. Built in #782,stacked on this one; deliberately separate because it changes the money path, where this PR does not.