Skip to content
66 changes: 66 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,72 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added — coinless tokens (#777, #781; wallet-api#140/#141/#147)

`payments.coinless(): CoinlessToken[]` and `payments.tokenData(tokenId): Promise<Uint8Array | null>`.

A token whose genesis data carries no value envelope names no coin — an NFT. `tokens()` skipped
every such entry, so it was held, verified, claimed and tombstone-recoverable, and shown nowhere.

The two reads are **disjoint**: an active inventory entry is in exactly one, so every existing
`tokens()`/`assets()` consumer is byte-identical and a coinless token joins no balance and no
coin-selection pool. It is deliberately not a `Token` — that type requires `coinId`, `symbol`,
`decimals` and `amount`, and filling them with `''`/`'0'` would put untrue values in fields
consumers sum or format. (#781 proposed widening `tokens()`; the divergence is recorded there.)

`tokenData()` is a call rather than a field: the genesis payload is an NFT's actual content, it is
unbounded, and blobs are lazy under server custody, so a list read must never carry it.

`tokenType` names the token's **class, not the instance** — every token of one kind shares a type.
Display metadata (`name`, `iconUrl`) is resolved onto the row from the registry the Sphere OWNS
(#767), so callers never reach for one: the process-global singleton is repointed by another
Sphere's init, which would retarget a second wallet on another network. `TokenRegistry` gains
`getTypeDefinition()`/`getTypeMeta()`, reading the token-type namespace — one registry file carries
both namespaces discriminated by `assetKind`, and the flat `getDefinition()` map cannot tell a type
from a coin id. An unrecognised type is legitimate and must never cause a token to be hidden.

`transfer:incoming` now names an arriving coinless token in a disjoint `coinless` field; it
previously mapped over assets, so such an arrival announced `tokens: []` and a UI listening for
arrivals saw nothing land.

### 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
callers read `false` as "data token, no value". Since `decodeTag` parses the tagged body and asserts
exhaustion, a **valid** `SpherePaymentData` carrying one trailing byte, a truncated one, a
non-canonically encoded tag head, and a `tag(55799)`-wrapped envelope each rendered as `value = null`
— real coins shown as zero, silently, with no error surface. A balance has no other one: showing
zero is the outcome from which a user cannot tell "no coins" from "I cannot read the coins".

Replaced by a structural classifier (`token-engine/value-envelope.ts`) ported from wallet-api's §8.2
step 6, reading the outer major type and the tag head alone. `SphereToken` gains `valueEnvelope`,
which distinguishes *why* `value` is null: `none_*` is genuinely coinless, `bare_collection` is the
bridged dialect this SDK does not decode (so zero means "cannot read", not "carries none").

Two fail-closed guards, both before any chain op: `split()` refuses a source whose value cannot be
read (it previously died inside the SDK with a bare `CborError` naming neither token nor cause), and
`mintDataToken()` refuses opaque bytes classification cannot frame — that check runs *before* the
mint, because `wrapToken` runs after certification and would otherwise strand an on-chain token.

### Fixed — history can record a coinless movement (#780; wallet-api#142/#151)

`recordSent`/`recordMint`/`recordReceived` wrapped scalars unconditionally, so the only expressible
shape was a one-element array — `[{coinId: '', amount: '0'}]` for a coinless token, which wallet-api
refuses so there is never a second wire spelling of "no coin". All three now take the asset list
directly and post `assets: []`.

`History.post` logged every failure as retry-safe. A 4xx is a permanent shape refusal that no retry
can fix, and that indiscriminate swallow is what let a refused receipt vanish with no error surface;
the two are now named apart (408/429 stay transient). It still never throws into the money path.

### Changed (BREAKING, internal ports)

`RecordSentInput`/`RecordMintInput`/`RecordReceivedInput` take `assets: {coinId, amount}[]` in place
of `coinId`/`amount` scalars. `SphereToken` gains required `valueEnvelope` and `tokenType`.
`InventoryItem`/`InventoryItemWire` gain optional `tokenType`. These are internal to the vertical and
the token-engine port; no root-export type changed shape except the additive `CoinlessToken` and
`IncomingTransfer.coinless`.

## [0.16.0] - 2026-09-03

### Removed (BREAKING) — the Sphere lifecycle globals (#766)
Expand Down
54 changes: 52 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,8 @@ console.log('Unicity ID:', identity.nametag); // alice
const assets = await sphere.payments.assets(); // Asset[] grouped by coin
const uct = await sphere.payments.assets(coinIdHex); // filter by coin
const tokens = sphere.payments.tokens(); // individual Token[] (sync view)
const nfts = sphere.payments.coinless(); // CoinlessToken[] — DISJOINT from tokens()
const payload = await sphere.payments.tokenData(id); // an NFT's genesis bytes, or null
const filtered = sphere.payments.tokens({ coinId: '...' });

// 6. Send tokens (L3). Recipient must have a PUBLISHED chain pubkey
Expand Down Expand Up @@ -264,7 +266,9 @@ Typed RPC layer for dApp ↔ wallet communication. Full guide: [`docs/CONNECT.md
| `Sphere.import(options)` | `Sphere` | Import from mnemonic/masterKey |
| `Sphere.importFromLegacyFile(options)` | `Sphere` | Import a `.txt` / flat-JSON / bare-mnemonic backup |
| `sphere.payments.assets(coinId?)` | `Promise<Asset[]>` | Assets grouped by coin (server read-through) |
| `sphere.payments.tokens(filter?)` | `Token[]` | Individual tokens (sync inventory view) |
| `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.mint(coinIdHex, amount)` | `Promise<MintResult>` | Self-mint via engine (journal-first, no faucet) |
| `sphere.payments.receive()` | `Promise<{ transfers }>` | Explicit one-shot mailbox drain |
Expand Down Expand Up @@ -299,7 +303,7 @@ The payments vertical emits exactly 8 events; identity/comms/groupchat events ri

| Event | Payload | When |
|-------|---------|------|
| `transfer:incoming` | `IncomingTransfer` (`{ senderPubkey, senderNametag?, tokens, memo?, receivedAt }`) | Tokens landed from the wallet-api mailbox (verified before entering balance) |
| `transfer:incoming` | `IncomingTransfer` (`{ senderPubkey, senderNametag?, tokens, coinless?, memo?, receivedAt }`) | Tokens landed from the wallet-api mailbox (verified before entering balance). A coinless arrival is named in `coinless`, NOT in `tokens` — read both |
| `transfer:updated` | `TransferResult` | Outgoing transfer changed status (read `status` / `deliveryPending`) |
| `transfer:attention` | `{ transferId, code, detail? }` | A transfer needs operator attention (stuck checkpoint, undeliverable, deferred) |
| `inventory:updated` | `{}` | Inventory changed (send/receive/mint/resync) |
Expand Down Expand Up @@ -565,10 +569,30 @@ interface TokenBlob {
token: Uint8Array; // the SDK's own Token.toCBOR() bytes — no sphere envelope
}

// A holding that names NO coin (wallet-api#140) — an NFT. Deliberately NOT a Token:
// that type requires coinId/symbol/decimals/amount, and sentinels would put untrue
// values in fields consumers sum. Disjoint from tokens(); joins no balance.
interface CoinlessToken {
tokenId: string; // the INSTANCE key
tokenType?: string; // the token's CLASS, lowercase hex — see the caveat below
name?: string; // resolved from the wallet's OWN registry when recognised
iconUrl?: string;
stateHash: string;
transferring: boolean;
suspectedSpent?: boolean;
createdAt: number;
updatedAt: number;
}

interface SphereToken {
sdkToken: Token; // OPAQUE SDK handle — never touch outside token-engine/
blob: TokenBlob; // serializable form
value: SphereValue | null; // decoded { assets: [{ coinId, amount: bigint }] }
// #778: WHY value is null. 'none_*' = genuinely coinless; 'bare_collection' =
// coins in the bridged dialect this SDK does not decode, so zero means "cannot
// read", NOT "has none". A corrupt envelope throws instead of reaching this.
valueEnvelope: 'sphere' | 'bare_collection' | 'none_tag' | 'none_other' | 'none_absent';
tokenType: string; // genesis TokenType hex — the CLASS, never the instance
}
```

Expand Down Expand Up @@ -711,6 +735,32 @@ authoritative for build success.
chain op; a replay converges by idempotent same-seed re-call. Lets a fresh wallet top up on
testnet2.

### Coinless tokens (#777/#778/#780/#781, wallet-api#140/#141/#147/#151)
- A token whose genesis data is **not a value envelope** names no coin. The word is **coinless**,
never "non-fungible": in Unicity every token is non-fungible by construction (each is a unique
object keyed by `tokenId`), so that term names every token and distinguishes none.
- Surfaced by `payments.coinless()`, **disjoint** from `tokens()` — an active entry is in exactly
one, so no coin consumer changes and an NFT joins no balance and no selector pool. It is not a
`Token`: that requires `coinId`/`symbol`/`decimals`/`amount`, and sentinels put untrue values in
fields consumers sum. `payments.tokenData(tokenId)` reads the genesis payload on demand.
- **`tokenType` is a CLASS, not an identity.** Every token of one kind shares a type; `tokenId` is
the instance key. Resolve names with `TokenRegistry.getTypeDefinition()` — the registry file
holds TWO id namespaces discriminated by `assetKind` (a `fungible` entry's id is a coin id, a
`non-fungible` entry's is a token type), and the flat `getDefinition()` map cannot tell them
apart. An unrecognised type is legitimate — never reject or hide a token for it. Do NOT build a
"group by type" UI for *valued* tokens: `mint()` and split outputs derive a type per operation.
- **`value === null` is ambiguous — read `valueEnvelope`.** `none_*` is genuinely coinless;
`bare_collection` is the bridged dialect this SDK does not decode, so zero there means "cannot
read", not "has none". Conflating them either hides real coins or invents a phantom NFT.
- A corrupt value envelope **throws** rather than reading as valueless (#778). The classifier's
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.
- 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
needs the client verdict re-derived, not merely re-tested (recorded in wallet-api's §8.2 too).

### Unicity IDs (nametags)
- Human-readable aliases (e.g., `@alice`) for receiving payments.
- **Registration = publishing the Nostr identity binding** (name ↔ chainPubkey,
Expand Down
52 changes: 52 additions & 0 deletions docs/API.md
Original file line number Diff line number Diff line change
Expand Up @@ -382,6 +382,7 @@ so a crash re-claims instead of losing.
```typescript
const { transfers } = await sphere.payments.receive();
sphere.on('transfer:incoming', (t) => console.log('from', t.senderNametag));
// A coinless (NFT) arrival is named in `t.coinless`, never in `t.tokens` — read both.
```

### `assets(coinId?: string): Promise<Asset[]>`
Expand All @@ -408,6 +409,57 @@ const all = sphere.payments.tokens();
const uctOnly = sphere.payments.tokens({ coinId: coinIdHex });
```

### `coinless(): CoinlessToken[]`

Synchronous view of holdings that name **no coin** — what a UI calls an NFT (wallet-api#140).

**Disjoint from `tokens()`**: an active token is in exactly one of the two reads, so existing
`tokens()`/`assets()` consumers are unaffected and a coinless token contributes to no balance.
It is deliberately not a `Token`: `Token` requires `coinId`, `symbol`, `decimals` and `amount`,
and filling those with `''`/`'0'` would put untrue values in fields consumers sum or format.

```typescript
interface CoinlessToken {
readonly tokenId: string; // genesis-stable INSTANCE key
readonly tokenType?: string; // the token's CLASS, lowercase hex — see below
readonly name?: string; // resolved from the OWNED registry, when recognised
readonly iconUrl?: string;
readonly stateHash: string;
readonly transferring: boolean; // reserved by a converging transfer
readonly suspectedSpent?: boolean;
readonly createdAt: number;
readonly updatedAt: number;
}

const nfts = sphere.payments.coinless();
```

`tokenType` names the token's **class, not the instance** — every token of one kind shares a type,
so two NFTs of a collection are told apart by `tokenId`. It is absent on rows the backend indexed
before it recorded types, and an unrecognised type is legitimate (a minter may use its own), so
never reject or hide a token for it. Do not build a "group by type" UI on it for *valued* tokens:
`mint()` and split outputs derive a type per operation, so there it is per-mint noise.

`name` and `iconUrl` are resolved **for you**, from the registry this wallet owns, whenever the type
is recognised. Do not look the type up yourself through `TokenRegistry.getInstance()`: a Sphere owns
its registry (#767) and the process-global one is repointed by another Sphere's init, so a second
wallet on another network would retarget it.

### `tokenData(tokenId: string): Promise<Uint8Array | null>`

The token's genesis payload — an NFT's actual content — or `null` when it carries none.

A call rather than a field on the row: the payload is unbounded and blobs are lazy under server
custody, so a list read must never carry it. Fetches the blob and decodes it. Throws
`VALIDATION_ERROR` for a token this wallet does not hold.

```typescript
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`.

### `mint(coinIdHex: string, amount: bigint): Promise<MintResult>`

Self-mint fungible tokens to this wallet via the token engine (no faucet). **Journal-first**:
Expand Down
19 changes: 19 additions & 0 deletions docs/INTEGRATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -512,6 +512,25 @@ for (const token of tokens) {
const uctTokens = sphere.payments.tokens({ coinId: coinIdHex });
```

### Get Coinless Tokens (NFTs)

A token whose genesis data carries no value envelope names no coin. These are a **separate,
disjoint read** — never returned by `tokens()`, and contributing to no balance:

```typescript
const nfts = sphere.payments.coinless();

for (const nft of nfts) {
console.log(`Token ${nft.tokenId}`);
// The CLASS of token, not its identity — every token of one kind shares a type.
console.log(` Type: ${nft.tokenType ?? '(unrecorded)'}`);
}

// The payload — an NFT's actual content. A call, not a field: it is unbounded
// and the blob is fetched on demand.
const bytes = await sphere.payments.tokenData(nfts[0].tokenId);
```

Lazy tokens (blob not yet downloaded) carry value metadata only; the blob is fetched on demand
when the token is selected for a spend.

Expand Down
25 changes: 25 additions & 0 deletions docs/MIGRATION-PAYMENTS-V2.md
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,31 @@ Error contract is UNCHANGED and load-bearing: the typed codes
`ProofUnconfirmedError.cause` carrying the raw network error all survive
verbatim. Keep your PENDING_COMMIT handling exactly as it is.

### 2a. New in [Unreleased]: coinless tokens (NFTs)

Nothing to migrate — purely additive — but worth knowing so a token list is not read as complete:

| need | call |
|---|---|
| coin tokens | `tokens(filter?)` — **unchanged**, and still excludes coinless holdings |
| coinless (NFT) holdings | `coinless(): CoinlessToken[]` |
| an NFT's payload | `tokenData(tokenId): Promise<Uint8Array \| null>` |

The two reads are **disjoint**: an active token appears in exactly one, so `tokens()`, `assets()`
and every balance are byte-identical to before. A UI that shows "all my tokens" now needs both.

A coinless token is deliberately not a `Token` — that type requires `coinId`, `symbol`, `decimals`
and `amount`, and populating them with `''`/`'0'` would put untrue values in fields UIs sum and
format. `CoinlessToken` carries `tokenId` (the instance), `tokenType` (the **class** — every token
of one kind shares it), `stateHash`, `transferring`, `suspectedSpent` and timestamps.

`transfer:incoming` gains an optional `coinless` array. If you render arrivals from `tokens`, a
coinless arrival will look empty — read `coinless` too.

An NFT's display metadata (`name`, `iconUrl`) is resolved for you, from the registry the Sphere
owns. Do not look the type up via `TokenRegistry.getInstance()`: a second Sphere's init repoints
that singleton (#767). An unrecognised type is legitimate — the row still renders, unnamed.

## 3. Composition changes

- **wallet-api is required** for money. `FileTokenStorageProvider` /
Expand Down
Loading
Loading