Skip to content

feat(payments-v2): sendWholeToken — move any token whole, coins included - #783

Merged
MastaP merged 10 commits into
mainfrom
feat/whole-token-send
Sep 11, 2026
Merged

MastaP merged 10 commits into
mainfrom
feat/whole-token-send

Conversation

@MastaP

@MastaP MastaP commented Sep 11, 2026

Copy link
Copy Markdown
Contributor

Unblocks unicity-sphere/sphere#502's per-row send button.

A wallet UI wants a send button on every token row that moves that token — not an amount the
selector satisfies from wherever it likes. sendCoinless (0.17.0) already did this for a token that
names no coin. The same machinery moves a valued token whole.

Generalised, not duplicated

send() is untouched: it still selects sources to cover an amount and may split. A whole spend
differs from it in exactly two places — the gate and the history row — so those are the only
things that changed. The machine's direct branch was already value-agnostic
(engine.transfer({ token, recipientPubkey }): no coin, no amount).

sendWholeToken(req)   any token; needs coin-transfer authority
sendCoinless(req)     refuses a valued source; where Connect's send_nft routes

Both run the SAME implementation, differing only by a REQUIRED requireCoinless field on the spend
input — required, not optional, so a new call site cannot forget which authority it is exercising.

Why two. Review caught that collapsing them was a privilege escalation: send_nft carries
nft:transfer, which connect/permissions.ts deliberately separates from transfer:request and
which a test asserts does not authorise a coin send. A wallet host updating its send_nft handler
to a single generalised verb would have let a dApp holding only that scope move a valued token's
coins.

Internals renamed with it: planCoinlessplanWhole, materializeCoinlessSpend
materializeWholeSpend, CoinlessIntentPayloadWholeIntentPayload,
spendableCoinlessspendableToken, kind: 'coinless'kind: 'whole'.

The one refusal that survives

bare_collection. A whole spend moving coins is now the point, so a valued source is fine — but
that dialect carries coins this SDK cannot decode, so the move would be real while the history row
could only say assets: []: value gone, nothing accounted. The decoded BLOB decides, never the
mirror.

History records what actually moved

settledAmount sums a single coin and cannot serve a whole spend, so settledAssets sums every
asset across the certified recipient blobs. A coinless token still records assets: []; a valued one
records its real coins. Pinned by a probe — mutating it to [] logs a real coin movement as carrying
nothing.

Compatibility

kind: 'coinless' stays readable forever: 0.17.0 published it, it is durable server state, and a
client that wrote one must still be able to resume it. Only the written spelling changed.

Version. sendCoinless is kept, with its original semantics, so nothing 0.17.0 published is
removed — 0.17.1 is honest. It is no longer a deprecated alias but a real verb with a distinct
authority (below).

Verification

  • npm run test:run2312 tests / 132 files green.
  • npm run test:mutation — full gate running; 4 probes retargeted or added, all killing individually
    (the whole-send history accounting, the bare_collection refusal, spendableToken's eligibility
    gates, and planWhole's refusal of an unspendable source).
  • typecheck, typecheck:tests, lint, build.

Three probes needed chasing and each was a real gap rather than bookkeeping: one survived because the
new tests sat at the wrong layer, one went stale from the rename, and one had its tests list
pointing at a file that could not fail it.

Staging e2e — 6/6 green on testnet2

Both kinds now go A→B→A. One-way delivery only proves the token arrived; the return leg proves
the RECEIVED token is a spendable source, and for the valued one that its coins survived intact.

  • coinless A→B→A — same genesis id after the round trip, tokenData() still byte-identical
  • valued A→B→A — 250 travels whole to B, then back; serverTotal confirms 250 at A, 0 at B
  • the NFT-scoped verb still refuses a valued token, against the real backend

Test-suite stability

clear() on another storage leaves a live Sphere on a different one fully alive was timing out at
the 10s default while taking 14.3s under full-suite parallelism (≈6s isolated). This branch's added
tests raised the load until an already-marginal case tipped over — my cause, not a pre-existing
flake. Those cases do real BIP39 + Argon2 work before any assertion runs, so 10s was never realistic
under contention; each it in that file now carries 60s, assertions untouched. Confirmed by three
consecutive full runs, 2314/2314 each.

…uded

`sendCoinless` moved a token that named no coin. The same machinery moves a VALUED
token whole — one named source, one direct transfer, never a split — and a wallet
UI wants exactly that: a send button on every row that moves THAT token, not an
amount the selector satisfies from wherever it likes.

Generalised rather than duplicated. `send()` is unchanged: it still selects sources
to cover an amount and may split. What differs for a whole spend is only the gate
and the history row, so those are the only things that changed.

  sendCoinless            -> sendWholeToken
  SendCoinlessRequest     -> SendWholeTokenRequest
  planCoinless            -> planWhole
  materializeCoinlessSpend-> materializeWholeSpend
  CoinlessIntentPayload   -> WholeIntentPayload
  spendableCoinless       -> spendableToken   (drops the coinless-only gate)
  kind: 'coinless'        -> kind: 'whole'

**The one refusal left is `bare_collection`.** A whole spend moves the token as it
is, so coins travelling with it is the point — but that dialect carries coins this
SDK cannot decode, so the move would be real while the history row could only say
`assets: []`: value gone, nothing accounted. The decoded BLOB decides, never the
mirror.

**History records what actually moved.** `settledAssets` sums every asset across the
certified recipient blobs — the whole-spend twin of `settledAmount`, which cannot
serve here because there is no single coin to sum. A coinless token still records
`assets: []`; a valued one records its real coins.

`kind: 'coinless'` stays READABLE forever: 0.17.0 published it, it is durable server
state, and a client that wrote one must still be able to resume it. Only the written
spelling changed.

Consumer gate: no sibling repo uses `sendCoinless` — sphere still pins 0.16.0 — so
this generalises a verb nothing consumes yet rather than breaking one.

Verified: 2307 tests green; typecheck, typecheck:tests, lint clean.

Refs unicity-sphere/sphere#502.

@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: 67d523f3af

ℹ️ 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 modules/payments-v2/send-whole.ts
Comment thread modules/payments-v2/convergence.ts Outdated
Comment thread tests/e2e/coinless-tokens.staging.e2e.test.ts Outdated
Comment thread docs/API.md
CI-red. The whole-token change left `isCoinlessEnvelope` imported but unused —
its call site became a `bare_collection` check — and the note about the 0.17.0
`kind` spelling pushed the comment block past the five-line budget.

I pushed without reading the lint output: it was inside a backgrounded command
whose output I grepped only for the test count.
**P1 — privilege escalation I introduced.** Generalising `sendCoinless` into
`sendWholeToken` broke the boundary the Connect rename exists to draw: `send_nft`
carries `nft:transfer`, which `connect/permissions.ts` deliberately separates from
`transfer:request` and which a test asserts does NOT authorise a coin send. A
wallet host updating its `send_nft` handler to the replacement would have let a
dApp holding only that scope move a valued token's coins.

Two entry points over ONE implementation, which is also what the reuse was for:

  sendWholeToken(req)  — any token; needs coin-transfer authority
  sendCoinless(req)    — refuses a valued source; where send_nft routes

`requireCoinless` is a REQUIRED field on the spend input, not an optional flag, so
a new call site cannot forget which authority it is exercising.

Three P2s, all correct:

- `pendingTransfers()` reads the RAW decrypted payload and never passes through
  `validatePayload`, so a 0.17-written `kind: 'coinless'` intent rendered as an
  empty coin row with no tokenId. It now recognises both spellings.
- The staging e2e still asserted the old refusal of a valued token, so it would
  have gone red the next time staging ran. Replaced with a real valued A→B whole
  send — B receives the whole 250, A drains to 0, no split — plus a case pinning
  that the NFT-scoped verb still refuses.
- `docs/API.md` still described the verb as coinless-only and told readers to use
  `send()` for valued tokens, contradicting the implementation. Rewritten, with
  CLAUDE.md and the CHANGELOG.

Verified: 2314 tests green; typecheck, typecheck:tests, lint clean. New probe on
the boundary itself.
…t ratios

CI-red for the third time on the same three violations, which I had reported as
clean each time.

The reason: `npm run lint` ends with two lines —

  ✖ 29 problems (3 errors, 26 warnings)
    0 errors and 1 warning potentially fixable with the `--fix` option.

I was reading the second line and taking "0 errors" to mean none existed. It means
none are AUTO-FIXABLE. The error count is on the ✖ line.

- `PaymentsFacade.ts` 814 -> 800: folded three one-line wrappers into their callers
  and dropped `sha256`/`bytesToHex`, orphaned when the mint path moved to mint.ts.
- `api.ts` and `send-whole.ts`: comment ratio, from the prose added with the NFT
  permission boundary.

Verified from the ✖ summary line: 0 errors. 2314 tests, typecheck, typecheck:tests,
build all green.
The valued whole-send leg was pushed without ever being executed — typechecked
only. It failed on first run: I redeclared HARNESS_COIN as 'a'.repeat(64) instead
of importing the canonical '11'.repeat(32) from support/staging, so the test minted
one coin and `localTotal` summed another. Always 0, hence a 126s timeout.

The feature was correct the whole time: the send delivered, B's row carried the
250, A drained to empty. Only the assertion was wrong.

Verified: 6/6 green against wallet-api staging on testnet2, including the valued
A→B whole send.
…l-suite load

`clear() on another storage leaves a live Sphere on a different one fully alive`
timed out at the 10s default while taking 14.3s under full-suite parallelism. It
passes in ~6s isolated, so the assertion was never wrong — the budget was.

My cause, not a pre-existing flake: this branch added tests and suites, raising
parallel load until an already-marginal case tipped over. Calling it pre-existing
because main showed it too was categorising instead of fixing.

These do real BIP39 + Argon2 work per case — several seconds each before any
assertion runs — so 10s was never realistic for them under contention. Each `it` in
the file now carries 60s. The assertions are untouched; only the time budget moved.

Verified: three consecutive full runs, 2314/2314 each.

Also in this commit: the staging suite gains A→B→A round trips for both a coinless
and a valued whole send, at the owner's request. One-way delivery never proves the
RECEIVED token is a spendable source — only the return leg does, and for the valued
one it proves the coins survived intact. The NFT-refusal case now reopens the wallet
first, so the refusal comes from the intended gate rather than from "not a spendable
holding" on a mirror that had not yet synced.

Staging: 6/6 green on testnet2, round trips included.
@MastaP

MastaP commented Sep 11, 2026

Copy link
Copy Markdown
Contributor Author

@codex review

Head is 31dd129f. All four of your earlier findings are fixed and their threads resolved.

Your P1 changed the design, correctly. Collapsing sendCoinless into one generalised verb was a
privilege escalation: send_nft carries nft:transfer, which is deliberately separate from
transfer:request. There are now two entry points over ONE implementation:

sendWholeToken(req)   any token; needs coin-transfer authority
sendCoinless(req)     refuses a valued source; where send_nft routes

They differ only by a required requireCoinless field on the spend input — required rather than
optional so a new call site cannot forget which authority it exercises. This also keeps
sendCoinless intact, so nothing 0.17.0 published is removed and 0.17.1 is an honest patch.

The other three: pendingTransfers() now recognises 0.17's legacy kind: 'coinless' spelling (it
reads the raw payload and never passes through validatePayload); the staging leg that asserted the
old refusal was replaced with a real valued send; and docs/API.md, CLAUDE.md and the CHANGELOG no
longer claim valued tokens are refused.

Worth your attention on this pass:

  • send-whole.ts — two gates now: bare_collection is refused for everyone (real coins this SDK
    cannot decode, so the move would happen while history could only say assets: []), and
    requireCoinless refuses a valued source on the NFT-scoped path.
  • TransferMachine.settledAssets — a whole spend has no single coin to sum, so history records
    every asset across the certified recipient blobs. A coinless token still logs assets: [].
  • machine/resume.ts — accepts both 'whole' and 0.17's 'coinless', since that is durable
    server state a client may already have written.

Verification on this head: 2314 tests, three consecutive full runs to confirm stability after a
timeout fix; typecheck, typecheck:tests, lint (0 errors on the ✖ line), build; staging e2e 6/6 on
testnet2 including A→B→A round trips for both a coinless and a valued whole send
. CI build green on
Node 22 and 24; test-mutation still running there, and I have not re-run the full local gate since
the last few commits — individual probes for each new guard kill.

…moved probes

CI's mutation gate caught a defect three of my own edits had conspired to create,
and that every green local run had missed.

`Receive.ts` still carried its OWN `isCoinlessEnvelope`, shadowing the shared one.
The sequence: the "hoist to one definition" edit added the import but its deletion
of the local copy silently did not match; a later lint pass then flagged the now
-unused import and I removed it — restoring the duplicate and severing receive from
the shared definition entirely. So mutating the real one changed nothing there, and
the probe survived. Exactly the drift the hoist was meant to prevent.

Two probes went STALE where the refactor moved their targets: the terminal-conflict
rule now reads `job.kind === 'whole'`, and the kind check admits 0.17's legacy
spelling. Updated with the refactor, never dropped.

This is why the gate is a merge gate: three passes of local green, a review, and a
staging suite all missed a guard that had quietly stopped guarding anything.

@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: 31dd129fe7

ℹ️ 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 modules/payments-v2/index.ts Outdated
Comment thread modules/payments-v2/machine/types.ts Outdated
Comment thread CHANGELOG.md Outdated
…eadable

0.17.0 shipped `SendCoinlessRequest` and wrote transfer intents with
`kind: 'coinless'`. Renaming both to `whole` in 0.17.1 broke two things a
patch release must not break.

`SendCoinlessRequest` is restored as an alias of `SendWholeTokenRequest`
and re-exported from the barrel, so an import written against 0.17.0 still
compiles.

`WholeIntentPayload.kind` widens back to `'whole' | 'coinless'`: the
payload is DURABLE SERVER STATE, and a 0.17.0 client may have left one
open. New payloads write `'whole'`; `'coinless'` is accepted on read and
normalized at validation. `isWholeIntent` is the single discriminator —
`compose.ts` and `resume.ts` now route through it rather than comparing
the string, so neither spelling can fall into the coin branch and lose the
envelope guard or record a history row with an undefined coinId.

Verified: an intent written as `kind: 'coinless'` resumes, and one naming a
`bare_collection` source is still refused (machine-resume.test.ts); a probe
pins the accept list, since dropping the legacy spelling would strand those
intents open forever with their sources reserved and no error surface.
The 0.17.0 publish left its entries under `[Unreleased]`, so the CHANGELOG
named no version for what shipped and none for 0.17.1. Stamped
`[0.17.0] - 2026-09-11` over the shipped sections and added a `[0.17.1]`
section covering the `sendWholeToken` rename, what stays compiling, and the
shared-classifier fix. Both link references added.

`receive-conflates-unreadable-with-coinless` went STALE when Receive.ts
stopped carrying its own copy of `isCoinlessEnvelope`: its `find` pinned
the deleted body, which is now the same line the classifier's own probe
guards. Repointed at the RECEIVE-path decision — the site that chooses
whether an arrival is announced as coinless — so the two probes guard
distinct code again. Both KILLED.

CLAUDE.md's intent-kind bullet still said `'coin' | 'coinless'`; it now
reads `'coin' | 'whole'` and records that the 0.17.0 spelling stays
read-accepted and that `isWholeIntent()` is the only discriminator.
A whole spend carries the source's coins along, but `materializeWholeSpend`
always returned `sourceTokens: []`, so `finishSend` produced a
`TransferResult` whose `tokens` was empty even when the token held real
value. The history row the same operation writes recorded the true assets,
so the two disagreed: every caller and every `transfer:updated` consumer
was told a send that moved coins moved nothing.

`sourceTokens` is now one `transferringToken` row per asset the named token
carries — the same helper and the same shape `send()` reports. A coinless
token still reports none: it has no amount in flight.

Verified: facade.test.ts asserts the result's rows match the history row for
a valued whole send, and asserts an empty list for a coinless one; a probe
pins the mapping, since returning [] is precisely the pre-fix behaviour.
@MastaP
MastaP merged commit a02d356 into main Sep 11, 2026
3 checks passed
@MastaP
MastaP deleted the feat/whole-token-send branch September 11, 2026 16:15
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant