diff --git a/CHANGELOG.md b/CHANGELOG.md index 0fdd7822..f852ea7e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -35,6 +35,37 @@ 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.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, +`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 +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' | '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 +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) `isSpherePaymentData` was `try { decodeTag(d).tag === CBOR_TAG } catch { return false }`, and both diff --git a/CLAUDE.md b/CLAUDE.md index 2c523284..31c02fb5 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.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? }`) | @@ -756,6 +757,20 @@ 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 `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' | '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. +- 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 2449ba34..51716bdf 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 (an NFT). Distinct from transfer:request. */ + NFT_TRANSFER: 'nft: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_NFT]: PERMISSION_SCOPES.NFT_TRANSFER, }; // ============================================================================= diff --git a/connect/protocol.ts b/connect/protocol.ts index 0130cd69..204b39bf 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,9 @@ export const INTENT_ACTIONS = { RECEIVE: 'receive', SIGN_MESSAGE: 'sign_message', MINT: 'mint', + // #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/docs/API.md b/docs/API.md index 9d4c7423..703af4ba 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`. +### `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.sendCoinless({ + 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; `sendCoinless` +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**: diff --git a/modules/payments-v2/PaymentsFacade.ts b/modules/payments-v2/PaymentsFacade.ts index 7f904184..1adac755 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, 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 { materializeCoinlessSpend } from './send-coinless'; 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: 'coinless'; readonly request: SendCoinlessRequest }; + 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)); } + sendCoinless(request: SendCoinlessRequest): Promise { + return this.track(this.sendCoinlessOutcome(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 sendCoinlessOutcome(request: SendCoinlessRequest): Promise { + return this.runJob({ kind: 'coinless', 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 === 'coinless' || 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 === 'coinless') { + const spend = this.queue.planCoinless(transferId, job.request.tokenId); + const sourceIds = this.markPlanned(transferId, '', spend); + try { + return await materializeCoinlessSpend( + { 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 e4518396..dd146976 100644 --- a/modules/payments-v2/api.ts +++ b/modules/payments-v2/api.ts @@ -9,6 +9,12 @@ export interface SendRequest { memo?: string; } +export interface SendCoinlessRequest { + recipient: string; + tokenId: string; + memo?: string; +} + export interface MintResult { success: boolean; tokenId?: string; @@ -84,6 +90,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; @@ -101,6 +109,7 @@ export interface PaymentsV2 { history(page?: { before?: string; limit?: number }): Promise; send(req: SendRequest): 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 79718747..08cb0103 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({ @@ -282,9 +283,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 === 'coinless' + ? { 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..5e577152 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 === 'coinless') { + 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/index.ts b/modules/payments-v2/index.ts index 90a909c0..06d10d15 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, SendCoinlessRequest, MintResult, HistoryEntry, HistoryPage, PaymentRequestView, PaymentRequestStatus, PaymentsRequestsApi, PendingTransfer } from './api'; export type { StoragePort, DeliveryPort, diff --git a/modules/payments-v2/inventory/InventoryView.ts b/modules/payments-v2/inventory/InventoryView.ts index 914079d2..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 @@ -362,9 +376,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..f85f6c31 --- /dev/null +++ b/modules/payments-v2/machine/payload-view.ts @@ -0,0 +1,19 @@ +import type { CoinIntentPayload, IntentPayload, CoinlessIntentPayload } from './types'; + +export function isCoinlessIntent(p: IntentPayload): p is CoinlessIntentPayload { + return p.kind === 'coinless'; +} + +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..3735aba4 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, CoinlessIntentPayload } 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 buildCoinlessPayload( + recipientPubkey: string, + request: { tokenId: string; memo?: string }, + spentStates: Record +): CoinlessIntentPayload { + return { + v: 2, + kind: 'coinless', + 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..27a146f8 100644 --- a/modules/payments-v2/machine/resume.ts +++ b/modules/payments-v2/machine/resume.ts @@ -6,7 +6,8 @@ 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 { 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'; @@ -168,6 +169,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 'coinless' whose named + // source actually holds coins would move them while history records assets: []. + if (job.payload.kind === 'coinless' && !isCoinlessEnvelope(token.valueEnvelope)) { + 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 { @@ -209,6 +221,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 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', + 'VALIDATION_ERROR' + ); + } + if (p.split !== undefined) { + throw new SphereError('token intent payload cannot carry a split', 'VALIDATION_ERROR'); + } + return { ...(p as CoinlessIntentPayload), kind: 'coinless', 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 +262,20 @@ 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'); + if (typeof p.recipient !== 'string') { + throw new SphereError('intent payload is missing recipient', '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'); + // 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') { + throw new SphereError( + `unsupported intent kind '${String(kind)}' — not resumable by this client`, + 'VALIDATION_ERROR' + ); } - return p as IntentPayload; + 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 bf1321c8..6460dff7 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 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 | CoinlessIntentPayload; + export interface PlannedOp { kind: 'direct' | 'split'; // Plan-derived; never execution-derived. 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..3ec680e5 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. + */ + planCoinless(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-coinless.ts b/modules/payments-v2/send-coinless.ts new file mode 100644 index 00000000..ed954f52 --- /dev/null +++ b/modules/payments-v2/send-coinless.ts @@ -0,0 +1,66 @@ +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'; +import type { SendCoinlessRequest } from './api'; +import type { StoragePort } from './ports'; + +export interface CoinlessSpendDeps { + readonly engine: ITokenEngine; + readonly storagePort: Pick; +} + +export interface CoinlessSpendInput { + readonly transferId: string; + readonly recipientPubkey: string; + readonly request: SendCoinlessRequest; + 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 materializeCoinlessSpend( + deps: CoinlessSpendDeps, + input: CoinlessSpendInput +): 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 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' + ); + } + const keys = await engine.deliveryKeys(bytes); + const payload = buildCoinlessPayload(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/e2e/coinless-tokens.staging.e2e.test.ts b/tests/e2e/coinless-tokens.staging.e2e.test.ts index 811776e5..1f056cd8 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.sendCoinless({ + 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 sendCoinless, 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.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. + const assets = await w.facade.assets(); + expect(assets[0]?.totalAmount).toBe('250'); + }, + 600_000 + ); +}); diff --git a/tests/mutation/probes.json b/tests/mutation/probes.json index b5c54f79..46a124de 100644 --- a/tests/mutation/probes.json +++ b/tests/mutation/probes.json @@ -1426,5 +1426,96 @@ "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/pinned-balance.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": "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 (!isCoinlessEnvelope(token.valueEnvelope)) {", + "replace": " if (false as boolean) {", + "tests": [ + "tests/unit/payments-v2/send-coinless.test.ts" + ] + }, + { + "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 === '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" + ] + }, + { + "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 !== 'coinless') {", + "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 === '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/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..5fffe7a2 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_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', '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', + '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 2562a3db..db399b99 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_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_nft onto transfer:request + // would silently widen every dApp that already holds it. + expect(hasIntentPermission(new Set([PERMISSION_SCOPES.TRANSFER_REQUEST]), INTENT_ACTIONS.SEND_NFT)).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('nft:transfer authorises send_nft', () => { + expect(hasIntentPermission(new Set([PERMISSION_SCOPES.NFT_TRANSFER]), INTENT_ACTIONS.SEND_NFT)).toBe(true); + }); +}); 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..463471a7 100644 --- a/tests/unit/payments-v2/facade.test.ts +++ b/tests/unit/payments-v2/facade.test.ts @@ -764,3 +764,124 @@ describe('PaymentsFacade — mint', () => { expect(world.facade.tokens()).toHaveLength(1); }); }); + +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.sendCoinless({ 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.sendCoinless({ 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.sendCoinless({ 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.sendCoinless({ 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.sendCoinless({ 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.sendCoinless({ recipient: '@peer', tokenId: nft.blob.tokenId }); + await vi.waitFor(() => expect(gate.entered).toBe(true)); + await expect( + world.facade.sendCoinless({ recipient: '@peer', tokenId: nft.blob.tokenId }) + ).rejects.toThrow(/already reserved/); + + gate.release(); + 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 + .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 + // 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(); + 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' }]); + }); +}); 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..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; @@ -425,6 +437,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..7b83263f 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'); @@ -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'); @@ -309,3 +309,109 @@ 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 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. + const w = makeWorld(); + const valued = await w.seed(1000n); + const id = 'c0000000-0000-4000-8000-000000000003'; + await stageRawIntent(w, id, { + v: 2, + kind: 'coinless', + 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 }; +} diff --git a/tests/unit/payments-v2/pinned-balance.test.ts b/tests/unit/payments-v2/pinned-balance.test.ts index 4809c45c..c5d289d8 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 () => { + // 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(); + expect(ledger.unprovenReason()).not.toBeNull(); + const queue = new SpendQueue({ + ledger, + getPool: () => [], + spendableCoinless: () => true, + }); + 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', () => { + const ledger = new ReservationLedger(); + ledger.setAuthoritative(true); + expect(ledger.unprovenReason()).toBeNull(); + const queue = new SpendQueue({ + ledger, + getPool: () => [], + spendableCoinless: (id) => id === 'nft-1', + }); + expect(queue.planCoinless('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-coinless.test.ts b/tests/unit/payments-v2/send-coinless.test.ts new file mode 100644 index 00000000..6f61b2db --- /dev/null +++ b/tests/unit/payments-v2/send-coinless.test.ts @@ -0,0 +1,96 @@ +/** + * #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 { materializeCoinlessSpend } from '../../../modules/payments-v2/send-coinless'; + +const TOKEN_ID = 'aa'.repeat(32); +const RECIPIENT = '02'.repeat(16) + '03'; + +function engineWith( + value: SphereToken['value'], + valueEnvelope: SphereToken['valueEnvelope'] = value === null ? 'none_other' : 'sphere' +): ITokenEngine { + return { + 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; +} + +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('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(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 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 materializeCoinlessSpend(deps, input); + expect(ctx.plan.ops).toHaveLength(1); + expect(ctx.plan.ops[0]?.kind).toBe('direct'); + 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 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(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. */