Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
17 changes: 16 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<Uint8Array \| null>` | A token's genesis payload (fetches the blob) |
| `sphere.payments.send(request)` | `Promise<TransferResult>` | Send L3 tokens (wallet-api vertical) |
| `sphere.payments.send(request)` | `Promise<TransferResult>` | Send L3 coin tokens (wallet-api vertical) |
| `sphere.payments.sendCoinless(request)` | `Promise<TransferResult>` | Move a COINLESS token whole (`{recipient, tokenId, memo?}`) |
| `sphere.payments.mint(coinIdHex, amount)` | `Promise<MintResult>` | Self-mint via engine (journal-first, no faucet) |
| `sphere.payments.receive()` | `Promise<{ transfers }>` | Explicit one-shot mailbox drain |
| `sphere.payments.history(page?)` | `Promise<HistoryPage>` | Paged history (`{ before?, limit? }`) |
Expand Down Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions connect/permissions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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];
Expand Down Expand Up @@ -66,6 +68,7 @@ export const INTENT_PERMISSIONS: Record<string, PermissionScope> = {
[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,
};

// =============================================================================
Expand Down
5 changes: 4 additions & 1 deletion connect/protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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];
Expand Down
25 changes: 25 additions & 0 deletions docs/API.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<TransferResult>`

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<MintResult>`

Self-mint fungible tokens to this wallet via the token engine (no faucet). **Journal-first**:
Expand Down
139 changes: 69 additions & 70 deletions modules/payments-v2/PaymentsFacade.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,9 @@
// lives here (bounded re-plan, remainder accumulation, keep-open rethrow);
// the TransferMachine stays policy-free. Wiring lives in compose.ts.

import { sha256 } from '@noble/hashes/sha2.js';

Check warning on line 5 in modules/payments-v2/PaymentsFacade.ts

View workflow job for this annotation

GitHub Actions / build (24)

'sha256' is defined but never used. Allowed unused vars must match /^_/u

Check warning on line 5 in modules/payments-v2/PaymentsFacade.ts

View workflow job for this annotation

GitHub Actions / build (22)

'sha256' is defined but never used. Allowed unused vars must match /^_/u

import { bytesToHex } from '../../core/crypto';

Check warning on line 7 in modules/payments-v2/PaymentsFacade.ts

View workflow job for this annotation

GitHub Actions / build (24)

'bytesToHex' is defined but never used. Allowed unused vars must match /^_/u

Check warning on line 7 in modules/payments-v2/PaymentsFacade.ts

View workflow job for this annotation

GitHub Actions / build (22)

'bytesToHex' is defined but never used. Allowed unused vars must match /^_/u
import {
PartialSendConflictError,
SphereError,
Expand All @@ -15,10 +15,12 @@
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';
Expand Down Expand Up @@ -62,8 +64,17 @@

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[];
Expand Down Expand Up @@ -266,6 +277,10 @@
return this.track(this.sendOutcome(request));
}

sendCoinless(request: SendCoinlessRequest): Promise<TransferResult> {
return this.track(this.sendCoinlessOutcome(request));
}

async receive(): Promise<{ transfers: IncomingTransfer[] }> {
const transfers = await this.track(this.receiveLoop.drainOnce());
return { transfers };
Expand Down Expand Up @@ -306,13 +321,36 @@
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<TransferResult> {
private sendOutcome(request: SendRequest): Promise<TransferResult> {
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<TransferResult> {
return this.runJob({ kind: 'coinless', request }, '0');
}

private async runJob(job: SendJob, amount: string): Promise<TransferResult> {
const run: SendRun = {
amount: request.amount,
amount,
delivered: [],
sentTokens: [],
tokenTransfers: [],
Expand All @@ -322,7 +360,7 @@
lastTransferId: '',
};
try {
return await this.sendWithPolicy(request, run);
return await this.sendWithPolicy(job, run);
} catch (err) {
this.emitCleanFailure(err, run.lastTransferId);
throw err;
Expand All @@ -344,19 +382,22 @@
this.deps.emit('transfer:updated', failed);
}

private async sendWithPolicy(request: SendRequest, run: SendRun): Promise<TransferResult> {
const recipient = await requireSameNetworkRecipient(this.deps, request.recipient);
private async sendWithPolicy(job: SendJob, run: SendRun): Promise<TransferResult> {
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') {
Expand Down Expand Up @@ -470,9 +511,25 @@
return { kind: 'rethrow', error: err };
}

private async planAndMaterialize(recipientPubkey: string, request: SendRequest, run: SendRun): Promise<AttemptCtx> {
private async planAndMaterialize(recipientPubkey: string, job: SendJob, run: SendRun): Promise<AttemptCtx> {
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);
Expand Down Expand Up @@ -533,6 +590,7 @@
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<number> {
const engine = this.engine();
Expand Down Expand Up @@ -646,71 +704,12 @@
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<MintResult> {
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<void> {
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<number> {
let resolved = 0;
Expand Down Expand Up @@ -745,7 +744,7 @@
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;
}

Expand Down
Loading
Loading