From 54f6be3f5c232295a3a18b88768f70e2ed1bb5fd Mon Sep 17 00:00:00 2001 From: Pavel Grigorenko Date: Thu, 10 Sep 2026 12:27:15 +0200 Subject: [PATCH 01/12] feat(payments-v2): discriminate the durable intent, and teach resume to read both shapes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reader before writer: nothing in this commit writes a token-addressed intent. It lands the union and the resume path that understands it, so a client can never meet a payload shape older code cannot parse. `IntentPayload` becomes `CoinIntentPayload | TokenIntentPayload` on a still-`v:2` envelope — the coin payload's stored shape did not change. `kind` is REQUIRED on both arms, never optional: a missed writer must be a compile error, not a payload that silently reads as a coin spend. The compiler duly found all thirteen coin-shaped reads, now narrowed through `machine/payload-view.ts` rather than by scattered `kind ===` checks. A `TokenIntentPayload` names EXACTLY one source (`direct: [string]` by type) and can never carry a split. `resume.validatePayload` re-checks both rather than trusting the type across a decrypt boundary, because a second leg would make `settlePartial` reachable with every conflict amount 0n — a remainder of '0' completes the intent and reports SUCCESS for a leg that never landed. An ABSENT `kind` reads as `'coin'`. That is a migration rather than a guess: it is the only shape any client wrote before this change. Also fixed, found while re-reading the mirror against §16: `applyOne` inherited `assets` on an ACTIVE row that omitted them. Absent assets on an active row STATE coinlessness, so inheriting left stale assets in `tokens()` while `coinless` was true — the same row in both reads at once, shown as an NFT while holding 100 coins. Only a tombstone may inherit now, which is what `recoverRemoved` actually needs. Caught by a test written to fail first. A token-addressed send records `assets: []` plus its `tokenId` in history, the shape wallet-api#151 accepts; `PendingTransfer` gains `tokenId` so an open token intent renders as the token instead of `coinId: ''` / `amount: ''`. Verified: 450 payments-v2 tests green; typecheck, typecheck:tests and lint clean. Refs #777. --- modules/payments-v2/api.ts | 2 + modules/payments-v2/compose.ts | 8 +-- modules/payments-v2/convergence.ts | 24 +++++++-- .../payments-v2/inventory/InventoryView.ts | 8 +-- .../payments-v2/machine/TransferMachine.ts | 26 ++++++--- modules/payments-v2/machine/payload-view.ts | 19 +++++++ modules/payments-v2/machine/payload.ts | 24 ++++++++- modules/payments-v2/machine/resume.ts | 54 +++++++++++++++---- modules/payments-v2/machine/types.ts | 21 ++++++-- tests/unit/payments-v2/inventory.test.ts | 14 +++++ tests/unit/payments-v2/machine-harness.ts | 1 + tests/unit/payments-v2/machine-resume.test.ts | 2 +- 12 files changed, 169 insertions(+), 34 deletions(-) create mode 100644 modules/payments-v2/machine/payload-view.ts diff --git a/modules/payments-v2/api.ts b/modules/payments-v2/api.ts index e4518396..70819358 100644 --- a/modules/payments-v2/api.ts +++ b/modules/payments-v2/api.ts @@ -84,6 +84,8 @@ export interface PendingTransfer { recipient: string; coinId: string; amount: string; + /** Set instead of coinId/amount when the intent is a token-addressed spend. */ + tokenId?: string; legs: { certified: number; total: number }; deliveryPending: boolean; createdAt: number; diff --git a/modules/payments-v2/compose.ts b/modules/payments-v2/compose.ts index 79718747..8eb80e69 100644 --- a/modules/payments-v2/compose.ts +++ b/modules/payments-v2/compose.ts @@ -282,9 +282,11 @@ function buildMachineDeps( recordHistory: async ({ transferId, payload, committedAmount }) => { await historyStore.recordSent({ transferId, - // §5.9: the SETTLED amount (machine-computed from the certified - // recipient blobs), never payload.amount — the plan. - assets: [{ coinId: payload.coinId, amount: committedAmount }], + // §5.9: the SETTLED amount, never payload.amount — the plan. A token + // spend moved no coin: `assets: []` + tokenId (wallet-api#151 / §10). + ...(payload.kind === 'token' + ? { assets: [], tokenId: payload.direct[0] } + : { assets: [{ coinId: payload.coinId, amount: committedAmount }] }), recipientPubkey: payload.recipient, ...(payload.memo !== undefined ? { memo: payload.memo } : {}), }); diff --git a/modules/payments-v2/convergence.ts b/modules/payments-v2/convergence.ts index 91c02d16..0838df3f 100644 --- a/modules/payments-v2/convergence.ts +++ b/modules/payments-v2/convergence.ts @@ -6,7 +6,7 @@ import type { PendingTransfer } from './api'; import type { DeliveryPort } from './ports'; import type { DeliveryJournalEntry, IntentBackstopEntry, ShortfallEntry } from './stores'; -import type { IntentPayload } from './machine/types'; +import type { CoinIntentPayload, IntentPayload } from './machine/types'; import { retryAfterMsOf, type MachineStores, type ReplayDeps } from './machine/journal'; import { resumeAll } from './machine/resume'; import type { MachineDeps } from './machine/TransferMachine'; @@ -281,14 +281,32 @@ async function openRow( transferId: entry.transferId, kind: 'open', recipient: typeof payload?.recipient === 'string' ? payload.recipient : '', - coinId: typeof payload?.coinId === 'string' ? payload.coinId : '', - amount: typeof payload?.amount === 'string' ? payload.amount : '', + ...subject(payload), legs: { certified, total: Math.max(total, certified) }, deliveryPending: journal.length > 0, createdAt: entry.createdAt, }; } +/** + * What the row is FOR: a coin and an amount, or the token being moved. A + * token-addressed intent names no coin, so it renders as its token rather than + * as `coinId: ''` / `amount: ''`. + */ +function subject( + payload: Partial | null +): { coinId: string; amount: string; tokenId?: string } { + if (payload?.kind === 'token') { + const tokenId = payload.direct?.[0]; + return { coinId: '', amount: '', ...(typeof tokenId === 'string' ? { tokenId } : {}) }; + } + const coin = payload as Partial | null; + return { + coinId: typeof coin?.coinId === 'string' ? coin.coinId : '', + amount: typeof coin?.amount === 'string' ? coin.amount : '', + }; +} + /** The intent already completed but a deposit is still owed from the journal. */ function journalOnlyRow(transferId: string, entries: DeliveryJournalEntry[]): PendingTransfer { const certified = distinctOpCount(entries); diff --git a/modules/payments-v2/inventory/InventoryView.ts b/modules/payments-v2/inventory/InventoryView.ts index 914079d2..2d7c2328 100644 --- a/modules/payments-v2/inventory/InventoryView.ts +++ b/modules/payments-v2/inventory/InventoryView.ts @@ -362,9 +362,11 @@ export class InventoryView { 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 ?? [], + // ONLY a tombstone inherits: it omits assets for an unrelated reason and + // recoverRemoved must know the amount it restores. An ACTIVE row that omits + // them is stating coinlessness, and inheriting there would keep stale assets + // in tokens() while `coinless` is true — the row in BOTH reads (§16). + assets: item.assets ?? (item.status === 'removed' ? (prev?.assets ?? []) : []), coinless: isCoinless(item, prev), ...(tokenType !== undefined ? { tokenType } : {}), createdAt: prev?.createdAt ?? now, diff --git a/modules/payments-v2/machine/TransferMachine.ts b/modules/payments-v2/machine/TransferMachine.ts index e851ec69..3d236d2e 100644 --- a/modules/payments-v2/machine/TransferMachine.ts +++ b/modules/payments-v2/machine/TransferMachine.ts @@ -17,6 +17,7 @@ import { TransferConflictError, } from '../../../token-engine/errors'; import type { DeliverOptions, DeliveryPort, StoragePort } from '../ports'; +import { coinIdOf, splitOf } from './payload-view'; import type { DeliveryJournalEntry, ScopedKV, ShortfallEntry } from '../stores'; import type { IntentPayload, OpOutcome, OutcomeClass, PlannedOp } from './types'; import { @@ -353,13 +354,16 @@ export class TransferMachine { const finished = await engine.transfer({ token: source, recipientPubkey }, opts); return { recipientBlob: finished.blob.token }; } - const split = ctx.payload.split!; + const split = splitOf(ctx.payload)!; + // A split is planned only for a coin spend, so the coin id is present by + // construction; a token-addressed intent never reaches this branch. + const splitCoinId = coinIdOf(ctx.payload)!; const { outputs } = await engine.split( { token: source, outputs: [ - { recipientPubkey, coinId: ctx.payload.coinId, amount: BigInt(split.splitAmount) }, - { recipientPubkey: this.deps.ownPubkey, coinId: ctx.payload.coinId, amount: BigInt(split.remainderAmount) }, + { recipientPubkey, coinId: splitCoinId, amount: BigInt(split.splitAmount) }, + { recipientPubkey: this.deps.ownPubkey, coinId: splitCoinId, amount: BigInt(split.remainderAmount) }, ], }, opts @@ -524,7 +528,9 @@ export class TransferMachine { const entry: ShortfallEntry = { transferId: r.transferId, remainingAmount: undelivered.toString(), - coinId: r.payload.coinId, + // '' only on a token-addressed intent, which is one leg: it either lands or + // conflicts, so a shortfall row is unreachable there (settlePartial needs >1). + coinId: coinIdOf(r.payload) ?? '', recipient: r.payload.recipient, committedTokenIds: committed.map((o) => o.op.sourceTokenId), createdAt: this.deps.now(), @@ -561,7 +567,10 @@ export class TransferMachine { committed: OpOutcome[] ): Promise { try { - const committedAmount = await this.settledAmount(engine, payload.coinId, committed); + // A token-addressed spend has no coin to sum: what settled is the token itself. + const coinId = coinIdOf(payload); + const committedAmount = + coinId === undefined ? '0' : await this.settledAmount(engine, coinId, committed); await this.deps.recordHistory?.({ transferId, payload, phase, committedAmount }); } catch { /* a history failure never fails the money path */ @@ -619,6 +628,9 @@ function conflictAmount( op: PlannedOp, source: SphereToken | undefined ): bigint { - if (op.kind === 'split') return BigInt(payload.split!.splitAmount); - return source !== undefined ? engine.balanceOf(source, payload.coinId) : 0n; + const split = splitOf(payload); + if (op.kind === 'split') return BigInt(split!.splitAmount); + const coinId = coinIdOf(payload); + if (coinId === undefined) return 0n; // token-addressed: the unit is the token + return source !== undefined ? engine.balanceOf(source, coinId) : 0n; } diff --git a/modules/payments-v2/machine/payload-view.ts b/modules/payments-v2/machine/payload-view.ts new file mode 100644 index 00000000..5a60ca2c --- /dev/null +++ b/modules/payments-v2/machine/payload-view.ts @@ -0,0 +1,19 @@ +import type { CoinIntentPayload, IntentPayload, TokenIntentPayload } from './types'; + +export function isTokenIntent(p: IntentPayload): p is TokenIntentPayload { + return p.kind === 'token'; +} + +export function isCoinIntent(p: IntentPayload): p is CoinIntentPayload { + return p.kind === 'coin'; +} + +export function splitOf(p: IntentPayload): CoinIntentPayload['split'] { + return p.kind === 'coin' ? p.split : undefined; +} + +/** The coin spent, or undefined when token-addressed. Handle absence, never ''. */ +export function coinIdOf(p: IntentPayload): string | undefined { + return p.kind === 'coin' ? p.coinId : undefined; +} + diff --git a/modules/payments-v2/machine/payload.ts b/modules/payments-v2/machine/payload.ts index 8fcf0677..9b601cc0 100644 --- a/modules/payments-v2/machine/payload.ts +++ b/modules/payments-v2/machine/payload.ts @@ -3,16 +3,17 @@ import type { SendRequest } from '../api'; import type { PlannedSpend } from '../select/queue'; -import type { IntentPayload } from './types'; +import type { CoinIntentPayload, TokenIntentPayload } from './types'; export function buildPayload( recipientPubkey: string, request: SendRequest, spend: PlannedSpend, spentStates: Record -): IntentPayload { +): CoinIntentPayload { return { v: 2, + kind: 'coin', recipient: recipientPubkey, coinId: request.coinId, amount: request.amount, @@ -34,3 +35,22 @@ export function buildPayload( export function messageOf(err: unknown): string { return err instanceof Error ? err.message : String(err); } + +/** + * A token-addressed spend (#777). Takes no PlannedSpend because nothing was + * selected: the source is named, so there is no amount, no split and no change. + */ +export function buildTokenPayload( + recipientPubkey: string, + request: { tokenId: string; memo?: string }, + spentStates: Record +): TokenIntentPayload { + return { + v: 2, + kind: 'token', + recipient: recipientPubkey, + ...(request.memo !== undefined ? { memo: request.memo } : {}), + direct: [request.tokenId], + spentStates, + }; +} diff --git a/modules/payments-v2/machine/resume.ts b/modules/payments-v2/machine/resume.ts index 72f8e02f..6642771e 100644 --- a/modules/payments-v2/machine/resume.ts +++ b/modules/payments-v2/machine/resume.ts @@ -6,7 +6,7 @@ import { SphereError } from '../../../core/errors'; import { logger } from '../../../core/logger'; import type { SphereToken } from '../../../token-engine/types'; import type { DeliveryJournalEntry, IntentBackstopEntry } from '../stores'; -import type { IntentPayload } from './types'; +import type { CoinIntentPayload, IntentPayload, TokenIntentPayload } from './types'; import { ATTENTION_CHECKPOINT_STUCK, createMachineStores, type MachineStores } from './journal'; import { TransferMachine, buildOps, classifyError, type MachineDeps } from './TransferMachine'; @@ -209,6 +209,39 @@ async function classifyResumeFailure( report.failed.push(transferId); } +/** + * A token-addressed intent is EXACTLY one named source and never a split. Both are + * enforced here rather than trusted: a second leg would make `settlePartial` + * reachable with every conflict amount 0n, so a remainder of '0' would complete the + * intent and report success for a leg that never landed. + */ +function validateCoinPayload(c: Partial): CoinIntentPayload { + if (typeof c.coinId !== 'string' || typeof c.amount !== 'string') { + throw new SphereError('intent payload is missing coinId/amount', 'VALIDATION_ERROR'); + } + const s = c.split; + if ( + s !== undefined && + (typeof s.tokenId !== 'string' || typeof s.splitAmount !== 'string' || typeof s.remainderAmount !== 'string') + ) { + throw new SphereError('intent payload split spec is malformed', 'VALIDATION_ERROR'); + } + return { ...(c as CoinIntentPayload), kind: 'coin' }; +} + +function validateTokenPayload(p: Partial): TokenIntentPayload { + if (p.direct?.length !== 1 || typeof p.direct[0] !== 'string' || p.direct[0] === '') { + throw new SphereError( + 'token intent payload must name exactly one source token', + 'VALIDATION_ERROR' + ); + } + if (p.split !== undefined) { + throw new SphereError('token intent payload cannot carry a split', 'VALIDATION_ERROR'); + } + return { ...(p as TokenIntentPayload), kind: 'token', direct: [p.direct[0]] }; +} + function validatePayload(raw: unknown): IntentPayload { const p = raw !== null && typeof raw === 'object' ? (raw as Partial) : null; if (p === null || p.v !== 2 || !Array.isArray(p.direct) || p.direct.some((t) => typeof t !== 'string')) { @@ -217,15 +250,14 @@ function validatePayload(raw: unknown): IntentPayload { 'VALIDATION_ERROR' ); } - if (typeof p.recipient !== 'string' || typeof p.coinId !== 'string' || typeof p.amount !== 'string') { - throw new SphereError('intent payload is missing recipient/coinId/amount', 'VALIDATION_ERROR'); - } - const s = p.split; - if ( - s !== undefined && - (typeof s.tokenId !== 'string' || typeof s.splitAmount !== 'string' || typeof s.remainderAmount !== 'string') - ) { - throw new SphereError('intent payload split spec is malformed', 'VALIDATION_ERROR'); + if (typeof p.recipient !== 'string') { + throw new SphereError('intent payload is missing recipient', 'VALIDATION_ERROR'); } - return p as IntentPayload; + // An ABSENT kind is the only shape written before #777, and it was always a coin + // spend — so defaulting is a migration, not a guess. Anything written since + // carries the discriminant, because the type makes omitting it a compile error. + const kind = p.kind ?? 'coin'; + return kind === 'token' + ? validateTokenPayload(p as Partial) + : validateCoinPayload(p as Partial); } diff --git a/modules/payments-v2/machine/types.ts b/modules/payments-v2/machine/types.ts index bf1321c8..be415454 100644 --- a/modules/payments-v2/machine/types.ts +++ b/modules/payments-v2/machine/types.ts @@ -1,16 +1,29 @@ -export interface IntentPayload { +interface IntentPayloadBase { v: 2; recipient: string; - coinId: string; - amount: string; memo?: string; // Stored order is normative (E.3). direct: string[]; - split?: { tokenId: string; splitAmount: string; remainderAmount: string }; // Dual-field = old-module v:2 wire compat (always equal); collapse at the flip. spentStates?: Record; } +export interface CoinIntentPayload extends IntentPayloadBase { + kind: 'coin'; + coinId: string; + amount: string; + split?: { tokenId: string; splitAmount: string; remainderAmount: string }; +} + +export interface TokenIntentPayload extends IntentPayloadBase { + kind: 'token'; + direct: [string]; + split?: undefined; +} + +/** `kind` is REQUIRED on both arms: a missed writer must be a compile error. */ +export type IntentPayload = CoinIntentPayload | TokenIntentPayload; + export interface PlannedOp { kind: 'direct' | 'split'; // Plan-derived; never execution-derived. diff --git a/tests/unit/payments-v2/inventory.test.ts b/tests/unit/payments-v2/inventory.test.ts index 756f5d71..0ba97f46 100644 --- a/tests/unit/payments-v2/inventory.test.ts +++ b/tests/unit/payments-v2/inventory.test.ts @@ -653,6 +653,20 @@ describe('InventoryView — coinless tokens (#777)', () => { expect(row?.name).toBeUndefined(); }); + it('an ACTIVE row that stops naming assets does not inherit them — the two reads stay disjoint', async () => { + // §16: `assets` absent means tombstone OR active-coinless, so inheriting on an + // ACTIVE row makes `coinless` true while stale assets keep the row in tokens() + // as well — present in both reads at once. Only a tombstone may inherit. + const { view, queue } = makeView([page([item('X', { seq: 1, amount: '100' })], 5), page([], 5)]); + await view.fullPull(); + queue.push(page([item('X', { seq: 2, state: 'S2', noAssets: true, tokenType: NFT_TYPE })], 6)); + await view.delta(); + + expect(view.coinless(registry).map((t) => t.tokenId)).toEqual(['X']); + expect(view.tokens(registry).map((t) => t.id)).toEqual([]); + expect(view.pool(COIN)).toEqual([]); + }); + 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), diff --git a/tests/unit/payments-v2/machine-harness.ts b/tests/unit/payments-v2/machine-harness.ts index 97aa053f..c63bfcd8 100644 --- a/tests/unit/payments-v2/machine-harness.ts +++ b/tests/unit/payments-v2/machine-harness.ts @@ -425,6 +425,7 @@ async function buildPlan(w: WorldState, opts: PlanOpts): Promise { } const payload: IntentPayload = { v: 2, + kind: 'coin', recipient: w.recipientHex, coinId: COIN, amount: opts.amount, diff --git a/tests/unit/payments-v2/machine-resume.test.ts b/tests/unit/payments-v2/machine-resume.test.ts index 8825567a..40e5fc23 100644 --- a/tests/unit/payments-v2/machine-resume.test.ts +++ b/tests/unit/payments-v2/machine-resume.test.ts @@ -123,7 +123,7 @@ describe('TransferMachine resume path (§5.5 P6 — same machine, rehydrated)', const shortfall = await createMachineStores(w.kv).shortfalls.getByKey('partial-1'); expect(shortfall).toMatchObject({ remainingAmount: '400', - coinId: plan.payload.coinId, + coinId: plan.payload.kind === 'coin' ? plan.payload.coinId : '', committedTokenIds: [tokenA.blob.tokenId], }); expect(w.api.inspectIntent(w.caller, 'partial-1')?.status).toBe('completed'); From 840a2facb1b7b62717dc411228b1b86d975cf2b1 Mon Sep 17 00:00:00 2001 From: Pavel Grigorenko Date: Thu, 10 Sep 2026 18:07:39 +0200 Subject: [PATCH 02/12] =?UTF-8?q?feat(payments-v2):=20sendToken=20?= =?UTF-8?q?=E2=80=94=20move=20a=20coinless=20token=20whole?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit payments.sendToken({ recipient, tokenId, memo? }): Promise A second verb rather than a widened `send()`: the addressing model is genuinely different. A coin spend SELECTS sources to cover an amount and may queue for a combination that frees up; a token spend reserves the one it was NAMED. There is no amount, no combination, no split and no change. **One money path, not two.** The same `TransferMachine`, the same durable intent, the same checkpoints, mailbox deposit and applyDelta. `sendWithPolicy` is one loop over a `SendJob` union; the two spends diverge in exactly one place — `planAndMaterialize` — and nowhere else. `SpendQueue.planToken` reserves the named source behind the SAME #738 fail-closed gate `freeView()` applies: while the held-set is unproven nothing is spendable, or a restart could double-spend a source an open intent already holds. It never queues, because queueing waits for some other combination to free up and a named token has none — it would block until a timeout on a spend that cannot become possible. A token already held by another reservation is refused outright. **A proven conflict is TERMINAL for a named source.** #625's bounded re-plan exists to pick a DIFFERENT source after a lost race; a named token has no alternative, so re-planning would re-pick the same one or nothing. Retrying it would spin. Three independent gates keep a valued token out, because each covers a different failure: `spendableCoinless` on the mirror (the fast, ordinary refusal), `planToken`'s reservation check (concurrency), and a re-check of the decoded BLOB in `materializeTokenSpend` — the blob is the authority on what a token carries, and the mirror disagreeing with it is exactly the case where coins would move unaccounted for. `pool()`'s eligibility gates are now `isSpendable()`, shared by both verbs, so one probe covers both and they cannot drift apart. Extracted to stay under the 800-line ceiling the additions crossed: `modules/payments-v2/mint.ts` (the journal-first mint and its finalize, a pure move behind an explicit deps object) and `send-token.ts`. Verified: 2288 tests green (7 new, incl. a valued-token refusal, an unknown-token refusal that reserves nothing, a concurrent double-spend refusal, and a coin send proving selection never saw the coinless token); 6 new mutation probes; typecheck, typecheck:tests and lint clean. Refs #777. --- modules/payments-v2/PaymentsFacade.ts | 139 +++++++++--------- modules/payments-v2/api.ts | 7 + modules/payments-v2/compose.ts | 1 + .../payments-v2/inventory/InventoryView.ts | 20 ++- modules/payments-v2/mint.ts | 102 +++++++++++++ modules/payments-v2/select/queue.ts | 37 +++++ modules/payments-v2/send-token.ts | 64 ++++++++ tests/mutation/probes.json | 61 ++++++++ tests/unit/payments-v2/facade-harness.ts | 17 +++ tests/unit/payments-v2/facade.test.ts | 93 ++++++++++++ 10 files changed, 468 insertions(+), 73 deletions(-) create mode 100644 modules/payments-v2/mint.ts create mode 100644 modules/payments-v2/send-token.ts diff --git a/modules/payments-v2/PaymentsFacade.ts b/modules/payments-v2/PaymentsFacade.ts index 7f904184..575aa0a7 100644 --- a/modules/payments-v2/PaymentsFacade.ts +++ b/modules/payments-v2/PaymentsFacade.ts @@ -15,10 +15,12 @@ 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 { CoinlessToken, ConnectionStatus, HistoryPage, MintResult, PaymentsV2, PendingTransfer, SendRequest } from './api'; +import type { CoinlessToken, ConnectionStatus, HistoryPage, MintResult, PaymentsV2, PendingTransfer, SendRequest, SendTokenRequest } from './api'; import { SerialChain, SingleFlight } from './async'; import { ConvergenceHeartbeat, Converger, derivePendingTransfers } from './convergence'; import { readTokenData } from './inventory/token-data'; +import { finalizeMint, type MintDeps, runMintUnderJournal } from './mint'; +import { materializeTokenSpend } from './send-token'; import { partialize, stampTransferId } from './send-errors'; import { requireSameNetworkRecipient } from './recipient'; import { reseedAndReset, type RestoreDeps } from './restore'; @@ -62,8 +64,17 @@ export const ATTENTION_MINT_UNRESOLVED = 'mint:unresolved'; const INVENTORY_SCAN_PAGE_LIMIT = 50; +/** + * What this attempt is spending. The policy loop is shared; only planning and the + * conflict rule differ, so the union is narrowed in exactly those two places. + */ +type SendJob = + | { readonly kind: 'coin'; readonly request: SendRequest } + | { readonly kind: 'token'; readonly request: SendTokenRequest }; + interface AttemptCtx { readonly transferId: string; + /** '' for a token-addressed spend: it names no coin. */ readonly coinId: string; readonly plan: MachinePlan; readonly sourceIds: readonly string[]; @@ -266,6 +277,10 @@ export class PaymentsFacade implements PaymentsV2 { return this.track(this.sendOutcome(request)); } + sendToken(request: SendTokenRequest): Promise { + return this.track(this.sendTokenOutcome(request)); + } + async receive(): Promise<{ transfers: IncomingTransfer[] }> { const transfers = await this.track(this.receiveLoop.drainOnce()); return { transfers }; @@ -306,13 +321,36 @@ export class PaymentsFacade implements PaymentsV2 { return (this.deps.now ?? Date.now)(); } + private mintDeps(): MintDeps { + return { + engine: this.engine(), + mintJournal: this.machineStores.mintJournal, + storagePort: this.deps.storagePort, + recordMint: (input) => this.historyStore.recordMint(input), + armHeartbeat: () => this.heartbeat.arm(), + noteHeldState: (tokenId, stateHash) => void this.heldStates.set(tokenId, stateHash), + refreshView: () => this.trackTail(this.view.delta()), + ownPubkeyBytes: this.ownPubkeyBytes, + now: () => this.nowMs(), + }; + } + // ── send policy (§5.5 + old-loop parity; the machine stays policy-free) ──── /** The ONE place a send() outcome is shaped: success emits in finishSend, a * CLEAN rejection emits `transfer:updated{status:'failed'}` here (§4). */ - private async sendOutcome(request: SendRequest): Promise { + private sendOutcome(request: SendRequest): Promise { + return this.runJob({ kind: 'coin', request }, request.amount); + } + + /** A token spend has no amount; '0' keeps the shortfall arithmetic total-free. */ + private sendTokenOutcome(request: SendTokenRequest): Promise { + return this.runJob({ kind: 'token', request }, '0'); + } + + private async runJob(job: SendJob, amount: string): Promise { const run: SendRun = { - amount: request.amount, + amount, delivered: [], sentTokens: [], tokenTransfers: [], @@ -322,7 +360,7 @@ export class PaymentsFacade implements PaymentsV2 { lastTransferId: '', }; try { - return await this.sendWithPolicy(request, run); + return await this.sendWithPolicy(job, run); } catch (err) { this.emitCleanFailure(err, run.lastTransferId); throw err; @@ -344,19 +382,22 @@ export class PaymentsFacade implements PaymentsV2 { this.deps.emit('transfer:updated', failed); } - private async sendWithPolicy(request: SendRequest, run: SendRun): Promise { - const recipient = await requireSameNetworkRecipient(this.deps, request.recipient); + private async sendWithPolicy(job: SendJob, run: SendRun): Promise { + const recipient = await requireSameNetworkRecipient(this.deps, job.request.recipient); for (let attempt = 0; ; attempt++) { let ctx: AttemptCtx; try { - ctx = await this.planAndMaterialize(recipient.chainPubkey, { ...request, amount: run.amount }, run); + ctx = await this.planAndMaterialize(recipient.chainPubkey, job, run); } catch (err) { throw partialize(err, run); } const disposition = await this.runAttempt(ctx); if (disposition.kind === 'rethrow') throw partialize(disposition.error, run); if (disposition.kind === 'retry-full') { - if (attempt >= MAX_RESELECT) throw partialize(disposition.error, run); + // #625's re-plan searches for a DIFFERENT source. A named token has no + // alternative — re-planning would pick the same one or nothing — so a + // proven conflict is TERMINAL here rather than a bounded retry. + if (job.kind === 'token' || attempt >= MAX_RESELECT) throw partialize(disposition.error, run); continue; } if (disposition.kind === 'success') { @@ -470,9 +511,25 @@ export class PaymentsFacade implements PaymentsV2 { return { kind: 'rethrow', error: err }; } - private async planAndMaterialize(recipientPubkey: string, request: SendRequest, run: SendRun): Promise { + private async planAndMaterialize(recipientPubkey: string, job: SendJob, run: SendRun): Promise { const transferId = this.newId(); run.lastTransferId = transferId; + // The ONE divergence: a coin spend SELECTS sources to cover an amount and may + // queue for them; a token spend reserves the one it was NAMED and never queues. + if (job.kind === 'token') { + const spend = this.queue.planToken(transferId, job.request.tokenId); + const sourceIds = this.markPlanned(transferId, '', spend); + try { + return await materializeTokenSpend( + { engine: this.engine(), storagePort: this.deps.storagePort }, + { transferId, recipientPubkey, request: job.request, sourceIds } + ); + } catch (err) { + this.settleFailure(transferId, '', sourceIds); + throw err; + } + } + const request = { ...job.request, amount: run.amount }; const planned = this.queue.plan(transferId, { coinId: request.coinId, amount: request.amount }); const spend = planned.kind === 'planned' ? planned.spend : await planned.settled; const sourceIds = this.markPlanned(transferId, request.coinId, spend); @@ -533,6 +590,7 @@ export class PaymentsFacade implements PaymentsV2 { return { transferId, coinId: request.coinId, plan, sourceIds, sourceTokens }; } + /** isSpent sweep over the attempt's sources; demote proven-spent states (durable). */ private async demoteSpentSources(ctx: AttemptCtx): Promise { const engine = this.engine(); @@ -646,71 +704,12 @@ export class PaymentsFacade implements PaymentsV2 { const mintId = this.newId(); this.activeMoneyOps.add(mintId); // its replay stays hands-off while this attempt runs try { - return await this.mintUnderJournal(mintId, coinId, amount); + return await runMintUnderJournal(this.mintDeps(), { mintId, coinId, amount }); } finally { this.activeMoneyOps.delete(mintId); } } - private async mintUnderJournal(mintId: string, coinId: string, amount: bigint): Promise { - const engine = this.engine(); - // tokenId stays '' until mint returns; replay converges via the F13 same-seed re-call. - const entry: MintJournalEntry = { - mintId, - coinId, - amount: amount.toString(), - tokenId: '', - createdAt: this.nowMs(), - }; - await this.machineStores.mintJournal.upsert(entry); - let token: SphereToken; - try { - token = await engine.mint(mintParams(this.ownPubkeyBytes, coinId, amount), { transferId: mintId }); - } catch (err) { - // Entry retained: the heartbeat / start() replay resolves it (inventory check / F13 seed). - this.heartbeat.arm(); - return { success: false, error: messageOf(err) }; - } - if (token.blob.tokenId !== entry.tokenId) { - await this.machineStores.mintJournal.upsert({ ...entry, tokenId: token.blob.tokenId }); - } - try { - await this.finalizeMint(engine, mintId, token, coinId, amount.toString()); - } catch (err) { - this.heartbeat.arm(); - return { success: false, tokenId: token.blob.tokenId, error: messageOf(err) }; - } - return { success: true, tokenId: token.blob.tokenId }; - } - - private async finalizeMint( - engine: ITokenEngine, - mintId: string, - token: SphereToken, - coinId: string, - amount: string - ): Promise { - const bytes = token.blob.token; - const digest = bytesToHex(sha256(bytes)); - const keys = await this.deps.storagePort.uploadBlobs([{ sha256: digest, bytes }]); - const key = keys.get(digest); - if (key === undefined) { - throw new SphereError(`no upload key returned for mint blob ${digest}`, 'STORAGE_ERROR'); - } - await this.deps.storagePort.applyDelta({ - transferId: mintId, - spent: [], - added: [{ tokenId: token.blob.tokenId, key }], - }); - 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()); - } - /** @returns how many journal entries were RESOLVED (cleared) — heartbeat progress. */ private async replayMints(): Promise { let resolved = 0; @@ -745,7 +744,7 @@ export class PaymentsFacade implements PaymentsV2 { if (token.blob.tokenId !== entry.tokenId) { await this.machineStores.mintJournal.upsert({ ...entry, tokenId: token.blob.tokenId }); } - await this.finalizeMint(engine, entry.mintId, token, entry.coinId, entry.amount); + await finalizeMint(this.mintDeps(), entry.mintId, token, entry.coinId, entry.amount); return true; } diff --git a/modules/payments-v2/api.ts b/modules/payments-v2/api.ts index 70819358..2c791275 100644 --- a/modules/payments-v2/api.ts +++ b/modules/payments-v2/api.ts @@ -9,6 +9,12 @@ export interface SendRequest { memo?: string; } +export interface SendTokenRequest { + recipient: string; + tokenId: string; + memo?: string; +} + export interface MintResult { success: boolean; tokenId?: string; @@ -103,6 +109,7 @@ export interface PaymentsV2 { history(page?: { before?: string; limit?: number }): Promise; send(req: SendRequest): Promise; + sendToken(req: SendTokenRequest): Promise; mint(coinId: string, amount: bigint): Promise; receive(): Promise<{ transfers: IncomingTransfer[] }>; diff --git a/modules/payments-v2/compose.ts b/modules/payments-v2/compose.ts index 8eb80e69..3cec2285 100644 --- a/modules/payments-v2/compose.ts +++ b/modules/payments-v2/compose.ts @@ -173,6 +173,7 @@ export function composeFacadeParts(deps: PaymentsFacadeDeps, hooks: FacadeHooks) const queue = new SpendQueue({ ledger, getPool: (coinId) => view.pool(coinId), + spendableCoinless: (tokenId) => view.spendableCoinless(tokenId), ...(deps.workBudget !== undefined ? { workBudget: deps.workBudget } : {}), }); const historyStore = new History({ diff --git a/modules/payments-v2/inventory/InventoryView.ts b/modules/payments-v2/inventory/InventoryView.ts index 2d7c2328..1fece0ed 100644 --- a/modules/payments-v2/inventory/InventoryView.ts +++ b/modules/payments-v2/inventory/InventoryView.ts @@ -208,15 +208,29 @@ export class InventoryView { pool(coinId: string): PoolEntry[] { const out: PoolEntry[] = []; for (const [tokenId, entry] of this.mirror) { - if (entry.status !== 'active') continue; - if (this.inFlight.has(tokenId) && !this.pinned(tokenId)) continue; - if (this.suspected.has(stateKey(tokenId, entry.stateHash))) continue; + if (!this.isSpendable(tokenId, entry)) continue; const asset = entry.assets.find((a) => a.coinId === coinId); if (asset) out.push({ tokenId, amount: BigInt(asset.amount) }); } return out; } + /** + * The eligibility gates every spend shares, coin or token — ONE definition, so a + * single probe covers both verbs and neither can drift from the other. + */ + private isSpendable(tokenId: string, entry: MirrorEntry): boolean { + if (entry.status !== 'active') return false; + if (this.inFlight.has(tokenId) && !this.pinned(tokenId)) return false; + return !this.suspected.has(stateKey(tokenId, entry.stateHash)); + } + + /** #777: is this NAMED token a spendable COINLESS holding? Never a coin source. */ + spendableCoinless(tokenId: string): boolean { + const entry = this.mirror.get(tokenId); + return entry !== undefined && entry.coinless && this.isSpendable(tokenId, entry); + } + /** * Current mirrored state of an ACTIVE token, or undefined when it is unknown * or tombstoned. Exists so a cached blob can be state-scoped (F6): a blob is diff --git a/modules/payments-v2/mint.ts b/modules/payments-v2/mint.ts new file mode 100644 index 00000000..0d43480d --- /dev/null +++ b/modules/payments-v2/mint.ts @@ -0,0 +1,102 @@ +import { sha256 } from '@noble/hashes/sha2.js'; + +import { bytesToHex } from '../../core/crypto'; +import { SphereError } from '../../core/errors'; +import type { ITokenEngine } from '../../token-engine/engine'; +import type { SphereToken } from '../../token-engine/types'; + +import type { MintResult } from './api'; +import type { RecordMintInput } from './history/History'; +import { messageOf } from './machine/payload'; +import { mintParams } from './mint-params'; +import type { ListStore } from './machine/journal'; +import type { MintJournalEntry } from './stores'; +import type { StoragePort } from './ports'; + +/** Everything the journal-first mint needs, injected so the path stays testable. */ +export interface MintDeps { + readonly engine: ITokenEngine; + readonly mintJournal: ListStore; + readonly storagePort: Pick; + readonly recordMint: (input: RecordMintInput) => Promise; + readonly armHeartbeat: () => void; + readonly noteHeldState: (tokenId: string, stateHash: string) => void; + readonly refreshView: () => void; + readonly ownPubkeyBytes: Uint8Array; + readonly now: () => number; +} + +/** + * Journal-first self-mint: the entry is durable BEFORE the chain op, so a crash + * converges by the F13 same-seed re-call rather than minting twice. + */ +export function runMintUnderJournal( + deps: MintDeps, + input: { mintId: string; coinId: string; amount: bigint } +): Promise { + return mintUnderJournal(deps, input); +} + +async function mintUnderJournal( +deps: MintDeps, +input: { mintId: string; coinId: string; amount: bigint } +): Promise { +const { mintId, coinId, amount } = input; +const engine = deps.engine; + // tokenId stays '' until mint returns; replay converges via the F13 same-seed re-call. + const entry: MintJournalEntry = { + mintId, + coinId, + amount: amount.toString(), + tokenId: '', + createdAt: deps.now(), + }; + await deps.mintJournal.upsert(entry); + let token: SphereToken; + try { + token = await engine.mint(mintParams(deps.ownPubkeyBytes, coinId, amount), { transferId: mintId }); + } catch (err) { + // Entry retained: the heartbeat / start() replay resolves it (inventory check / F13 seed). + deps.armHeartbeat(); + return { success: false, error: messageOf(err) }; + } + if (token.blob.tokenId !== entry.tokenId) { + await deps.mintJournal.upsert({ ...entry, tokenId: token.blob.tokenId }); + } + try { + await finalizeMint(deps, mintId, token, coinId, amount.toString()); + } catch (err) { + deps.armHeartbeat(); + return { success: false, tokenId: token.blob.tokenId, error: messageOf(err) }; + } + return { success: true, tokenId: token.blob.tokenId }; +} + +export async function finalizeMint( +deps: MintDeps, +mintId: string, +token: SphereToken, +coinId: string, +amount: string +): Promise { +const engine = deps.engine; + const bytes = token.blob.token; + const digest = bytesToHex(sha256(bytes)); + const keys = await deps.storagePort.uploadBlobs([{ sha256: digest, bytes }]); + const key = keys.get(digest); + if (key === undefined) { + throw new SphereError(`no upload key returned for mint blob ${digest}`, 'STORAGE_ERROR'); + } + await deps.storagePort.applyDelta({ + transferId: mintId, + spent: [], + added: [{ tokenId: token.blob.tokenId, key }], + }); + await deps.recordMint({ + tokenId: token.blob.tokenId, + assets: [{ coinId, amount }], + }); + await deps.mintJournal.removeByKey(mintId); + deps.noteHeldState(token.blob.tokenId, (await engine.deliveryKeys(bytes)).stateHash); + deps.refreshView(); +} diff --git a/modules/payments-v2/select/queue.ts b/modules/payments-v2/select/queue.ts index f06b7b08..e9453bff 100644 --- a/modules/payments-v2/select/queue.ts +++ b/modules/payments-v2/select/queue.ts @@ -20,6 +20,8 @@ export type PlanOutcome = export interface SpendQueueDeps { readonly ledger: ReservationLedger; readonly getPool: (coinId: string) => readonly PoolEntry[]; + /** #777: is this NAMED coinless token spendable right now? Same gates as pool(). */ + readonly spendableCoinless?: (tokenId: string) => boolean; readonly workBudget?: number; } @@ -89,6 +91,41 @@ export class SpendQueue { return { kind: 'queued', settled: this.enqueue(reservationId, request.coinId, amount) }; } + /** + * #777: reserve a NAMED coinless source. Never queues — there is nothing to wait + * for. A coin plan waits because some OTHER combination may free up; a named + * token is either free now or held by a reservation that only its own transfer + * can release, so queueing would block until a timeout on a spend that cannot + * become possible. + */ + planToken(reservationId: string, tokenId: string): PlannedSpend { + if (this.destroyed) { + throw new SphereError('Module has been destroyed', 'MODULE_DESTROYED'); + } + // #738 fail-closed, the same gate freeView() applies: while the held-set is + // unproven nothing is spendable, or a restart could double-spend a source an + // open intent already holds. + const unproven = this.deps.ledger.unprovenReason(); + if (unproven !== null) { + throw new SphereError(`Cannot spend yet: ${unproven}`, 'SEND_SYNC_PENDING'); + } + if (this.deps.spendableCoinless?.(tokenId) !== true) { + throw new SphereError( + `Token ${tokenId} is not a spendable coinless holding`, + 'VALIDATION_ERROR' + ); + } + const holder = this.deps.ledger.holderOf(tokenId); + if (holder !== undefined) { + throw new SphereError( + `Token ${tokenId} is already reserved by transfer ${holder}`, + 'VALIDATION_ERROR' + ); + } + this.deps.ledger.reserve(reservationId, [{ tokenId }]); + return { reservationId, plan: { direct: [tokenId] } }; + } + /** * The sources `plan()` WOULD pick right now, reserving nothing and queueing * nothing — a read-only probe used to warm blobs while the user is still diff --git a/modules/payments-v2/send-token.ts b/modules/payments-v2/send-token.ts new file mode 100644 index 00000000..bcd075c2 --- /dev/null +++ b/modules/payments-v2/send-token.ts @@ -0,0 +1,64 @@ +import { SphereError } from '../../core/errors'; +import type { ITokenEngine } from '../../token-engine/engine'; + +import { buildTokenPayload } from './machine/payload'; +import { buildOps, type MachinePlan } from './machine/TransferMachine'; +import type { SendTokenRequest } from './api'; +import type { StoragePort } from './ports'; + +export interface TokenSpendDeps { + readonly engine: ITokenEngine; + readonly storagePort: Pick; +} + +export interface TokenSpendInput { + readonly transferId: string; + readonly recipientPubkey: string; + readonly request: SendTokenRequest; + readonly sourceIds: readonly string[]; +} + +/** + * The token-addressed twin of the facade's `materialize`: one named source, no + * coin, no split. `sourceTokens` stays EMPTY — those are the coin rows a UI shows + * as in-flight, and a coinless token has no amount to show as moving. + */ +export async function materializeTokenSpend( + deps: TokenSpendDeps, + input: TokenSpendInput +): Promise<{ + transferId: string; + coinId: string; + plan: MachinePlan; + sourceIds: readonly string[]; + sourceTokens: never[]; +}> { + const { engine } = deps; + const tokenId = input.request.tokenId; + const bytes = (await deps.storagePort.getBlobs([tokenId])).get(tokenId); + if (bytes === undefined) { + throw new SphereError(`Selected source ${tokenId} has no blob in storage`, 'STORAGE_ERROR'); + } + const token = await engine.decodeToken({ tokenId, token: bytes }); + // The mirror said coinless; the BLOB is the authority on what it carries, and the + // two disagreeing means this would move a valued token with its coins unaccounted. + if (token.value !== null) { + throw new SphereError( + `Token ${tokenId} carries coin value and cannot be sent with sendToken — use send()`, + 'VALIDATION_ERROR' + ); + } + const keys = await engine.deliveryKeys(bytes); + const payload = buildTokenPayload(input.recipientPubkey, input.request, { + [tokenId]: { local: keys.stateHash, protocol: keys.stateHash }, + }); + const plan: MachinePlan = { + transferId: input.transferId, + recipientPubkey: input.recipientPubkey, + payload, + ops: buildOps(payload), + sources: new Map([[tokenId, token]]), + ...(input.request.memo !== undefined ? { memo: input.request.memo } : {}), + }; + return { transferId: input.transferId, coinId: '', plan, sourceIds: input.sourceIds, sourceTokens: [] }; +} diff --git a/tests/mutation/probes.json b/tests/mutation/probes.json index b5c54f79..da758fa1 100644 --- a/tests/mutation/probes.json +++ b/tests/mutation/probes.json @@ -1426,5 +1426,66 @@ "tests": [ "tests/unit/payments-v2/inventory.test.ts" ] + }, + { + "name": "plantoken-skips-738-gate", + "note": "#777/#738: while the held-set is unproven nothing is spendable. Skipping the gate for a NAMED source lets a restart double-spend a token an open intent already holds.", + "file": "modules/payments-v2/select/queue.ts", + "find": " if (unproven !== null) {\n throw new SphereError(`Cannot spend yet: ${unproven}`, 'SEND_SYNC_PENDING');", + "replace": " if (false as boolean) {\n throw new SphereError(`Cannot spend yet: ${String(unproven)}`, 'SEND_SYNC_PENDING');", + "tests": [ + "tests/unit/payments-v2/queue.test.ts", + "tests/unit/payments-v2/facade.test.ts" + ] + }, + { + "name": "plantoken-accepts-any-token", + "note": "#777: sendToken must refuse a source that is not a SPENDABLE COINLESS holding \u2014 otherwise a valued token moves with its coins unaccounted for.", + "file": "modules/payments-v2/select/queue.ts", + "find": " if (this.deps.spendableCoinless?.(tokenId) !== true) {", + "replace": " if (false as boolean) {", + "tests": [ + "tests/unit/payments-v2/facade.test.ts" + ] + }, + { + "name": "plantoken-ignores-existing-reservation", + "note": "#777: a second concurrent sendToken of the same token must be refused. Ignoring the ledger holder double-spends the one source.", + "file": "modules/payments-v2/select/queue.ts", + "find": " const holder = this.deps.ledger.holderOf(tokenId);\n if (holder !== undefined) {", + "replace": " const holder = this.deps.ledger.holderOf(tokenId);\n if (false as boolean) {", + "tests": [ + "tests/unit/payments-v2/facade.test.ts" + ] + }, + { + "name": "spendable-coinless-ignores-kind", + "note": "#777: spendableCoinless gates on entry.coinless. Dropping it makes every coin token a sendToken candidate.", + "file": "modules/payments-v2/inventory/InventoryView.ts", + "find": " return entry !== undefined && entry.coinless && this.isSpendable(tokenId, entry);", + "replace": " return entry !== undefined && this.isSpendable(tokenId, entry);", + "tests": [ + "tests/unit/payments-v2/facade.test.ts" + ] + }, + { + "name": "sendtoken-blob-value-guard-removed", + "note": "#777: the BLOB is the authority on what a source carries. Without the re-check a mirror that wrongly says coinless would move a valued token with its coins unaccounted for.", + "file": "modules/payments-v2/send-token.ts", + "find": " if (token.value !== null) {", + "replace": " if (false as boolean) {", + "tests": [ + "tests/unit/payments-v2/facade.test.ts" + ] + }, + { + "name": "sendtoken-conflict-replans", + "note": "#625's re-plan searches for a DIFFERENT source; a NAMED token has none, so a proven conflict must be terminal rather than spinning the bounded retry.", + "file": "modules/payments-v2/PaymentsFacade.ts", + "find": " if (job.kind === 'token' || attempt >= MAX_RESELECT) throw partialize(disposition.error, run);", + "replace": " if (attempt >= MAX_RESELECT) throw partialize(disposition.error, run);", + "tests": [ + "tests/unit/payments-v2/facade.test.ts" + ] } ] diff --git a/tests/unit/payments-v2/facade-harness.ts b/tests/unit/payments-v2/facade-harness.ts index 82f0e584..4ff04a03 100644 --- a/tests/unit/payments-v2/facade-harness.ts +++ b/tests/unit/payments-v2/facade-harness.ts @@ -161,6 +161,8 @@ export interface World { /** The fake transport directory the production resolver reads (identifier → binding). */ peers: Map; seed(amount: bigint): Promise; + /** #777: a token that names NO coin, indexed through the fake backend. */ + seedCoinless(data?: Uint8Array): Promise; peerDeliver(token: SphereToken, transferId: string): Promise; gate(name: 'putIntent' | 'deliver' | 'listOpen' | 'applyDelta' | 'incoming'): Gate; } @@ -315,6 +317,21 @@ export function makeWorld( }); return token; }, + seedCoinless: async (data?: Uint8Array) => { + const token = await engine.mintDataToken({ + recipientPubkey: hexToBytes(OWN_PUB), + data: data ?? new TextEncoder().encode('kitty'), + }); + const bytes = token.blob.token; + const sha = sha256Hex(bytes); + await innerClient.uploadBlob(`fake://put/${sha}`, bytes); + await innerClient.apply({ + transferId: `seed-${token.blob.tokenId}`, + spent: [], + added: [{ tokenId: token.blob.tokenId, key: sha }], + }); + return token; + }, peerDeliver: async (token: SphereToken, transferId: string) => { const peerPort = new WalletApiDeliveryPort({ client: new FakeWalletApiV2Client(api, peerCaller, { decodeBlob: combined }), diff --git a/tests/unit/payments-v2/facade.test.ts b/tests/unit/payments-v2/facade.test.ts index 8f5de55a..4049ded4 100644 --- a/tests/unit/payments-v2/facade.test.ts +++ b/tests/unit/payments-v2/facade.test.ts @@ -764,3 +764,96 @@ describe('PaymentsFacade — mint', () => { expect(world.facade.tokens()).toHaveLength(1); }); }); + +describe('PaymentsFacade — sendToken: moving a coinless token (#777)', () => { + it('moves the named token whole: one direct leg, deposited, intent completed', async () => { + const world = makeWorld(); + const nft = await world.seedCoinless(); + await world.facade.start(); + + const result = await world.facade.sendToken({ recipient: '@peer', tokenId: nft.blob.tokenId }); + + expect(result.status).toBe('delivered'); + expect(result.tokenTransfers).toEqual([ + { sourceTokenId: nft.blob.tokenId, method: 'direct' }, + ]); + const mailbox = await world.api.listMailbox(peerCaller, 0); + expect(mailbox.entries.map((e) => e.tokenId)).toEqual([nft.blob.tokenId]); + await flushTail(); + expect(world.api.inspectIntent(ownCaller, result.id)?.status).toBe('completed'); + }); + + it('leaves the wallet: the token is gone from coinless() once the spend settles', async () => { + const world = makeWorld(); + const nft = await world.seedCoinless(); + await world.facade.start(); + expect(world.facade.coinless().map((t) => t.tokenId)).toEqual([nft.blob.tokenId]); + + await world.facade.sendToken({ recipient: '@peer', tokenId: nft.blob.tokenId }); + await flushTail(); + + expect(world.facade.coinless().map((t) => t.tokenId)).not.toContain(nft.blob.tokenId); + }); + + it('records history with an EMPTY asset list and the tokenId, never a synthetic coin', async () => { + const world = makeWorld(); + const nft = await world.seedCoinless(); + await world.facade.start(); + + await world.facade.sendToken({ recipient: '@peer', tokenId: nft.blob.tokenId }); + await flushTail(); + + const sent = (await world.api.listHistory(ownCaller, {})).records.filter((r) => r.type === 'SENT'); + expect(sent).toHaveLength(1); + expect(sent[0]?.assets).toEqual([]); + expect(sent[0]?.tokenId).toBe(nft.blob.tokenId); + }); + + it('REFUSES a valued token — it would move real coins unaccounted for', async () => { + const world = makeWorld(); + const coin = await world.seed(100n); + await world.facade.start(); + + await expect( + world.facade.sendToken({ recipient: '@peer', tokenId: coin.blob.tokenId }) + ).rejects.toThrow(/not a spendable coinless holding/); + }); + + it('REFUSES an unknown token before reserving anything or touching the chain', async () => { + const world = makeWorld(); + await world.facade.start(); + + await expect( + world.facade.sendToken({ recipient: '@peer', tokenId: 'ff'.repeat(32) }) + ).rejects.toThrow(/not a spendable coinless holding/); + expect(await world.facade.pendingTransfers()).toEqual([]); + }); + + it('REFUSES a second concurrent spend of the same token rather than double-spending it', async () => { + const world = makeWorld(); + const nft = await world.seedCoinless(); + await world.facade.start(); + const gate = world.gate('deliver'); + + const first = world.facade.sendToken({ recipient: '@peer', tokenId: nft.blob.tokenId }); + await gate.reached; + await expect( + world.facade.sendToken({ recipient: '@peer', tokenId: nft.blob.tokenId }) + ).rejects.toThrow(/already reserved/); + + gate.release(); + await first; + }); + + it('the COIN path is untouched: a coin send still selects and still splits', async () => { + const world = makeWorld(); + await world.seedCoinless(); + const coin = await world.seed(100n); + await world.facade.start(); + + const result = await world.facade.send({ recipient: '@peer', amount: '40', coinId: COIN }); + + // The coinless token is not a candidate: selection saw only the coin token. + expect(result.tokenTransfers).toEqual([{ sourceTokenId: coin.blob.tokenId, method: 'split' }]); + }); +}); From 4c57fb32f67fe4d5010350b126af59caca1a6580 Mon Sep 17 00:00:00 2001 From: Pavel Grigorenko Date: Thu, 10 Sep 2026 18:11:23 +0200 Subject: [PATCH 03/12] feat(connect): send_token intent and its own token:transfer scope MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Connect 2.1 → 2.2. Additive: the `send` intent and `transfer:request` scope are untouched, and the handshake gate is MAJOR-only, so no existing dApp is cut off. `send_token` gets its OWN scope rather than reusing `transfer:request`. Mapping it onto the existing one would silently widen every dApp that already holds it — a page granted coin transfers could move an NFT it was never authorised for. The two are independent in both directions, pinned by tests: `transfer:request` does not authorise `send_token`, and `token:transfer` does not authorise `send`. ConnectHost needs no change: an intent frame is `params: Record` forwarded verbatim after the channel gates, so the wallet app's `onIntent` is what interprets it. The protocol surface guard did its job — it failed with "bump SPHERE_CONNECT_VERSION + update EXPECTED", which is exactly the prompt it exists to give. Counts move 14 methods / 6 intents / 13 scopes → 14 / 7 / 14. Verified: 2291 tests green (3 new on scope independence); typecheck, typecheck:tests, lint and build clean. Refs #777. --- connect/permissions.ts | 3 +++ connect/protocol.ts | 6 +++++- tests/unit/connect/lock.test.ts | 6 +++--- tests/unit/connect/protocol-surface.test.ts | 7 +++++-- tests/unit/connect/protocol.test.ts | 22 +++++++++++++++++++-- tests/unit/payments-v2/facade.test.ts | 2 +- 6 files changed, 37 insertions(+), 9 deletions(-) diff --git a/connect/permissions.ts b/connect/permissions.ts index 2449ba34..7d56469f 100644 --- a/connect/permissions.ts +++ b/connect/permissions.ts @@ -23,6 +23,8 @@ export const PERMISSION_SCOPES = { PAYMENT_REQUEST: 'payment:request', SIGN_REQUEST: 'sign:request', MINT_REQUEST: 'mint:request', + /** #777: moving a coinless token. Separate from transfer:request by design. */ + TOKEN_TRANSFER: 'token:transfer', } as const; export type PermissionScope = (typeof PERMISSION_SCOPES)[keyof typeof PERMISSION_SCOPES]; @@ -66,6 +68,7 @@ export const INTENT_PERMISSIONS: Record = { [INTENT_ACTIONS.RECEIVE]: PERMISSION_SCOPES.IDENTITY_READ, [INTENT_ACTIONS.SIGN_MESSAGE]: PERMISSION_SCOPES.SIGN_REQUEST, [INTENT_ACTIONS.MINT]: PERMISSION_SCOPES.MINT_REQUEST, + [INTENT_ACTIONS.SEND_TOKEN]: PERMISSION_SCOPES.TOKEN_TRANSFER, }; // ============================================================================= diff --git a/connect/protocol.ts b/connect/protocol.ts index 0130cd69..5bb99d79 100644 --- a/connect/protocol.ts +++ b/connect/protocol.ts @@ -10,7 +10,7 @@ import { majorOf } from './semver'; // ============================================================================= export const SPHERE_CONNECT_NAMESPACE = 'sphere-connect'; -export const SPHERE_CONNECT_VERSION = '2.1'; // Connect protocol version (semver MAJOR.MINOR) +export const SPHERE_CONNECT_VERSION = '2.2'; // Connect protocol version (semver MAJOR.MINOR) // Default npm-SDK floor a host enforces at the handshake (0.14.1 = the P11 flip: // the v1 payments era is gone; pre-flip ConnectClients expect a wallet that no @@ -60,6 +60,10 @@ export const INTENT_ACTIONS = { RECEIVE: 'receive', SIGN_MESSAGE: 'sign_message', MINT: 'mint', + // #777: params { to, tokenId, memo? }. Distinct from SEND because the addressing + // model differs — a named token, no amount — and so a wallet can grant moving an + // NFT without granting coin transfers. + SEND_TOKEN: 'send_token', } as const; export type IntentAction = (typeof INTENT_ACTIONS)[keyof typeof INTENT_ACTIONS]; diff --git a/tests/unit/connect/lock.test.ts b/tests/unit/connect/lock.test.ts index 7b220877..04ad2eaa 100644 --- a/tests/unit/connect/lock.test.ts +++ b/tests/unit/connect/lock.test.ts @@ -1760,10 +1760,10 @@ describe('ConnectClient.walletProtocol', () => { expect(client.walletProtocol).toBeNull(); }); - it("records this SDK's wallet as 2.1", async () => { + it("records this SDK's wallet as 2.2", async () => { const h = await connectHarness(); expect(h.client.walletProtocol).toBe(SPHERE_CONNECT_VERSION); - expect(h.client.walletProtocol).toBe('2.1'); + expect(h.client.walletProtocol).toBe('2.2'); }); it('records an OLD 2.0 wallet, so a dApp knows wallet:unlocked will never arrive', async () => { @@ -1792,7 +1792,7 @@ describe('ConnectClient.walletProtocol', () => { it('is cleared when the session goes away', async () => { const h = await connectHarness(); - expect(h.client.walletProtocol).toBe('2.1'); + expect(h.client.walletProtocol).toBe('2.2'); await h.client.disconnect(); diff --git a/tests/unit/connect/protocol-surface.test.ts b/tests/unit/connect/protocol-surface.test.ts index cc95ea92..ec02ec9f 100644 --- a/tests/unit/connect/protocol-surface.test.ts +++ b/tests/unit/connect/protocol-surface.test.ts @@ -30,14 +30,17 @@ const BUMP_REMINDER = // answered MODULE_NOT_AVAILABLE), and the consumer gate found zero dApp users. Version // stays 2.1 by owner decision; a removed method now falls through to METHOD_NOT_FOUND. const EXPECTED = { - version: '2.1', + // 2.2: #777 adds the send_token intent + token:transfer scope. Additive, and the + // handshake gate is MAJOR-only, so no existing dApp is cut off. + version: '2.2', intents: [ - 'send', 'dm', 'payment_request', 'receive', 'sign_message', 'mint', + 'send', 'dm', 'payment_request', 'receive', 'sign_message', 'mint', 'send_token', ], scopes: [ 'identity:read', 'balance:read', 'tokens:read', 'history:read', 'events:subscribe', 'resolve:peer', 'transfer:request', 'dm:request', 'dm:read', 'dm:manage', 'payment:request', 'sign:request', 'mint:request', + 'token:transfer', ], methods: [ 'sphere_getIdentity', 'sphere_getBalance', 'sphere_getAssets', diff --git a/tests/unit/connect/protocol.test.ts b/tests/unit/connect/protocol.test.ts index 2562a3db..f605b014 100644 --- a/tests/unit/connect/protocol.test.ts +++ b/tests/unit/connect/protocol.test.ts @@ -11,6 +11,7 @@ import { isSphereConnectMessage, createRequestId, } from '../../../connect/protocol'; +import { hasIntentPermission, PERMISSION_SCOPES } from '../../../connect/permissions'; describe('Protocol', () => { describe('isSphereConnectMessage', () => { @@ -90,8 +91,8 @@ describe('Protocol', () => { }); describe('protocol v2 gate surface', () => { - it('Connect version is bumped to 2.1', () => { - expect(SPHERE_CONNECT_VERSION).toBe('2.1'); + it('Connect version is bumped to 2.2', () => { + expect(SPHERE_CONNECT_VERSION).toBe('2.2'); }); it('has the new error codes', () => { expect(ERROR_CODES.UNSUPPORTED_PROTOCOL_VERSION).toBe(4007); @@ -142,3 +143,20 @@ describe('auto-pushed wallet events', () => { expect(isAutoPushedEvent('')).toBe(false); }); }); + +describe('send_token is gated by its OWN scope (#777)', () => { + it('transfer:request does NOT authorise moving a token', () => { + // The point of a separate scope: a wallet can grant coin transfers without + // granting NFT moves, and vice versa. Mapping send_token onto transfer:request + // would silently widen every dApp that already holds it. + expect(hasIntentPermission(new Set([PERMISSION_SCOPES.TRANSFER_REQUEST]), INTENT_ACTIONS.SEND_TOKEN)).toBe(false); + }); + + it('token:transfer does NOT authorise a coin send', () => { + expect(hasIntentPermission(new Set([PERMISSION_SCOPES.TOKEN_TRANSFER]), INTENT_ACTIONS.SEND)).toBe(false); + }); + + it('token:transfer authorises send_token', () => { + expect(hasIntentPermission(new Set([PERMISSION_SCOPES.TOKEN_TRANSFER]), INTENT_ACTIONS.SEND_TOKEN)).toBe(true); + }); +}); diff --git a/tests/unit/payments-v2/facade.test.ts b/tests/unit/payments-v2/facade.test.ts index 4049ded4..450b887c 100644 --- a/tests/unit/payments-v2/facade.test.ts +++ b/tests/unit/payments-v2/facade.test.ts @@ -836,7 +836,7 @@ describe('PaymentsFacade — sendToken: moving a coinless token (#777)', () => { const gate = world.gate('deliver'); const first = world.facade.sendToken({ recipient: '@peer', tokenId: nft.blob.tokenId }); - await gate.reached; + await vi.waitFor(() => expect(gate.entered).toBe(true)); await expect( world.facade.sendToken({ recipient: '@peer', tokenId: nft.blob.tokenId }) ).rejects.toThrow(/already reserved/); From 80d2c237f42ab006aca5264faea03513d5a09a96 Mon Sep 17 00:00:00 2001 From: Pavel Grigorenko Date: Thu, 10 Sep 2026 18:11:59 +0200 Subject: [PATCH 04/12] docs: sendToken, the discriminated intent, and the Connect 2.2 surface Covers the transfer half of #777 across the API reference, CLAUDE.md's method table and Key Concepts, and the CHANGELOG: why it is a separate verb, why a proven conflict is terminal for a named source, the three gates that keep a valued token out, and why send_token carries its own scope rather than reusing transfer:request. --- CHANGELOG.md | 24 ++++++++++++++++++++++++ CLAUDE.md | 15 ++++++++++++++- docs/API.md | 25 +++++++++++++++++++++++++ 3 files changed, 63 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0fdd7822..48ee0a75 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -35,6 +35,30 @@ from a coin id. An unrecognised type is legitimate and must never cause a token previously mapped over assets, so such an arrival announced `tokens: []` and a UI listening for arrivals saw nothing land. +### Added — transferring a coinless token (#777) + +`payments.sendToken({ recipient, tokenId, memo? })` moves a coinless token whole: one named source, +one direct transfer, never a split. A separate verb rather than a widened `send()` because the +addressing differs — `send()` selects sources to cover an amount and may queue for a combination, +`sendToken` reserves the token you named and never queues, since nothing can free up that helps. + +It is the SAME `TransferMachine`, durable intent, checkpoints, mailbox deposit and applyDelta — +one money path, with the two spends diverging in exactly one function. Three independent gates keep +a valued token out: the mirror (`spendableCoinless`), the reservation ledger (concurrency), and a +re-check of the decoded blob, which is the authority on what a token actually carries. + +A proven conflict is **terminal** for a named source: #625's bounded re-plan exists to pick a +different source after a lost race, and a named token has no alternative. + +The durable intent is now discriminated by a REQUIRED `kind` (`'coin' | 'token'`) on a still-`v:2` +envelope; an absent kind reads as `'coin'` — a migration, not a guess, since it is the only shape +any client wrote. A token intent names exactly one source and can never carry a split, re-checked +on resume rather than trusted across the decrypt boundary. + +Connect 2.1 → 2.2: a `send_token` intent with its own `token:transfer` scope. Additive, and the +handshake gate is MAJOR-only, so no existing dApp is cut off. The scope is deliberately separate — +mapping `send_token` onto `transfer:request` would silently widen every dApp already holding it. + ### 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 diff --git a/CLAUDE.md b/CLAUDE.md index 2c523284..22f0572e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -269,7 +269,8 @@ Typed RPC layer for dApp ↔ wallet communication. Full guide: [`docs/CONNECT.md | `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.send(request)` | `Promise` | Send L3 coin tokens (wallet-api vertical) | +| `sphere.payments.sendToken(request)` | `Promise` | Move a COINLESS token whole (`{recipient, tokenId, memo?}`) | | `sphere.payments.mint(coinIdHex, amount)` | `Promise` | Self-mint via engine (journal-first, no faucet) | | `sphere.payments.receive()` | `Promise<{ transfers }>` | Explicit one-shot mailbox drain | | `sphere.payments.history(page?)` | `Promise` | Paged history (`{ before?, limit? }`) | @@ -756,6 +757,18 @@ authoritative for build success. 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. +- **Transfer** is `sendToken({recipient, tokenId, memo?})`, a separate verb: a coin spend SELECTS + sources for an amount and may queue; a token spend reserves the one it was NAMED and never + queues (nothing can free up that would help). Same `TransferMachine`, same durable intent — one + money path. A proven conflict is TERMINAL: #625's re-plan needs an alternative source and a + named token has none. Three independent gates keep a valued token out (mirror, reservation, and + a re-check of the decoded BLOB, which is the authority on what a token carries). +- The durable intent is discriminated by a REQUIRED `kind` (`'coin' | 'token'`); an ABSENT kind + reads as `'coin'`, the only shape written before #777. A token intent names EXACTLY one source + and can never carry a split — re-checked on resume, because a second leg would let a '0' + remainder complete the intent and report success for a leg that never landed. +- Connect: the `send_token` intent has its OWN `token:transfer` scope (2.1 → 2.2). Reusing + `transfer:request` would silently widen every dApp that already holds it. - 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 diff --git a/docs/API.md b/docs/API.md index 9d4c7423..01200400 100644 --- a/docs/API.md +++ b/docs/API.md @@ -460,6 +460,31 @@ 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`. +### `sendToken(req: { recipient, tokenId, memo? }): Promise` + +Move a **coinless** token whole. All-or-nothing: one named source, one direct transfer, never a +split — there is no amount to divide and no change to return. + +```typescript +const result = await sphere.payments.sendToken({ + recipient: '@bob', // same resolver send() uses + tokenId: nft.tokenId, + memo: 'happy birthday', // optional, recipient-encrypted +}); +``` + +A separate verb rather than a widened `send()` because the addressing model differs: `send()` +selects sources to cover an amount and may queue for a combination that frees up; `sendToken` +reserves the one token you named. + +Refuses — **before any reservation or chain op** — a `tokenId` that is unknown, tombstoned, already +in flight, #625-demoted, or that **carries coin value**. A valued token leaves only through +`send()`, so its coins are always accounted for. + +Unlike a coin send, a proven conflict is **terminal**: #625's re-plan looks for a different source +and a named token has none, so there is nothing to retry. Treat the same possibly-committed rules +as `send()` — never re-issue after `CERTIFICATION_UNCONFIRMED`; `resumeNow()` converges it. + ### `mint(coinIdHex: string, amount: bigint): Promise` Self-mint fungible tokens to this wallet via the token engine (no faucet). **Journal-first**: From a7ee5979048cd1c0a39d09481d1a1bc2cd1cc4e0 Mon Sep 17 00:00:00 2001 From: Pavel Grigorenko Date: Thu, 10 Sep 2026 18:33:50 +0200 Subject: [PATCH 05/12] test: close the three coverage gaps the mutation gate found in the token spend MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit All three survivors were mine and all three were in the money path — each because the test asserted at the wrong layer, not because the probe was wrong. - The #738 fail-closed gate on planToken had no test at all: the facade tests never drive an unproven ledger. Now asserted directly on SpendQueue, both ways. - The blob re-check was unreachable from the facade: spendableCoinless refuses a valued token first, so the mirror gate killed every case before the blob guard ran. Tested against materializeTokenSpend directly, which is the layer the probe mutates — a mirror that says coinless while the blob carries coins. - Nothing drove a conflict on a token send. The new test seeds a SECOND coinless token, so if the re-plan ever ran it would have somewhere to go: moving a token the caller never named is worse than the failure. --- tests/mutation/probes.json | 5 +- tests/unit/payments-v2/facade.test.ts | 28 +++++++ tests/unit/payments-v2/pinned-balance.test.ts | 27 +++++++ tests/unit/payments-v2/send-token.test.ts | 73 +++++++++++++++++++ 4 files changed, 130 insertions(+), 3 deletions(-) create mode 100644 tests/unit/payments-v2/send-token.test.ts diff --git a/tests/mutation/probes.json b/tests/mutation/probes.json index da758fa1..2585a27d 100644 --- a/tests/mutation/probes.json +++ b/tests/mutation/probes.json @@ -1434,8 +1434,7 @@ "find": " if (unproven !== null) {\n throw new SphereError(`Cannot spend yet: ${unproven}`, 'SEND_SYNC_PENDING');", "replace": " if (false as boolean) {\n throw new SphereError(`Cannot spend yet: ${String(unproven)}`, 'SEND_SYNC_PENDING');", "tests": [ - "tests/unit/payments-v2/queue.test.ts", - "tests/unit/payments-v2/facade.test.ts" + "tests/unit/payments-v2/pinned-balance.test.ts" ] }, { @@ -1475,7 +1474,7 @@ "find": " if (token.value !== null) {", "replace": " if (false as boolean) {", "tests": [ - "tests/unit/payments-v2/facade.test.ts" + "tests/unit/payments-v2/send-token.test.ts" ] }, { diff --git a/tests/unit/payments-v2/facade.test.ts b/tests/unit/payments-v2/facade.test.ts index 450b887c..289a6c06 100644 --- a/tests/unit/payments-v2/facade.test.ts +++ b/tests/unit/payments-v2/facade.test.ts @@ -845,6 +845,34 @@ describe('PaymentsFacade — sendToken: moving a coinless token (#777)', () => { await first; }); + it('a proven conflict is TERMINAL for a named source — it never re-plans onto another token', async () => { + // #625's bounded re-plan exists to pick a DIFFERENT source after a lost race. + // A named token has no alternative, so retrying re-picks the same one (or + // nothing) and spins. Two coinless tokens are seeded deliberately: if the + // re-plan ever ran, the SECOND is what it would reach for — and moving a token + // the caller never named would be worse than the failure. + const world = makeWorld(); + const nft = await world.seedCoinless(); + const other = await world.seedCoinless(new TextEncoder().encode('other')); + await world.engine.foreignSpend(nft); // someone else spent it first + await world.facade.start(); + + const err = await world.facade + .sendToken({ recipient: '@peer', tokenId: nft.blob.tokenId }) + .then(() => null, (e: unknown) => e); + + // The caller must learn the token was spent elsewhere. If the re-plan ran, it + // re-picks the now-demoted source and reports "not a spendable coinless + // holding" instead — which hides the real cause behind a confusing one. + expect((err as Error).message).toMatch(/already consumed|conflict/i); + expect((err as Error).message).not.toMatch(/not a spendable coinless holding/); + + // Nothing was deposited, and the untouched token stayed untouched. + const mailbox = await world.api.listMailbox(peerCaller, 0); + expect(mailbox.entries.map((e) => e.tokenId)).not.toContain(other.blob.tokenId); + expect(world.facade.coinless().map((t) => t.tokenId)).toContain(other.blob.tokenId); + }); + it('the COIN path is untouched: a coin send still selects and still splits', async () => { const world = makeWorld(); await world.seedCoinless(); diff --git a/tests/unit/payments-v2/pinned-balance.test.ts b/tests/unit/payments-v2/pinned-balance.test.ts index 4809c45c..7dc75669 100644 --- a/tests/unit/payments-v2/pinned-balance.test.ts +++ b/tests/unit/payments-v2/pinned-balance.test.ts @@ -153,6 +153,33 @@ describe('#738 review: the held-set gate fails CLOSED', () => { ); }); + it('#777: a NAMED token spend is refused too while the ledger is unproven', async () => { + // planToken bypasses freeView(), where the #738 gate lives for coin spends, so + // it has to apply the gate itself — otherwise a restart could double-spend a + // token an open intent already holds, which is exactly what the gate prevents. + const ledger = new ReservationLedger(); + expect(ledger.unprovenReason()).not.toBeNull(); + const queue = new SpendQueue({ + ledger, + getPool: () => [], + spendableCoinless: () => true, + }); + expect(() => queue.planToken('r1', 'nft-1')).toThrow(/spending is paused|cannot spend yet/i); + }); + + it('#777: once the ledger is proven, the same named token plans and reserves', () => { + const ledger = new ReservationLedger(); + ledger.setAuthoritative(true); + expect(ledger.unprovenReason()).toBeNull(); + const queue = new SpendQueue({ + ledger, + getPool: () => [], + spendableCoinless: (id) => id === 'nft-1', + }); + expect(queue.planToken('r1', 'nft-1').plan.direct).toEqual(['nft-1']); + expect(ledger.holderOf('nft-1')).toBe('r1'); + }); + it('a backstop read failure leaves the ledger unproven instead of re-offering held sources', async () => { const { pins, ledger } = makePins(async () => { throw new Error('IndexedDB unavailable'); diff --git a/tests/unit/payments-v2/send-token.test.ts b/tests/unit/payments-v2/send-token.test.ts new file mode 100644 index 00000000..b2e15293 --- /dev/null +++ b/tests/unit/payments-v2/send-token.test.ts @@ -0,0 +1,73 @@ +/** + * #777: the BLOB is the authority on what a source carries. + * + * `spendableCoinless` reads the MIRROR, which is the server's index. If the two + * ever disagree — a stale row, a backend that indexed a payload it could not read, + * a reclassification — the mirror saying "coinless" while the blob carries coins is + * exactly the case where a valued token would move with its coins unaccounted for. + * The facade-level tests are killed by the mirror gate first, so this exercises the + * re-check directly. + */ + +import { describe, expect, it, vi } from 'vitest'; + +import { SphereError } from '../../../core/errors'; +import type { ITokenEngine } from '../../../token-engine/engine'; +import type { SphereToken } from '../../../token-engine/types'; +import { materializeTokenSpend } from '../../../modules/payments-v2/send-token'; + +const TOKEN_ID = 'aa'.repeat(32); +const RECIPIENT = '02'.repeat(16) + '03'; + +function engineWith(value: SphereToken['value']): ITokenEngine { + return { + decodeToken: vi.fn(async () => ({ value, blob: { tokenId: TOKEN_ID } }) as unknown as SphereToken), + deliveryKeys: vi.fn(async () => ({ tokenId: TOKEN_ID, stateHash: 'S1' })), + } as unknown as ITokenEngine; +} + +const storagePort = { + getBlobs: vi.fn(async () => new Map([[TOKEN_ID, new Uint8Array([1, 2, 3])]])), +}; + +const input = { + transferId: 'a1111111-1111-4111-8111-111111111111', + recipientPubkey: RECIPIENT, + request: { recipient: '@peer', tokenId: TOKEN_ID }, + sourceIds: [TOKEN_ID], +}; + +describe('materializeTokenSpend', () => { + it('REFUSES a source whose blob carries coin value, even when the mirror called it coinless', async () => { + const deps = { engine: engineWith({ assets: [{ coinId: 'bb'.repeat(32), amount: 5n }] }), storagePort }; + await expect(materializeTokenSpend(deps, input)).rejects.toThrow(/carries coin value/); + }); + + it('names send() in the refusal, so the caller knows which verb moves it', async () => { + const deps = { engine: engineWith({ assets: [{ coinId: 'bb'.repeat(32), amount: 5n }] }), storagePort }; + const err = await materializeTokenSpend(deps, input).then(() => null, (e: unknown) => e); + expect(err).toBeInstanceOf(SphereError); + expect((err as SphereError).message).toMatch(/use send\(\)/); + }); + + it('plans exactly ONE direct op for a genuinely coinless source — never a split', async () => { + const deps = { engine: engineWith(null), storagePort }; + const ctx = await materializeTokenSpend(deps, input); + expect(ctx.plan.ops).toHaveLength(1); + expect(ctx.plan.ops[0]?.kind).toBe('direct'); + expect(ctx.plan.payload.kind).toBe('token'); + expect(ctx.plan.payload.direct).toEqual([TOKEN_ID]); + }); + + it('reports no coin and no in-flight coin rows: a coinless spend has no amount to show', async () => { + const deps = { engine: engineWith(null), storagePort }; + const ctx = await materializeTokenSpend(deps, input); + expect(ctx.coinId).toBe(''); + expect(ctx.sourceTokens).toEqual([]); + }); + + it('refuses when the blob is missing rather than planning a spend of nothing', async () => { + const deps = { engine: engineWith(null), storagePort: { getBlobs: vi.fn(async () => new Map()) } }; + await expect(materializeTokenSpend(deps, input)).rejects.toThrow(/no blob in storage/); + }); +}); From a2d626f7244589707b352142533b1c880cd43955 Mon Sep 17 00:00:00 2001 From: Pavel Grigorenko Date: Thu, 10 Sep 2026 18:59:47 +0200 Subject: [PATCH 06/12] fix(payments-v2): resume refuses an intent it cannot safely execute MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both from `codex exec review` of the transfer branch, and both in the resume path — which is the one I built reader-first specifically to be safe, so they matter more than their P2 label suggests. **An EXPLICIT unknown `kind` migrated to 'coin'.** Only an ABSENT discriminant may do that: it is the one shape written before #777. An explicit unknown one — a newer client's payload, or a corrupted one — was falling through to coin semantics it was never written for, and a coin-SHAPED payload makes the discriminant the only thing telling them apart. Now refused; the intent stays open and untouched rather than executing under the wrong meaning. **Resume did not re-check the blob.** `materializeTokenSpend` treats the decoded blob as the authority on what a source carries, but the resume path decoded its sources and went straight to transferring them. A durable intent labelled `kind: 'token'` whose named source actually holds coins would have moved them while the history row for it records `assets: []` — value moved, nothing accounted for. `runOne` now applies the same check and fails closed, which is what it already does for a missing source. Verified: 3 new tests staging raw intents the way a foreign client would write them (an explicit unknown kind, an absent kind still migrating, and a token intent naming a valued source); 2 new mutation probes; full suite green. Refs #777. --- modules/payments-v2/machine/resume.ts | 20 +++++ tests/mutation/probes.json | 20 +++++ tests/unit/payments-v2/machine-resume.test.ts | 87 ++++++++++++++++++- 3 files changed, 126 insertions(+), 1 deletion(-) diff --git a/modules/payments-v2/machine/resume.ts b/modules/payments-v2/machine/resume.ts index 6642771e..13aeb185 100644 --- a/modules/payments-v2/machine/resume.ts +++ b/modules/payments-v2/machine/resume.ts @@ -168,6 +168,17 @@ async function runOne(ctx: RunCtx, job: ResumeJob, report: ResumeReport): Promis report.failed.push(job.transferId); // fail closed: intent stays open, untouched return; } + // The blob is the authority on what a source carries — the same rule the send + // path applies at materialize. A durable intent labelled 'token' whose named + // source actually holds coins would move them while history records assets: []. + if (job.payload.kind === 'token' && token.value !== null) { + logger.warn( + 'PaymentsV2', + `resume: token intent ${job.transferId} names a source carrying coin value — refusing` + ); + report.failed.push(job.transferId); + return; + } mine.set(op.sourceTokenId, token); } try { @@ -256,7 +267,16 @@ function validatePayload(raw: unknown): IntentPayload { // An ABSENT kind is the only shape written before #777, and it was always a coin // spend — so defaulting is a migration, not a guess. Anything written since // carries the discriminant, because the type makes omitting it a compile error. + // ABSENT migrates to 'coin' (the only shape written before #777). An EXPLICIT + // unknown one does NOT: a payload from a newer client, or a corrupted one, would + // otherwise execute under coin semantics it was never written for. const kind = p.kind ?? 'coin'; + if (kind !== 'coin' && kind !== 'token') { + throw new SphereError( + `unsupported intent kind '${String(kind)}' — not resumable by this client`, + 'VALIDATION_ERROR' + ); + } return kind === 'token' ? validateTokenPayload(p as Partial) : validateCoinPayload(p as Partial); diff --git a/tests/mutation/probes.json b/tests/mutation/probes.json index 2585a27d..06a92b60 100644 --- a/tests/mutation/probes.json +++ b/tests/mutation/probes.json @@ -1486,5 +1486,25 @@ "tests": [ "tests/unit/payments-v2/facade.test.ts" ] + }, + { + "name": "resume-migrates-unknown-kind-to-coin", + "note": "#777: only an ABSENT kind migrates to 'coin'. An EXPLICIT unknown one (a newer client's payload, or a corrupted one) must be refused rather than executed under coin semantics it was never written for.", + "file": "modules/payments-v2/machine/resume.ts", + "find": " if (kind !== 'coin' && kind !== 'token') {", + "replace": " if (false as boolean) {", + "tests": [ + "tests/unit/payments-v2/machine-resume.test.ts" + ] + }, + { + "name": "resume-skips-blob-value-authority", + "note": "#777: the blob is the authority on resume too. Without this a durable intent labelled 'token' whose named source carries coins moves them while history records assets: [] \u2014 value moved, nothing accounted.", + "file": "modules/payments-v2/machine/resume.ts", + "find": " if (job.payload.kind === 'token' && token.value !== null) {", + "replace": " if (false as boolean) {", + "tests": [ + "tests/unit/payments-v2/machine-resume.test.ts" + ] } ] diff --git a/tests/unit/payments-v2/machine-resume.test.ts b/tests/unit/payments-v2/machine-resume.test.ts index 40e5fc23..20b2220a 100644 --- a/tests/unit/payments-v2/machine-resume.test.ts +++ b/tests/unit/payments-v2/machine-resume.test.ts @@ -5,7 +5,7 @@ import { resumeAll } from '../../../modules/payments-v2/machine/resume'; import { createMachineStores } from '../../../modules/payments-v2/machine/journal'; import { STORE_KEYS } from '../../../modules/payments-v2/stores'; import { FakeApiError } from './fakes/FakeWalletApi'; -import { makeWorld, type World } from './machine-harness'; +import { COIN, makeWorld, type World } from './machine-harness'; const fail500 = async (): Promise => { throw new FakeApiError(500, 'INTERNAL', 'injected outage'); @@ -309,3 +309,88 @@ describe('TransferMachine resume path (§5.5 P6 — same machine, rehydrated)', expect(w.applied.length).toBe(applies); }); }); + +describe('resume refuses a durable intent it cannot safely execute (#777)', () => { + /** Stage a raw intent the way a foreign/newer client would have written it. */ + async function stageRawIntent(w: World, transferId: string, payload: unknown): Promise { + await w.api.putIntent(w.caller, { + transferId, + payload: `enc:${JSON.stringify(payload)}`, + requiresSeedClose: false, + }); + } + + it('refuses an EXPLICIT unknown kind rather than running it under coin semantics', async () => { + const w = makeWorld(); + const token = await w.seed(1000n); + const id = 'c0000000-0000-4000-8000-000000000001'; + // Coin-SHAPED, so only the discriminant tells it apart. Migrating this to + // 'coin' would execute a payload written for semantics this client lacks. + await stageRawIntent(w, id, { + v: 2, + kind: 'swap', + recipient: w.recipientHex, + coinId: COIN, + amount: '1000', + direct: [token.blob.tokenId], + }); + + const report = await resumeAll(w.deps); + + expect(report.failed).toContain(id); + expect(report.resumed).not.toContain(id); + expect(w.engine.transferCalls).toHaveLength(0); + expect(w.api.inspectIntent(w.caller, id)?.status).toBe('open'); + }); + + it('still migrates an ABSENT kind to coin — the only shape written before #777', async () => { + const w = makeWorld(); + const token = await w.seed(1000n); + const id = 'c0000000-0000-4000-8000-000000000002'; + await stageRawIntent(w, id, { + v: 2, + recipient: w.recipientHex, + coinId: COIN, + amount: '1000', + direct: [token.blob.tokenId], + spentStates: { + [token.blob.tokenId]: await stateOfToken(w, token), + }, + }); + + const report = await resumeAll(w.deps); + + expect(report.failed).not.toContain(id); + }); + + it('refuses a token intent whose named source actually carries COINS', async () => { + // The blob is the authority. Executing this would move real coins while the + // history row for it records `assets: []` — value moved, nothing accounted. + const w = makeWorld(); + const valued = await w.seed(1000n); + const id = 'c0000000-0000-4000-8000-000000000003'; + await stageRawIntent(w, id, { + v: 2, + kind: 'token', + recipient: w.recipientHex, + direct: [valued.blob.tokenId], + spentStates: { + [valued.blob.tokenId]: await stateOfToken(w, valued), + }, + }); + + const report = await resumeAll(w.deps); + + expect(report.failed).toContain(id); + expect(w.engine.transferCalls).toHaveLength(0); + expect((await w.api.listMailbox(w.recipientCaller)).entries).toHaveLength(0); + }); +}); + +async function stateOfToken( + w: World, + token: { blob: { token: Uint8Array } } +): Promise<{ local: string; protocol: string }> { + const keys = await w.engine.deliveryKeys(token.blob.token); + return { local: keys.stateHash, protocol: keys.stateHash }; +} From 25b7a5a59fde045ec1afda06df18a2fa692e49c1 Mon Sep 17 00:00:00 2001 From: Pavel Grigorenko Date: Thu, 10 Sep 2026 19:23:45 +0200 Subject: [PATCH 07/12] fix(payments-v2): export SendTokenRequest from the module barrel From the review. `sendToken` is on the public `PaymentsV2` surface but its request type was not re-exported, so a consumer of the `./payments-v2" subpath could see the method and not type a call to it. `CoinlessToken` was already reachable via the root `export * from './types'`; only this one was missing. Verified against the BUILT declarations, not just the source. --- modules/payments-v2/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/modules/payments-v2/index.ts b/modules/payments-v2/index.ts index 90a909c0..052d14f5 100644 --- a/modules/payments-v2/index.ts +++ b/modules/payments-v2/index.ts @@ -1,4 +1,4 @@ -export type { PaymentsV2, PaymentsV2Events, ConnectionStatus, SendRequest, MintResult, HistoryEntry, HistoryPage, PaymentRequestView, PaymentRequestStatus, PaymentsRequestsApi, PendingTransfer } from './api'; +export type { PaymentsV2, PaymentsV2Events, ConnectionStatus, SendRequest, SendTokenRequest, MintResult, HistoryEntry, HistoryPage, PaymentRequestView, PaymentRequestStatus, PaymentsRequestsApi, PendingTransfer } from './api'; export type { StoragePort, DeliveryPort, From aff5000ee5daee8e8c199e1c3ecf20ec2bf5c20c Mon Sep 17 00:00:00 2001 From: Pavel Grigorenko Date: Fri, 11 Sep 2026 10:37:58 +0200 Subject: [PATCH 08/12] =?UTF-8?q?test(e2e):=20move=20a=20real=20coinless?= =?UTF-8?q?=20token=20A=E2=86=92B=20on=20testnet2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `sendToken` had no e2e coverage at all. Everything about the transfer was proven against fakes plus the aggregator-backed engine — which shows this client is self-consistent, never that it and the service agree. Two wallets, a real testnet2-certified coinless token with the registry's non-fungible type, moved through the deployed backend: - the send certifies on-chain and reports one direct leg, never a split - B ACCEPTING it implies the full real trust-base verify + isOwnedBy passed, since Receive screens before it stores or claims - the payload comes back byte-identical through a DIFFERENT wallet's blob fetch — the round trip survives CBOR encode, content addressing, S3, the mailbox, the claim and a second wallet's decode - the token leaves A, and A's balance is untouched throughout - a VALUED token is refused by sendToken against the real backend, before any chain op: the coin is still spendable afterwards Verified: 5/5 green against wallet-api staging on testnet2. --- tests/e2e/coinless-tokens.staging.e2e.test.ts | 96 +++++++++++++++++++ 1 file changed, 96 insertions(+) diff --git a/tests/e2e/coinless-tokens.staging.e2e.test.ts b/tests/e2e/coinless-tokens.staging.e2e.test.ts index 811776e5..90ea8d0c 100644 --- a/tests/e2e/coinless-tokens.staging.e2e.test.ts +++ b/tests/e2e/coinless-tokens.staging.e2e.test.ts @@ -22,9 +22,12 @@ import type { SphereToken } from '../../token-engine/types'; import { RUN_STAGING } from './support/staging'; import { + activeRows, + drainUntil, logStep, makeVerticalWallet, shutdownVerticalWallets, + waitFor, type VWallet, } from './support/vertical'; @@ -168,3 +171,96 @@ describe.skipIf(!RUN_STAGING)('coinless tokens — live staging', () => { 900_000 ); }); + +describe.skipIf(!RUN_STAGING)('coinless tokens — transfer, live staging', () => { + afterAll(async () => { + await shutdownVerticalWallets(); + }); + + async function mintAndIndex(w: VWallet, data: Uint8Array): Promise { + const token = await w.engine.mintDataToken({ + recipientPubkey: hexToBytes(w.identity.chainPubkey), + data, + 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 }], + }); + logStep(`indexed coinless ${token.blob.tokenId.slice(0, 12)}…`); + return token; + } + + it( + 'moves a REAL coinless token A→B: certified on testnet2, verified by B before it enters inventory', + async () => { + let a = await makeVerticalWallet('nft-a'); + const b = await makeVerticalWallet('nft-b'); + const nft = await mintAndIndex(a, NFT_PAYLOAD); + + // Reopen so A's mirror sees the freshly indexed row. + await a.facade.stop().catch(() => undefined); + a = await makeVerticalWallet('nft-a', { identity: a.identity, kv: a.kv }); + expect(a.facade.coinless().map((t) => t.tokenId)).toContain(nft.blob.tokenId); + + const result = await a.facade.sendToken({ + recipient: b.identity.chainPubkey, + tokenId: nft.blob.tokenId, + }); + expect(['delivered', 'confirmed']).toContain(result.status); + expect(result.tokenTransfers).toEqual([ + { sourceTokenId: nft.blob.tokenId, method: 'direct' }, + ]); + + // B accepting implies the FULL real trust-base verify + isOwnedBy passed — + // Receive screens before it stores or claims. + await drainUntil( + b, + () => b.facade.coinless().some((t) => t.tokenId === nft.blob.tokenId), + 120_000, + 'B receives the coinless token' + ); + expect((await activeRows(b)).map((r) => r.tokenId)).toContain(nft.blob.tokenId); + + // The payload survived the whole round trip, byte for byte, through a + // DIFFERENT wallet's blob fetch. + expect(await b.facade.tokenData(nft.blob.tokenId)).toEqual(NFT_PAYLOAD); + + // …and it left A. + await waitFor( + a, + () => !a.facade.coinless().some((t) => t.tokenId === nft.blob.tokenId), + 90_000, + 'A no longer holds the token' + ); + expect(await a.facade.assets()).toEqual([]); + }, + 900_000 + ); + + it( + 'refuses to move a VALUED token through sendToken, against the real backend', + async () => { + const w = await makeVerticalWallet('nft-refuse'); + const mint = await w.facade.mint(HARNESS_COIN, 250n); + if (!mint.success || mint.tokenId === undefined) { + throw new Error(`valued mint failed: ${mint.error ?? 'unknown'}`); + } + + await expect( + w.facade.sendToken({ recipient: w.identity.chainPubkey, tokenId: mint.tokenId }) + ).rejects.toThrow(/not a spendable coinless holding|carries coin value/); + + // Refused BEFORE any chain op: the coin is still spendable. + const assets = await w.facade.assets(); + expect(assets[0]?.totalAmount).toBe('250'); + }, + 600_000 + ); +}); From 39967ee3eb9ee05d2bfeceb25b4dc250eb984cfc Mon Sep 17 00:00:00 2001 From: Pavel Grigorenko Date: Fri, 11 Sep 2026 11:19:10 +0200 Subject: [PATCH 09/12] refactor(connect)!: name the NFT intent and scope for what they move MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `token:transfer` sitting next to `transfer:request` distinguished nothing — the two read as the same thing, and a permission scope appears in a consent dialog where it has to be self-evident. A code comment is the wrong place to carry that. `send_token` had the same defect from the other side: coins are tokens too, so "send token" does not say which kind moves. INTENT_ACTIONS.SEND_TOKEN 'send_token' -> SEND_NFT 'send_nft' PERMISSION_SCOPES.TOKEN_TRANSFER 'token:transfer' -> NFT_TRANSFER 'nft:transfer' `TOKEN_TRANSFER` was also already taken in this repo: it named the removed Nostr event kind 31113 (docs/LEGACY-INVENTORY.md), so the constant collided with prior art that still appears in a test assertion. Pre-release: Connect 2.2 has not shipped, so nothing is renamed out from under a dApp. The spec's "coinless, never non-fungible" rule is about naming the PROPERTY in prose — for a scope a user reads in a consent prompt, "nft" is the word that communicates. Verified: full suite green; the protocol surface guard's EXPECTED updated with it. --- CHANGELOG.md | 6 ++++-- CLAUDE.md | 6 ++++-- connect/permissions.ts | 6 +++--- connect/protocol.ts | 7 +++---- tests/unit/connect/protocol-surface.test.ts | 6 +++--- tests/unit/connect/protocol.test.ts | 16 ++++++++-------- 6 files changed, 25 insertions(+), 22 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 48ee0a75..2a54aa59 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -55,9 +55,11 @@ envelope; an absent kind reads as `'coin'` — a migration, not a guess, since i any client wrote. A token intent names exactly one source and can never carry a split, re-checked on resume rather than trusted across the decrypt boundary. -Connect 2.1 → 2.2: a `send_token` intent with its own `token:transfer` scope. Additive, and the +Connect 2.1 → 2.2: a `send_nft` intent with its own `nft:transfer` scope. Additive, and the handshake gate is MAJOR-only, so no existing dApp is cut off. The scope is deliberately separate — -mapping `send_token` onto `transfer:request` would silently widen every dApp already holding it. +mapping it onto `transfer:request` would silently widen every dApp already holding that. Both are +named *nft* rather than *token* because coins are tokens too: `token:transfer` next to +`transfer:request` says nothing about which one moves what. ### Fixed — a corrupt value envelope no longer reads as "no value" (#778) diff --git a/CLAUDE.md b/CLAUDE.md index 22f0572e..7fb80391 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -767,8 +767,10 @@ authoritative for build success. reads as `'coin'`, the only shape written before #777. A token intent names EXACTLY one source and can never carry a split — re-checked on resume, because a second leg would let a '0' remainder complete the intent and report success for a leg that never landed. -- Connect: the `send_token` intent has its OWN `token:transfer` scope (2.1 → 2.2). Reusing - `transfer:request` would silently widen every dApp that already holds it. +- Connect: the `send_nft` intent has its OWN `nft:transfer` scope (2.1 → 2.2). Reusing + `transfer:request` would silently widen every dApp that already holds it. Named *nft*, not + *token*: coins are tokens too, so `token:transfer` beside `transfer:request` distinguishes + nothing (and `TOKEN_TRANSFER` already named the removed Nostr kind 31113). - 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 diff --git a/connect/permissions.ts b/connect/permissions.ts index 7d56469f..51716bdf 100644 --- a/connect/permissions.ts +++ b/connect/permissions.ts @@ -23,8 +23,8 @@ export const PERMISSION_SCOPES = { PAYMENT_REQUEST: 'payment:request', SIGN_REQUEST: 'sign:request', MINT_REQUEST: 'mint:request', - /** #777: moving a coinless token. Separate from transfer:request by design. */ - TOKEN_TRANSFER: 'token:transfer', + /** #777: moving a coinless token (an NFT). Distinct from transfer:request. */ + NFT_TRANSFER: 'nft:transfer', } as const; export type PermissionScope = (typeof PERMISSION_SCOPES)[keyof typeof PERMISSION_SCOPES]; @@ -68,7 +68,7 @@ export const INTENT_PERMISSIONS: Record = { [INTENT_ACTIONS.RECEIVE]: PERMISSION_SCOPES.IDENTITY_READ, [INTENT_ACTIONS.SIGN_MESSAGE]: PERMISSION_SCOPES.SIGN_REQUEST, [INTENT_ACTIONS.MINT]: PERMISSION_SCOPES.MINT_REQUEST, - [INTENT_ACTIONS.SEND_TOKEN]: PERMISSION_SCOPES.TOKEN_TRANSFER, + [INTENT_ACTIONS.SEND_NFT]: PERMISSION_SCOPES.NFT_TRANSFER, }; // ============================================================================= diff --git a/connect/protocol.ts b/connect/protocol.ts index 5bb99d79..204b39bf 100644 --- a/connect/protocol.ts +++ b/connect/protocol.ts @@ -60,10 +60,9 @@ export const INTENT_ACTIONS = { RECEIVE: 'receive', SIGN_MESSAGE: 'sign_message', MINT: 'mint', - // #777: params { to, tokenId, memo? }. Distinct from SEND because the addressing - // model differs — a named token, no amount — and so a wallet can grant moving an - // NFT without granting coin transfers. - SEND_TOKEN: 'send_token', + // #777: params { to, tokenId, memo? }. Named NFT rather than 'send_token': + // coins are tokens too, so 'token' does not say which kind moves. + SEND_NFT: 'send_nft', } as const; export type IntentAction = (typeof INTENT_ACTIONS)[keyof typeof INTENT_ACTIONS]; diff --git a/tests/unit/connect/protocol-surface.test.ts b/tests/unit/connect/protocol-surface.test.ts index ec02ec9f..5fffe7a2 100644 --- a/tests/unit/connect/protocol-surface.test.ts +++ b/tests/unit/connect/protocol-surface.test.ts @@ -30,17 +30,17 @@ const BUMP_REMINDER = // answered MODULE_NOT_AVAILABLE), and the consumer gate found zero dApp users. Version // stays 2.1 by owner decision; a removed method now falls through to METHOD_NOT_FOUND. const EXPECTED = { - // 2.2: #777 adds the send_token intent + token:transfer scope. Additive, and the + // 2.2: #777 adds the send_nft intent + nft:transfer scope. Additive, and the // handshake gate is MAJOR-only, so no existing dApp is cut off. version: '2.2', intents: [ - 'send', 'dm', 'payment_request', 'receive', 'sign_message', 'mint', 'send_token', + 'send', 'dm', 'payment_request', 'receive', 'sign_message', 'mint', 'send_nft', ], scopes: [ 'identity:read', 'balance:read', 'tokens:read', 'history:read', 'events:subscribe', 'resolve:peer', 'transfer:request', 'dm:request', 'dm:read', 'dm:manage', 'payment:request', 'sign:request', 'mint:request', - 'token:transfer', + 'nft:transfer', ], methods: [ 'sphere_getIdentity', 'sphere_getBalance', 'sphere_getAssets', diff --git a/tests/unit/connect/protocol.test.ts b/tests/unit/connect/protocol.test.ts index f605b014..db399b99 100644 --- a/tests/unit/connect/protocol.test.ts +++ b/tests/unit/connect/protocol.test.ts @@ -144,19 +144,19 @@ describe('auto-pushed wallet events', () => { }); }); -describe('send_token is gated by its OWN scope (#777)', () => { - it('transfer:request does NOT authorise moving a token', () => { +describe('send_nft is gated by its OWN scope (#777)', () => { + it('transfer:request does NOT authorise moving an NFT', () => { // The point of a separate scope: a wallet can grant coin transfers without - // granting NFT moves, and vice versa. Mapping send_token onto transfer:request + // granting NFT moves, and vice versa. Mapping send_nft onto transfer:request // would silently widen every dApp that already holds it. - expect(hasIntentPermission(new Set([PERMISSION_SCOPES.TRANSFER_REQUEST]), INTENT_ACTIONS.SEND_TOKEN)).toBe(false); + expect(hasIntentPermission(new Set([PERMISSION_SCOPES.TRANSFER_REQUEST]), INTENT_ACTIONS.SEND_NFT)).toBe(false); }); - it('token:transfer does NOT authorise a coin send', () => { - expect(hasIntentPermission(new Set([PERMISSION_SCOPES.TOKEN_TRANSFER]), INTENT_ACTIONS.SEND)).toBe(false); + it('nft:transfer does NOT authorise a coin send', () => { + expect(hasIntentPermission(new Set([PERMISSION_SCOPES.NFT_TRANSFER]), INTENT_ACTIONS.SEND)).toBe(false); }); - it('token:transfer authorises send_token', () => { - expect(hasIntentPermission(new Set([PERMISSION_SCOPES.TOKEN_TRANSFER]), INTENT_ACTIONS.SEND_TOKEN)).toBe(true); + it('nft:transfer authorises send_nft', () => { + expect(hasIntentPermission(new Set([PERMISSION_SCOPES.NFT_TRANSFER]), INTENT_ACTIONS.SEND_NFT)).toBe(true); }); }); From 569be297cd1ceb5d70ba0a4f692eab85c86dd031 Mon Sep 17 00:00:00 2001 From: Pavel Grigorenko Date: Fri, 11 Sep 2026 11:29:06 +0200 Subject: [PATCH 10/12] refactor(payments-v2)!: say "coinless", never "token", for a token that names no coin MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-through on the Connect rename: the same defect ran through the SDK side. "Token" was used to mean "coinless token" in a dozen places, and coins are tokens too — so the name distinguished nothing, exactly as `token:transfer` beside `transfer:request` did not. kind: 'coin' | 'token' -> 'coin' | 'coinless' (durable intent field) sendToken -> sendCoinless SendTokenRequest -> SendCoinlessRequest planToken -> planCoinless materializeTokenSpend -> materializeCoinlessSpend buildTokenPayload -> buildCoinlessPayload TokenIntentPayload -> CoinlessIntentPayload isTokenIntent -> isCoinlessIntent TokenSpendDeps/Input -> CoinlessSpendDeps/Input send-token.ts -> send-coinless.ts The discriminant is why this could not wait. It is DURABLE state — field-encrypted into the intent payload the server stores, and read back on resume — so renaming it after a release is a data migration, not a refactor. Nothing has shipped and the only `kind: 'token'` payloads that exist came from this branch's own staging runs, so the change is free exactly now and never again. One vocabulary per audience: the SDK says *coinless*, matching wallet-api's spec rule, and the Connect wire keeps *nft* (`send_nft`, `nft:transfer`) because a consent prompt is read by a person, for whom "coinless" communicates nothing. Unchanged deliberately: `tokenData(tokenId)` works on ANY token and is named correctly, and `Token`/`tokens()` keep meaning coin tokens — that ambiguity predates this work and renaming it is a breaking change for every consumer. Verified: 2302 tests green; typecheck, typecheck:tests and lint clean. The historical CHANGELOG entries about the removed `sendTokenTransfer` transport member were deliberately left alone. --- CHANGELOG.md | 11 +++++++--- CLAUDE.md | 6 ++--- docs/API.md | 6 ++--- modules/payments-v2/PaymentsFacade.ts | 22 +++++++++---------- modules/payments-v2/api.ts | 4 ++-- modules/payments-v2/compose.ts | 2 +- modules/payments-v2/convergence.ts | 2 +- modules/payments-v2/index.ts | 2 +- modules/payments-v2/machine/payload-view.ts | 6 ++--- modules/payments-v2/machine/payload.ts | 8 +++---- modules/payments-v2/machine/resume.ts | 16 +++++++------- modules/payments-v2/machine/types.ts | 6 ++--- modules/payments-v2/select/queue.ts | 2 +- .../{send-token.ts => send-coinless.ts} | 20 ++++++++--------- tests/e2e/coinless-tokens.staging.e2e.test.ts | 6 ++--- tests/mutation/probes.json | 4 ++-- tests/unit/payments-v2/facade.test.ts | 18 +++++++-------- tests/unit/payments-v2/machine-resume.test.ts | 2 +- tests/unit/payments-v2/pinned-balance.test.ts | 6 ++--- ...nd-token.test.ts => send-coinless.test.ts} | 16 +++++++------- 20 files changed, 85 insertions(+), 80 deletions(-) rename modules/payments-v2/{send-token.ts => send-coinless.ts} (81%) rename tests/unit/payments-v2/{send-token.test.ts => send-coinless.test.ts} (82%) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2a54aa59..f852ea7e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -37,10 +37,10 @@ arrivals saw nothing land. ### Added — transferring a coinless token (#777) -`payments.sendToken({ recipient, tokenId, memo? })` moves a coinless token whole: one named source, +`payments.sendCoinless({ recipient, tokenId, memo? })` moves a coinless token whole: one named source, one direct transfer, never a split. A separate verb rather than a widened `send()` because the addressing differs — `send()` selects sources to cover an amount and may queue for a combination, -`sendToken` reserves the token you named and never queues, since nothing can free up that helps. +`sendCoinless` reserves the token you named and never queues, since nothing can free up that helps. It is the SAME `TransferMachine`, durable intent, checkpoints, mailbox deposit and applyDelta — one money path, with the two spends diverging in exactly one function. Three independent gates keep @@ -50,11 +50,16 @@ re-check of the decoded blob, which is the authority on what a token actually ca A proven conflict is **terminal** for a named source: #625's bounded re-plan exists to pick a different source after a lost race, and a named token has no alternative. -The durable intent is now discriminated by a REQUIRED `kind` (`'coin' | 'token'`) on a still-`v:2` +The durable intent is now discriminated by a REQUIRED `kind` (`'coin' | 'coinless'`) on a still-`v:2` envelope; an absent kind reads as `'coin'` — a migration, not a guess, since it is the only shape any client wrote. A token intent names exactly one source and can never carry a split, re-checked on resume rather than trusted across the decrypt boundary. +**Naming**: the SDK says *coinless* throughout (`CoinlessToken`, `coinless()`, `sendCoinless`, +`kind: 'coinless'`), matching wallet-api's spec rule. The Connect wire says *nft* (`send_nft`, +`nft:transfer`) because that surface is read by a human in a consent prompt, where "coinless" would +not communicate. "Token" is never used to mean "coinless token": coins are tokens too. + Connect 2.1 → 2.2: a `send_nft` intent with its own `nft:transfer` scope. Additive, and the handshake gate is MAJOR-only, so no existing dApp is cut off. The scope is deliberately separate — mapping it onto `transfer:request` would silently widen every dApp already holding that. Both are diff --git a/CLAUDE.md b/CLAUDE.md index 7fb80391..31c02fb5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -270,7 +270,7 @@ Typed RPC layer for dApp ↔ wallet communication. Full guide: [`docs/CONNECT.md | `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 coin tokens (wallet-api vertical) | -| `sphere.payments.sendToken(request)` | `Promise` | Move a COINLESS token whole (`{recipient, tokenId, memo?}`) | +| `sphere.payments.sendCoinless(request)` | `Promise` | Move a COINLESS token whole (`{recipient, tokenId, memo?}`) | | `sphere.payments.mint(coinIdHex, amount)` | `Promise` | Self-mint via engine (journal-first, no faucet) | | `sphere.payments.receive()` | `Promise<{ transfers }>` | Explicit one-shot mailbox drain | | `sphere.payments.history(page?)` | `Promise` | Paged history (`{ before?, limit? }`) | @@ -757,13 +757,13 @@ authoritative for build success. 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. -- **Transfer** is `sendToken({recipient, tokenId, memo?})`, a separate verb: a coin spend SELECTS +- **Transfer** is `sendCoinless({recipient, tokenId, memo?})`, a separate verb: a coin spend SELECTS sources for an amount and may queue; a token spend reserves the one it was NAMED and never queues (nothing can free up that would help). Same `TransferMachine`, same durable intent — one money path. A proven conflict is TERMINAL: #625's re-plan needs an alternative source and a named token has none. Three independent gates keep a valued token out (mirror, reservation, and a re-check of the decoded BLOB, which is the authority on what a token carries). -- The durable intent is discriminated by a REQUIRED `kind` (`'coin' | 'token'`); an ABSENT kind +- The durable intent is discriminated by a REQUIRED `kind` (`'coin' | 'coinless'`); an ABSENT kind reads as `'coin'`, the only shape written before #777. A token intent names EXACTLY one source and can never carry a split — re-checked on resume, because a second leg would let a '0' remainder complete the intent and report success for a leg that never landed. diff --git a/docs/API.md b/docs/API.md index 01200400..703af4ba 100644 --- a/docs/API.md +++ b/docs/API.md @@ -460,13 +460,13 @@ 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`. -### `sendToken(req: { recipient, tokenId, memo? }): Promise` +### `sendCoinless(req: { recipient, tokenId, memo? }): Promise` Move a **coinless** token whole. All-or-nothing: one named source, one direct transfer, never a split — there is no amount to divide and no change to return. ```typescript -const result = await sphere.payments.sendToken({ +const result = await sphere.payments.sendCoinless({ recipient: '@bob', // same resolver send() uses tokenId: nft.tokenId, memo: 'happy birthday', // optional, recipient-encrypted @@ -474,7 +474,7 @@ const result = await sphere.payments.sendToken({ ``` A separate verb rather than a widened `send()` because the addressing model differs: `send()` -selects sources to cover an amount and may queue for a combination that frees up; `sendToken` +selects sources to cover an amount and may queue for a combination that frees up; `sendCoinless` reserves the one token you named. Refuses — **before any reservation or chain op** — a `tokenId` that is unknown, tombstoned, already diff --git a/modules/payments-v2/PaymentsFacade.ts b/modules/payments-v2/PaymentsFacade.ts index 575aa0a7..1adac755 100644 --- a/modules/payments-v2/PaymentsFacade.ts +++ b/modules/payments-v2/PaymentsFacade.ts @@ -15,12 +15,12 @@ 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 { CoinlessToken, ConnectionStatus, HistoryPage, MintResult, PaymentsV2, PendingTransfer, SendRequest, SendTokenRequest } from './api'; +import type { CoinlessToken, ConnectionStatus, HistoryPage, MintResult, PaymentsV2, PendingTransfer, SendRequest, SendCoinlessRequest } from './api'; import { SerialChain, SingleFlight } from './async'; import { ConvergenceHeartbeat, Converger, derivePendingTransfers } from './convergence'; import { readTokenData } from './inventory/token-data'; import { finalizeMint, type MintDeps, runMintUnderJournal } from './mint'; -import { materializeTokenSpend } from './send-token'; +import { materializeCoinlessSpend } from './send-coinless'; import { partialize, stampTransferId } from './send-errors'; import { requireSameNetworkRecipient } from './recipient'; import { reseedAndReset, type RestoreDeps } from './restore'; @@ -70,7 +70,7 @@ const INVENTORY_SCAN_PAGE_LIMIT = 50; */ type SendJob = | { readonly kind: 'coin'; readonly request: SendRequest } - | { readonly kind: 'token'; readonly request: SendTokenRequest }; + | { readonly kind: 'coinless'; readonly request: SendCoinlessRequest }; interface AttemptCtx { readonly transferId: string; @@ -277,8 +277,8 @@ export class PaymentsFacade implements PaymentsV2 { return this.track(this.sendOutcome(request)); } - sendToken(request: SendTokenRequest): Promise { - return this.track(this.sendTokenOutcome(request)); + sendCoinless(request: SendCoinlessRequest): Promise { + return this.track(this.sendCoinlessOutcome(request)); } async receive(): Promise<{ transfers: IncomingTransfer[] }> { @@ -344,8 +344,8 @@ export class PaymentsFacade implements PaymentsV2 { } /** A token spend has no amount; '0' keeps the shortfall arithmetic total-free. */ - private sendTokenOutcome(request: SendTokenRequest): Promise { - return this.runJob({ kind: 'token', request }, '0'); + private sendCoinlessOutcome(request: SendCoinlessRequest): Promise { + return this.runJob({ kind: 'coinless', request }, '0'); } private async runJob(job: SendJob, amount: string): Promise { @@ -397,7 +397,7 @@ export class PaymentsFacade implements PaymentsV2 { // #625's re-plan searches for a DIFFERENT source. A named token has no // alternative — re-planning would pick the same one or nothing — so a // proven conflict is TERMINAL here rather than a bounded retry. - if (job.kind === 'token' || attempt >= MAX_RESELECT) throw partialize(disposition.error, run); + if (job.kind === 'coinless' || attempt >= MAX_RESELECT) throw partialize(disposition.error, run); continue; } if (disposition.kind === 'success') { @@ -516,11 +516,11 @@ export class PaymentsFacade implements PaymentsV2 { run.lastTransferId = transferId; // The ONE divergence: a coin spend SELECTS sources to cover an amount and may // queue for them; a token spend reserves the one it was NAMED and never queues. - if (job.kind === 'token') { - const spend = this.queue.planToken(transferId, job.request.tokenId); + if (job.kind === 'coinless') { + const spend = this.queue.planCoinless(transferId, job.request.tokenId); const sourceIds = this.markPlanned(transferId, '', spend); try { - return await materializeTokenSpend( + return await materializeCoinlessSpend( { engine: this.engine(), storagePort: this.deps.storagePort }, { transferId, recipientPubkey, request: job.request, sourceIds } ); diff --git a/modules/payments-v2/api.ts b/modules/payments-v2/api.ts index 2c791275..dd146976 100644 --- a/modules/payments-v2/api.ts +++ b/modules/payments-v2/api.ts @@ -9,7 +9,7 @@ export interface SendRequest { memo?: string; } -export interface SendTokenRequest { +export interface SendCoinlessRequest { recipient: string; tokenId: string; memo?: string; @@ -109,7 +109,7 @@ export interface PaymentsV2 { history(page?: { before?: string; limit?: number }): Promise; send(req: SendRequest): Promise; - sendToken(req: SendTokenRequest): Promise; + sendCoinless(req: SendCoinlessRequest): Promise; mint(coinId: string, amount: bigint): Promise; receive(): Promise<{ transfers: IncomingTransfer[] }>; diff --git a/modules/payments-v2/compose.ts b/modules/payments-v2/compose.ts index 3cec2285..08cb0103 100644 --- a/modules/payments-v2/compose.ts +++ b/modules/payments-v2/compose.ts @@ -285,7 +285,7 @@ function buildMachineDeps( transferId, // §5.9: the SETTLED amount, never payload.amount — the plan. A token // spend moved no coin: `assets: []` + tokenId (wallet-api#151 / §10). - ...(payload.kind === 'token' + ...(payload.kind === 'coinless' ? { assets: [], tokenId: payload.direct[0] } : { assets: [{ coinId: payload.coinId, amount: committedAmount }] }), recipientPubkey: payload.recipient, diff --git a/modules/payments-v2/convergence.ts b/modules/payments-v2/convergence.ts index 0838df3f..5e577152 100644 --- a/modules/payments-v2/convergence.ts +++ b/modules/payments-v2/convergence.ts @@ -296,7 +296,7 @@ async function openRow( function subject( payload: Partial | null ): { coinId: string; amount: string; tokenId?: string } { - if (payload?.kind === 'token') { + if (payload?.kind === 'coinless') { const tokenId = payload.direct?.[0]; return { coinId: '', amount: '', ...(typeof tokenId === 'string' ? { tokenId } : {}) }; } diff --git a/modules/payments-v2/index.ts b/modules/payments-v2/index.ts index 052d14f5..06d10d15 100644 --- a/modules/payments-v2/index.ts +++ b/modules/payments-v2/index.ts @@ -1,4 +1,4 @@ -export type { PaymentsV2, PaymentsV2Events, ConnectionStatus, SendRequest, SendTokenRequest, MintResult, HistoryEntry, HistoryPage, PaymentRequestView, PaymentRequestStatus, PaymentsRequestsApi, PendingTransfer } from './api'; +export type { PaymentsV2, PaymentsV2Events, ConnectionStatus, SendRequest, SendCoinlessRequest, MintResult, HistoryEntry, HistoryPage, PaymentRequestView, PaymentRequestStatus, PaymentsRequestsApi, PendingTransfer } from './api'; export type { StoragePort, DeliveryPort, diff --git a/modules/payments-v2/machine/payload-view.ts b/modules/payments-v2/machine/payload-view.ts index 5a60ca2c..f85f6c31 100644 --- a/modules/payments-v2/machine/payload-view.ts +++ b/modules/payments-v2/machine/payload-view.ts @@ -1,7 +1,7 @@ -import type { CoinIntentPayload, IntentPayload, TokenIntentPayload } from './types'; +import type { CoinIntentPayload, IntentPayload, CoinlessIntentPayload } from './types'; -export function isTokenIntent(p: IntentPayload): p is TokenIntentPayload { - return p.kind === 'token'; +export function isCoinlessIntent(p: IntentPayload): p is CoinlessIntentPayload { + return p.kind === 'coinless'; } export function isCoinIntent(p: IntentPayload): p is CoinIntentPayload { diff --git a/modules/payments-v2/machine/payload.ts b/modules/payments-v2/machine/payload.ts index 9b601cc0..3735aba4 100644 --- a/modules/payments-v2/machine/payload.ts +++ b/modules/payments-v2/machine/payload.ts @@ -3,7 +3,7 @@ import type { SendRequest } from '../api'; import type { PlannedSpend } from '../select/queue'; -import type { CoinIntentPayload, TokenIntentPayload } from './types'; +import type { CoinIntentPayload, CoinlessIntentPayload } from './types'; export function buildPayload( recipientPubkey: string, @@ -40,14 +40,14 @@ export function messageOf(err: unknown): string { * A token-addressed spend (#777). Takes no PlannedSpend because nothing was * selected: the source is named, so there is no amount, no split and no change. */ -export function buildTokenPayload( +export function buildCoinlessPayload( recipientPubkey: string, request: { tokenId: string; memo?: string }, spentStates: Record -): TokenIntentPayload { +): CoinlessIntentPayload { return { v: 2, - kind: 'token', + kind: 'coinless', recipient: recipientPubkey, ...(request.memo !== undefined ? { memo: request.memo } : {}), direct: [request.tokenId], diff --git a/modules/payments-v2/machine/resume.ts b/modules/payments-v2/machine/resume.ts index 13aeb185..52f2dad3 100644 --- a/modules/payments-v2/machine/resume.ts +++ b/modules/payments-v2/machine/resume.ts @@ -6,7 +6,7 @@ import { SphereError } from '../../../core/errors'; import { logger } from '../../../core/logger'; import type { SphereToken } from '../../../token-engine/types'; import type { DeliveryJournalEntry, IntentBackstopEntry } from '../stores'; -import type { CoinIntentPayload, IntentPayload, TokenIntentPayload } from './types'; +import type { CoinIntentPayload, IntentPayload, CoinlessIntentPayload } from './types'; import { ATTENTION_CHECKPOINT_STUCK, createMachineStores, type MachineStores } from './journal'; import { TransferMachine, buildOps, classifyError, type MachineDeps } from './TransferMachine'; @@ -169,9 +169,9 @@ async function runOne(ctx: RunCtx, job: ResumeJob, report: ResumeReport): Promis return; } // The blob is the authority on what a source carries — the same rule the send - // path applies at materialize. A durable intent labelled 'token' whose named + // path applies at materialize. A durable intent labelled 'coinless' whose named // source actually holds coins would move them while history records assets: []. - if (job.payload.kind === 'token' && token.value !== null) { + if (job.payload.kind === 'coinless' && token.value !== null) { logger.warn( 'PaymentsV2', `resume: token intent ${job.transferId} names a source carrying coin value — refusing` @@ -240,7 +240,7 @@ function validateCoinPayload(c: Partial): CoinIntentPayload { return { ...(c as CoinIntentPayload), kind: 'coin' }; } -function validateTokenPayload(p: Partial): TokenIntentPayload { +function validateCoinlessPayload(p: Partial): CoinlessIntentPayload { if (p.direct?.length !== 1 || typeof p.direct[0] !== 'string' || p.direct[0] === '') { throw new SphereError( 'token intent payload must name exactly one source token', @@ -250,7 +250,7 @@ function validateTokenPayload(p: Partial): TokenIntentPayloa if (p.split !== undefined) { throw new SphereError('token intent payload cannot carry a split', 'VALIDATION_ERROR'); } - return { ...(p as TokenIntentPayload), kind: 'token', direct: [p.direct[0]] }; + return { ...(p as CoinlessIntentPayload), kind: 'coinless', direct: [p.direct[0]] }; } function validatePayload(raw: unknown): IntentPayload { @@ -271,13 +271,13 @@ function validatePayload(raw: unknown): IntentPayload { // unknown one does NOT: a payload from a newer client, or a corrupted one, would // otherwise execute under coin semantics it was never written for. const kind = p.kind ?? 'coin'; - if (kind !== 'coin' && kind !== 'token') { + if (kind !== 'coin' && kind !== 'coinless') { throw new SphereError( `unsupported intent kind '${String(kind)}' — not resumable by this client`, 'VALIDATION_ERROR' ); } - return kind === 'token' - ? validateTokenPayload(p as Partial) + return kind === 'coinless' + ? validateCoinlessPayload(p as Partial) : validateCoinPayload(p as Partial); } diff --git a/modules/payments-v2/machine/types.ts b/modules/payments-v2/machine/types.ts index be415454..6460dff7 100644 --- a/modules/payments-v2/machine/types.ts +++ b/modules/payments-v2/machine/types.ts @@ -15,14 +15,14 @@ export interface CoinIntentPayload extends IntentPayloadBase { split?: { tokenId: string; splitAmount: string; remainderAmount: string }; } -export interface TokenIntentPayload extends IntentPayloadBase { - kind: 'token'; +export interface CoinlessIntentPayload extends IntentPayloadBase { + kind: 'coinless'; direct: [string]; split?: undefined; } /** `kind` is REQUIRED on both arms: a missed writer must be a compile error. */ -export type IntentPayload = CoinIntentPayload | TokenIntentPayload; +export type IntentPayload = CoinIntentPayload | CoinlessIntentPayload; export interface PlannedOp { kind: 'direct' | 'split'; diff --git a/modules/payments-v2/select/queue.ts b/modules/payments-v2/select/queue.ts index e9453bff..3ec680e5 100644 --- a/modules/payments-v2/select/queue.ts +++ b/modules/payments-v2/select/queue.ts @@ -98,7 +98,7 @@ export class SpendQueue { * can release, so queueing would block until a timeout on a spend that cannot * become possible. */ - planToken(reservationId: string, tokenId: string): PlannedSpend { + planCoinless(reservationId: string, tokenId: string): PlannedSpend { if (this.destroyed) { throw new SphereError('Module has been destroyed', 'MODULE_DESTROYED'); } diff --git a/modules/payments-v2/send-token.ts b/modules/payments-v2/send-coinless.ts similarity index 81% rename from modules/payments-v2/send-token.ts rename to modules/payments-v2/send-coinless.ts index bcd075c2..cea99921 100644 --- a/modules/payments-v2/send-token.ts +++ b/modules/payments-v2/send-coinless.ts @@ -1,20 +1,20 @@ import { SphereError } from '../../core/errors'; import type { ITokenEngine } from '../../token-engine/engine'; -import { buildTokenPayload } from './machine/payload'; +import { buildCoinlessPayload } from './machine/payload'; import { buildOps, type MachinePlan } from './machine/TransferMachine'; -import type { SendTokenRequest } from './api'; +import type { SendCoinlessRequest } from './api'; import type { StoragePort } from './ports'; -export interface TokenSpendDeps { +export interface CoinlessSpendDeps { readonly engine: ITokenEngine; readonly storagePort: Pick; } -export interface TokenSpendInput { +export interface CoinlessSpendInput { readonly transferId: string; readonly recipientPubkey: string; - readonly request: SendTokenRequest; + readonly request: SendCoinlessRequest; readonly sourceIds: readonly string[]; } @@ -23,9 +23,9 @@ export interface TokenSpendInput { * coin, no split. `sourceTokens` stays EMPTY — those are the coin rows a UI shows * as in-flight, and a coinless token has no amount to show as moving. */ -export async function materializeTokenSpend( - deps: TokenSpendDeps, - input: TokenSpendInput +export async function materializeCoinlessSpend( + deps: CoinlessSpendDeps, + input: CoinlessSpendInput ): Promise<{ transferId: string; coinId: string; @@ -44,12 +44,12 @@ export async function materializeTokenSpend( // two disagreeing means this would move a valued token with its coins unaccounted. if (token.value !== null) { throw new SphereError( - `Token ${tokenId} carries coin value and cannot be sent with sendToken — use send()`, + `Token ${tokenId} carries coin value and cannot be sent with sendCoinless — use send()`, 'VALIDATION_ERROR' ); } const keys = await engine.deliveryKeys(bytes); - const payload = buildTokenPayload(input.recipientPubkey, input.request, { + const payload = buildCoinlessPayload(input.recipientPubkey, input.request, { [tokenId]: { local: keys.stateHash, protocol: keys.stateHash }, }); const plan: MachinePlan = { diff --git a/tests/e2e/coinless-tokens.staging.e2e.test.ts b/tests/e2e/coinless-tokens.staging.e2e.test.ts index 90ea8d0c..1f056cd8 100644 --- a/tests/e2e/coinless-tokens.staging.e2e.test.ts +++ b/tests/e2e/coinless-tokens.staging.e2e.test.ts @@ -209,7 +209,7 @@ describe.skipIf(!RUN_STAGING)('coinless tokens — transfer, live staging', () = a = await makeVerticalWallet('nft-a', { identity: a.identity, kv: a.kv }); expect(a.facade.coinless().map((t) => t.tokenId)).toContain(nft.blob.tokenId); - const result = await a.facade.sendToken({ + const result = await a.facade.sendCoinless({ recipient: b.identity.chainPubkey, tokenId: nft.blob.tokenId, }); @@ -245,7 +245,7 @@ describe.skipIf(!RUN_STAGING)('coinless tokens — transfer, live staging', () = ); it( - 'refuses to move a VALUED token through sendToken, against the real backend', + 'refuses to move a VALUED token through sendCoinless, against the real backend', async () => { const w = await makeVerticalWallet('nft-refuse'); const mint = await w.facade.mint(HARNESS_COIN, 250n); @@ -254,7 +254,7 @@ describe.skipIf(!RUN_STAGING)('coinless tokens — transfer, live staging', () = } await expect( - w.facade.sendToken({ recipient: w.identity.chainPubkey, tokenId: mint.tokenId }) + w.facade.sendCoinless({ recipient: w.identity.chainPubkey, tokenId: mint.tokenId }) ).rejects.toThrow(/not a spendable coinless holding|carries coin value/); // Refused BEFORE any chain op: the coin is still spendable. diff --git a/tests/mutation/probes.json b/tests/mutation/probes.json index 06a92b60..814a837d 100644 --- a/tests/mutation/probes.json +++ b/tests/mutation/probes.json @@ -1470,11 +1470,11 @@ { "name": "sendtoken-blob-value-guard-removed", "note": "#777: the BLOB is the authority on what a source carries. Without the re-check a mirror that wrongly says coinless would move a valued token with its coins unaccounted for.", - "file": "modules/payments-v2/send-token.ts", + "file": "modules/payments-v2/send-coinless.ts", "find": " if (token.value !== null) {", "replace": " if (false as boolean) {", "tests": [ - "tests/unit/payments-v2/send-token.test.ts" + "tests/unit/payments-v2/send-coinless.test.ts" ] }, { diff --git a/tests/unit/payments-v2/facade.test.ts b/tests/unit/payments-v2/facade.test.ts index 289a6c06..463471a7 100644 --- a/tests/unit/payments-v2/facade.test.ts +++ b/tests/unit/payments-v2/facade.test.ts @@ -765,13 +765,13 @@ describe('PaymentsFacade — mint', () => { }); }); -describe('PaymentsFacade — sendToken: moving a coinless token (#777)', () => { +describe('PaymentsFacade — sendCoinless: moving a coinless token (#777)', () => { it('moves the named token whole: one direct leg, deposited, intent completed', async () => { const world = makeWorld(); const nft = await world.seedCoinless(); await world.facade.start(); - const result = await world.facade.sendToken({ recipient: '@peer', tokenId: nft.blob.tokenId }); + const result = await world.facade.sendCoinless({ recipient: '@peer', tokenId: nft.blob.tokenId }); expect(result.status).toBe('delivered'); expect(result.tokenTransfers).toEqual([ @@ -789,7 +789,7 @@ describe('PaymentsFacade — sendToken: moving a coinless token (#777)', () => { await world.facade.start(); expect(world.facade.coinless().map((t) => t.tokenId)).toEqual([nft.blob.tokenId]); - await world.facade.sendToken({ recipient: '@peer', tokenId: nft.blob.tokenId }); + await world.facade.sendCoinless({ recipient: '@peer', tokenId: nft.blob.tokenId }); await flushTail(); expect(world.facade.coinless().map((t) => t.tokenId)).not.toContain(nft.blob.tokenId); @@ -800,7 +800,7 @@ describe('PaymentsFacade — sendToken: moving a coinless token (#777)', () => { const nft = await world.seedCoinless(); await world.facade.start(); - await world.facade.sendToken({ recipient: '@peer', tokenId: nft.blob.tokenId }); + await world.facade.sendCoinless({ recipient: '@peer', tokenId: nft.blob.tokenId }); await flushTail(); const sent = (await world.api.listHistory(ownCaller, {})).records.filter((r) => r.type === 'SENT'); @@ -815,7 +815,7 @@ describe('PaymentsFacade — sendToken: moving a coinless token (#777)', () => { await world.facade.start(); await expect( - world.facade.sendToken({ recipient: '@peer', tokenId: coin.blob.tokenId }) + world.facade.sendCoinless({ recipient: '@peer', tokenId: coin.blob.tokenId }) ).rejects.toThrow(/not a spendable coinless holding/); }); @@ -824,7 +824,7 @@ describe('PaymentsFacade — sendToken: moving a coinless token (#777)', () => { await world.facade.start(); await expect( - world.facade.sendToken({ recipient: '@peer', tokenId: 'ff'.repeat(32) }) + world.facade.sendCoinless({ recipient: '@peer', tokenId: 'ff'.repeat(32) }) ).rejects.toThrow(/not a spendable coinless holding/); expect(await world.facade.pendingTransfers()).toEqual([]); }); @@ -835,10 +835,10 @@ describe('PaymentsFacade — sendToken: moving a coinless token (#777)', () => { await world.facade.start(); const gate = world.gate('deliver'); - const first = world.facade.sendToken({ recipient: '@peer', tokenId: nft.blob.tokenId }); + const first = world.facade.sendCoinless({ recipient: '@peer', tokenId: nft.blob.tokenId }); await vi.waitFor(() => expect(gate.entered).toBe(true)); await expect( - world.facade.sendToken({ recipient: '@peer', tokenId: nft.blob.tokenId }) + world.facade.sendCoinless({ recipient: '@peer', tokenId: nft.blob.tokenId }) ).rejects.toThrow(/already reserved/); gate.release(); @@ -858,7 +858,7 @@ describe('PaymentsFacade — sendToken: moving a coinless token (#777)', () => { await world.facade.start(); const err = await world.facade - .sendToken({ recipient: '@peer', tokenId: nft.blob.tokenId }) + .sendCoinless({ recipient: '@peer', tokenId: nft.blob.tokenId }) .then(() => null, (e: unknown) => e); // The caller must learn the token was spent elsewhere. If the re-plan ran, it diff --git a/tests/unit/payments-v2/machine-resume.test.ts b/tests/unit/payments-v2/machine-resume.test.ts index 20b2220a..6350d3b5 100644 --- a/tests/unit/payments-v2/machine-resume.test.ts +++ b/tests/unit/payments-v2/machine-resume.test.ts @@ -371,7 +371,7 @@ describe('resume refuses a durable intent it cannot safely execute (#777)', () = const id = 'c0000000-0000-4000-8000-000000000003'; await stageRawIntent(w, id, { v: 2, - kind: 'token', + kind: 'coinless', recipient: w.recipientHex, direct: [valued.blob.tokenId], spentStates: { diff --git a/tests/unit/payments-v2/pinned-balance.test.ts b/tests/unit/payments-v2/pinned-balance.test.ts index 7dc75669..c5d289d8 100644 --- a/tests/unit/payments-v2/pinned-balance.test.ts +++ b/tests/unit/payments-v2/pinned-balance.test.ts @@ -154,7 +154,7 @@ describe('#738 review: the held-set gate fails CLOSED', () => { }); it('#777: a NAMED token spend is refused too while the ledger is unproven', async () => { - // planToken bypasses freeView(), where the #738 gate lives for coin spends, so + // planCoinless bypasses freeView(), where the #738 gate lives for coin spends, so // it has to apply the gate itself — otherwise a restart could double-spend a // token an open intent already holds, which is exactly what the gate prevents. const ledger = new ReservationLedger(); @@ -164,7 +164,7 @@ describe('#738 review: the held-set gate fails CLOSED', () => { getPool: () => [], spendableCoinless: () => true, }); - expect(() => queue.planToken('r1', 'nft-1')).toThrow(/spending is paused|cannot spend yet/i); + expect(() => queue.planCoinless('r1', 'nft-1')).toThrow(/spending is paused|cannot spend yet/i); }); it('#777: once the ledger is proven, the same named token plans and reserves', () => { @@ -176,7 +176,7 @@ describe('#738 review: the held-set gate fails CLOSED', () => { getPool: () => [], spendableCoinless: (id) => id === 'nft-1', }); - expect(queue.planToken('r1', 'nft-1').plan.direct).toEqual(['nft-1']); + expect(queue.planCoinless('r1', 'nft-1').plan.direct).toEqual(['nft-1']); expect(ledger.holderOf('nft-1')).toBe('r1'); }); diff --git a/tests/unit/payments-v2/send-token.test.ts b/tests/unit/payments-v2/send-coinless.test.ts similarity index 82% rename from tests/unit/payments-v2/send-token.test.ts rename to tests/unit/payments-v2/send-coinless.test.ts index b2e15293..c1ce099b 100644 --- a/tests/unit/payments-v2/send-token.test.ts +++ b/tests/unit/payments-v2/send-coinless.test.ts @@ -14,7 +14,7 @@ import { describe, expect, it, vi } from 'vitest'; import { SphereError } from '../../../core/errors'; import type { ITokenEngine } from '../../../token-engine/engine'; import type { SphereToken } from '../../../token-engine/types'; -import { materializeTokenSpend } from '../../../modules/payments-v2/send-token'; +import { materializeCoinlessSpend } from '../../../modules/payments-v2/send-coinless'; const TOKEN_ID = 'aa'.repeat(32); const RECIPIENT = '02'.repeat(16) + '03'; @@ -37,37 +37,37 @@ const input = { sourceIds: [TOKEN_ID], }; -describe('materializeTokenSpend', () => { +describe('materializeCoinlessSpend', () => { it('REFUSES a source whose blob carries coin value, even when the mirror called it coinless', async () => { const deps = { engine: engineWith({ assets: [{ coinId: 'bb'.repeat(32), amount: 5n }] }), storagePort }; - await expect(materializeTokenSpend(deps, input)).rejects.toThrow(/carries coin value/); + await expect(materializeCoinlessSpend(deps, input)).rejects.toThrow(/carries coin value/); }); it('names send() in the refusal, so the caller knows which verb moves it', async () => { const deps = { engine: engineWith({ assets: [{ coinId: 'bb'.repeat(32), amount: 5n }] }), storagePort }; - const err = await materializeTokenSpend(deps, input).then(() => null, (e: unknown) => e); + const err = await materializeCoinlessSpend(deps, input).then(() => null, (e: unknown) => e); expect(err).toBeInstanceOf(SphereError); expect((err as SphereError).message).toMatch(/use send\(\)/); }); it('plans exactly ONE direct op for a genuinely coinless source — never a split', async () => { const deps = { engine: engineWith(null), storagePort }; - const ctx = await materializeTokenSpend(deps, input); + const ctx = await materializeCoinlessSpend(deps, input); expect(ctx.plan.ops).toHaveLength(1); expect(ctx.plan.ops[0]?.kind).toBe('direct'); - expect(ctx.plan.payload.kind).toBe('token'); + expect(ctx.plan.payload.kind).toBe('coinless'); expect(ctx.plan.payload.direct).toEqual([TOKEN_ID]); }); it('reports no coin and no in-flight coin rows: a coinless spend has no amount to show', async () => { const deps = { engine: engineWith(null), storagePort }; - const ctx = await materializeTokenSpend(deps, input); + const ctx = await materializeCoinlessSpend(deps, input); expect(ctx.coinId).toBe(''); expect(ctx.sourceTokens).toEqual([]); }); it('refuses when the blob is missing rather than planning a spend of nothing', async () => { const deps = { engine: engineWith(null), storagePort: { getBlobs: vi.fn(async () => new Map()) } }; - await expect(materializeTokenSpend(deps, input)).rejects.toThrow(/no blob in storage/); + await expect(materializeCoinlessSpend(deps, input)).rejects.toThrow(/no blob in storage/); }); }); From ec59c6d7dc9ec2f03140cd4af3cb212d6b7c2814 Mon Sep 17 00:00:00 2001 From: Pavel Grigorenko Date: Fri, 11 Sep 2026 11:49:20 +0200 Subject: [PATCH 11/12] test(mutation): update three probes the coinless rename moved MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The runner flagged them STALE rather than passing silently — the `find` strings pointed at `kind: 'token'` comparisons the rename replaced. Updated in place with the refactor, never dropped: a probe whose target moved is a probe guarding nothing, and deleting one hides that its invariant lost coverage. `sendtoken-conflict-replans` renamed to `sendcoinless-conflict-replans` to match the verb it guards. --- tests/mutation/probes.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/mutation/probes.json b/tests/mutation/probes.json index 814a837d..8b278c1e 100644 --- a/tests/mutation/probes.json +++ b/tests/mutation/probes.json @@ -1478,10 +1478,10 @@ ] }, { - "name": "sendtoken-conflict-replans", + "name": "sendcoinless-conflict-replans", "note": "#625's re-plan searches for a DIFFERENT source; a NAMED token has none, so a proven conflict must be terminal rather than spinning the bounded retry.", "file": "modules/payments-v2/PaymentsFacade.ts", - "find": " if (job.kind === 'token' || attempt >= MAX_RESELECT) throw partialize(disposition.error, run);", + "find": " if (job.kind === 'coinless' || attempt >= MAX_RESELECT) throw partialize(disposition.error, run);", "replace": " if (attempt >= MAX_RESELECT) throw partialize(disposition.error, run);", "tests": [ "tests/unit/payments-v2/facade.test.ts" @@ -1491,7 +1491,7 @@ "name": "resume-migrates-unknown-kind-to-coin", "note": "#777: only an ABSENT kind migrates to 'coin'. An EXPLICIT unknown one (a newer client's payload, or a corrupted one) must be refused rather than executed under coin semantics it was never written for.", "file": "modules/payments-v2/machine/resume.ts", - "find": " if (kind !== 'coin' && kind !== 'token') {", + "find": " if (kind !== 'coin' && kind !== 'coinless') {", "replace": " if (false as boolean) {", "tests": [ "tests/unit/payments-v2/machine-resume.test.ts" @@ -1501,7 +1501,7 @@ "name": "resume-skips-blob-value-authority", "note": "#777: the blob is the authority on resume too. Without this a durable intent labelled 'token' whose named source carries coins moves them while history records assets: [] \u2014 value moved, nothing accounted.", "file": "modules/payments-v2/machine/resume.ts", - "find": " if (job.payload.kind === 'token' && token.value !== null) {", + "find": " if (job.payload.kind === 'coinless' && token.value !== null) {", "replace": " if (false as boolean) {", "tests": [ "tests/unit/payments-v2/machine-resume.test.ts" From 11976e2d054869c71e426e070d7f9af9142a74b1 Mon Sep 17 00:00:00 2001 From: Pavel Grigorenko Date: Fri, 11 Sep 2026 12:34:23 +0200 Subject: [PATCH 12/12] fix(payments-v2): gate the coinless spend on the ENVELOPE, not on value === null MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From review (P1). `materializeCoinlessSpend` and the resume twin both asked `token.value !== null`. But `bare_collection` — the bridged dialect carrying coins this SDK cannot decode — decodes to a null value exactly like a genuinely coinless token. Either guard would therefore have moved a valued token and recorded `assets: []` for it: coins gone, nothing accounted. This is the ambiguity `valueEnvelope` exists to resolve, and the rule is written into CLAUDE.md — `none_*` means "names no coin", `bare_collection` means "cannot read". I documented it, fixed one instance of it in the receive path after an earlier review, and then wrote the same bug into both new guards. `isCoinlessEnvelope` moves to `token-engine/value-envelope.ts` beside the type it interprets, and all three call sites use it. It was a private helper in Receive.ts — three copies of a question this subtle is how they drift apart again. Test seams needed for it: `engineWith` takes an explicit envelope, and the resume harness gains `forceEnvelope` so a bridged source can be staged at all. Neither path could express the case before, which is why neither caught it. Also fixes the CI lint break: a duplicated comment block in resume.ts (two paragraphs saying the same thing about the absent-kind migration) and the ratio in value-envelope.ts. Verified: 2307 tests green over two clean runs; typecheck, typecheck:tests, lint. Probes retargeted onto the envelope check, plus one new on the shared helper. --- modules/payments-v2/machine/resume.ts | 10 +++---- modules/payments-v2/send-coinless.ts | 8 +++--- tests/mutation/probes.json | 17 +++++++++--- tests/unit/payments-v2/machine-harness.ts | 12 +++++++++ tests/unit/payments-v2/machine-resume.test.ts | 21 +++++++++++++++ tests/unit/payments-v2/send-coinless.test.ts | 27 +++++++++++++++++-- token-engine/value-envelope.ts | 8 ++++++ 7 files changed, 89 insertions(+), 14 deletions(-) diff --git a/modules/payments-v2/machine/resume.ts b/modules/payments-v2/machine/resume.ts index 52f2dad3..27a146f8 100644 --- a/modules/payments-v2/machine/resume.ts +++ b/modules/payments-v2/machine/resume.ts @@ -6,6 +6,7 @@ import { SphereError } from '../../../core/errors'; import { logger } from '../../../core/logger'; import type { SphereToken } from '../../../token-engine/types'; import type { DeliveryJournalEntry, IntentBackstopEntry } from '../stores'; +import { isCoinlessEnvelope } from '../../../token-engine/value-envelope'; import type { CoinIntentPayload, IntentPayload, CoinlessIntentPayload } from './types'; import { ATTENTION_CHECKPOINT_STUCK, createMachineStores, type MachineStores } from './journal'; import { TransferMachine, buildOps, classifyError, type MachineDeps } from './TransferMachine'; @@ -171,7 +172,7 @@ async function runOne(ctx: RunCtx, job: ResumeJob, report: ResumeReport): Promis // The blob is the authority on what a source carries — the same rule the send // path applies at materialize. A durable intent labelled 'coinless' whose named // source actually holds coins would move them while history records assets: []. - if (job.payload.kind === 'coinless' && token.value !== null) { + if (job.payload.kind === 'coinless' && !isCoinlessEnvelope(token.valueEnvelope)) { logger.warn( 'PaymentsV2', `resume: token intent ${job.transferId} names a source carrying coin value — refusing` @@ -264,11 +265,8 @@ function validatePayload(raw: unknown): IntentPayload { if (typeof p.recipient !== 'string') { throw new SphereError('intent payload is missing recipient', 'VALIDATION_ERROR'); } - // An ABSENT kind is the only shape written before #777, and it was always a coin - // spend — so defaulting is a migration, not a guess. Anything written since - // carries the discriminant, because the type makes omitting it a compile error. - // ABSENT migrates to 'coin' (the only shape written before #777). An EXPLICIT - // unknown one does NOT: a payload from a newer client, or a corrupted one, would + // ABSENT migrates to 'coin' (the only shape written before #777) — a migration, + // not a guess. An EXPLICIT unknown one does NOT: a newer client's payload would // otherwise execute under coin semantics it was never written for. const kind = p.kind ?? 'coin'; if (kind !== 'coin' && kind !== 'coinless') { diff --git a/modules/payments-v2/send-coinless.ts b/modules/payments-v2/send-coinless.ts index cea99921..ed954f52 100644 --- a/modules/payments-v2/send-coinless.ts +++ b/modules/payments-v2/send-coinless.ts @@ -1,5 +1,6 @@ import { SphereError } from '../../core/errors'; import type { ITokenEngine } from '../../token-engine/engine'; +import { isCoinlessEnvelope } from '../../token-engine/value-envelope'; import { buildCoinlessPayload } from './machine/payload'; import { buildOps, type MachinePlan } from './machine/TransferMachine'; @@ -40,9 +41,10 @@ export async function materializeCoinlessSpend( throw new SphereError(`Selected source ${tokenId} has no blob in storage`, 'STORAGE_ERROR'); } const token = await engine.decodeToken({ tokenId, token: bytes }); - // The mirror said coinless; the BLOB is the authority on what it carries, and the - // two disagreeing means this would move a valued token with its coins unaccounted. - if (token.value !== null) { + // The BLOB is the authority, and the question is the ENVELOPE, not the value: + // `bare_collection` decodes to a null value while carrying coins this SDK cannot + // read, so a value check would move a valued token recording `assets: []`. + if (!isCoinlessEnvelope(token.valueEnvelope)) { throw new SphereError( `Token ${tokenId} carries coin value and cannot be sent with sendCoinless — use send()`, 'VALIDATION_ERROR' diff --git a/tests/mutation/probes.json b/tests/mutation/probes.json index 8b278c1e..46a124de 100644 --- a/tests/mutation/probes.json +++ b/tests/mutation/probes.json @@ -1468,10 +1468,10 @@ ] }, { - "name": "sendtoken-blob-value-guard-removed", + "name": "sendcoinless-blob-envelope-guard-removed", "note": "#777: the BLOB is the authority on what a source carries. Without the re-check a mirror that wrongly says coinless would move a valued token with its coins unaccounted for.", "file": "modules/payments-v2/send-coinless.ts", - "find": " if (token.value !== null) {", + "find": " if (!isCoinlessEnvelope(token.valueEnvelope)) {", "replace": " if (false as boolean) {", "tests": [ "tests/unit/payments-v2/send-coinless.test.ts" @@ -1501,10 +1501,21 @@ "name": "resume-skips-blob-value-authority", "note": "#777: the blob is the authority on resume too. Without this a durable intent labelled 'token' whose named source carries coins moves them while history records assets: [] \u2014 value moved, nothing accounted.", "file": "modules/payments-v2/machine/resume.ts", - "find": " if (job.payload.kind === 'coinless' && token.value !== null) {", + "find": " if (job.payload.kind === 'coinless' && !isCoinlessEnvelope(token.valueEnvelope)) {", "replace": " if (false as boolean) {", "tests": [ "tests/unit/payments-v2/machine-resume.test.ts" ] + }, + { + "name": "coinless-envelope-check-uses-value-not-envelope", + "note": "#777/#778 P1: `value === null` is true for a coinless token AND for bare_collection, which carries coins this SDK cannot decode. Keying any spend or display on the value moves a valued token recording assets: [].", + "file": "token-engine/value-envelope.ts", + "find": " return envelope.startsWith('none_');", + "replace": " return envelope !== 'sphere';", + "tests": [ + "tests/unit/payments-v2/send-coinless.test.ts", + "tests/unit/payments-v2/receive.test.ts" + ] } ] diff --git a/tests/unit/payments-v2/machine-harness.ts b/tests/unit/payments-v2/machine-harness.ts index c63bfcd8..c858a575 100644 --- a/tests/unit/payments-v2/machine-harness.ts +++ b/tests/unit/payments-v2/machine-harness.ts @@ -61,6 +61,18 @@ export class RealizationEngine extends FakeTokenEngine { readonly splitCalls: { key: string; checkpointStore: boolean }[] = []; readonly lineage = new Map(); opLog: string[] | null = null; + /** #777: force a decoded token's envelope, to model the bridged dialect. */ + private readonly forcedEnvelopes = new Map(); + + forceEnvelope(tokenId: string, envelope: SphereToken['valueEnvelope']): void { + this.forcedEnvelopes.set(tokenId, envelope); + } + + override async decodeToken(blob: Parameters[0]): Promise { + const token = await super.decodeToken(blob); + const forced = this.forcedEnvelopes.get(token.blob.tokenId); + return forced === undefined ? token : { ...token, valueEnvelope: forced }; + } beforeOp: ((key: string, kind: 'direct' | 'split') => void) | null = null; afterOp: ((key: string, kind: 'direct' | 'split') => Error | null) | null = null; diff --git a/tests/unit/payments-v2/machine-resume.test.ts b/tests/unit/payments-v2/machine-resume.test.ts index 6350d3b5..7b83263f 100644 --- a/tests/unit/payments-v2/machine-resume.test.ts +++ b/tests/unit/payments-v2/machine-resume.test.ts @@ -363,6 +363,27 @@ describe('resume refuses a durable intent it cannot safely execute (#777)', () = expect(report.failed).not.toContain(id); }); + it('refuses a coinless intent whose source is an UNREADABLE envelope, not just a valued one', async () => { + // `bare_collection` decodes to value === null exactly like a coinless token, so + // a value check passes it through. The envelope is the question. + const w = makeWorld(); + const token = await w.seed(1000n); + const id = 'c0000000-0000-4000-8000-000000000004'; + w.engine.forceEnvelope(token.blob.tokenId, 'bare_collection'); + await stageRawIntent(w, id, { + v: 2, + kind: 'coinless', + recipient: w.recipientHex, + direct: [token.blob.tokenId], + spentStates: { [token.blob.tokenId]: await stateOfToken(w, token) }, + }); + + const report = await resumeAll(w.deps); + + expect(report.failed).toContain(id); + expect(w.engine.transferCalls).toHaveLength(0); + }); + it('refuses a token intent whose named source actually carries COINS', async () => { // The blob is the authority. Executing this would move real coins while the // history row for it records `assets: []` — value moved, nothing accounted. diff --git a/tests/unit/payments-v2/send-coinless.test.ts b/tests/unit/payments-v2/send-coinless.test.ts index c1ce099b..6f61b2db 100644 --- a/tests/unit/payments-v2/send-coinless.test.ts +++ b/tests/unit/payments-v2/send-coinless.test.ts @@ -19,9 +19,14 @@ import { materializeCoinlessSpend } from '../../../modules/payments-v2/send-coin const TOKEN_ID = 'aa'.repeat(32); const RECIPIENT = '02'.repeat(16) + '03'; -function engineWith(value: SphereToken['value']): ITokenEngine { +function engineWith( + value: SphereToken['value'], + valueEnvelope: SphereToken['valueEnvelope'] = value === null ? 'none_other' : 'sphere' +): ITokenEngine { return { - decodeToken: vi.fn(async () => ({ value, blob: { tokenId: TOKEN_ID } }) as unknown as SphereToken), + decodeToken: vi.fn( + async () => ({ value, valueEnvelope, blob: { tokenId: TOKEN_ID } }) as unknown as SphereToken + ), deliveryKeys: vi.fn(async () => ({ tokenId: TOKEN_ID, stateHash: 'S1' })), } as unknown as ITokenEngine; } @@ -71,3 +76,21 @@ describe('materializeCoinlessSpend', () => { await expect(materializeCoinlessSpend(deps, input)).rejects.toThrow(/no blob in storage/); }); }); + +describe('materializeCoinlessSpend — an unreadable envelope is not coinless', () => { + it('REFUSES a bare_collection source: null value, but REAL coins this SDK cannot decode', async () => { + // The bridged dialect decodes to `value: null` exactly like a coinless token. + // Checking the value rather than the ENVELOPE would move a valued token and + // record `assets: []` for it — coins gone, nothing accounted. + const deps = { engine: engineWith(null, 'bare_collection'), storagePort }; + await expect(materializeCoinlessSpend(deps, input)).rejects.toThrow(/carries coin value/); + }); + + it.each(['none_absent', 'none_tag', 'none_other'] as const)( + 'still allows a genuinely coinless %s envelope', + async (envelope) => { + const deps = { engine: engineWith(null, envelope), storagePort }; + await expect(materializeCoinlessSpend(deps, input)).resolves.toBeDefined(); + } + ); +}); diff --git a/token-engine/value-envelope.ts b/token-engine/value-envelope.ts index 96b03add..21c56f62 100644 --- a/token-engine/value-envelope.ts +++ b/token-engine/value-envelope.ts @@ -23,6 +23,14 @@ export type ValueEnvelope = | 'none_other' | 'none_absent'; +/** + * Does the token genuinely name NO coin? NOT the same question as `value === null`: + * `bare_collection` is null too while carrying coins this SDK cannot read. + */ +export function isCoinlessEnvelope(envelope: ValueEnvelope): boolean { + return envelope.startsWith('none_'); +} + export interface ClassifiedValue { readonly envelope: ValueEnvelope; /** Populated only for `'sphere'`; null for every other envelope. */