diff --git a/docs/architecture/brc-100.md b/docs/architecture/brc-100.md index 4fb603ae0..f249870fe 100644 --- a/docs/architecture/brc-100.md +++ b/docs/architecture/brc-100.md @@ -68,6 +68,8 @@ The application does not need separate business logic for desktop and mobile if `noSend` and `sendWith` support chained batches. A wallet can create several actions without broadcasting them, pass change forward through `noSendChange`, and later send them together. +For the full lifecycle — every storage call each method makes, the transaction and output state machines, and where the TypeScript and Go implementations differ — see [Wallet UTXO Lifecycle](./wallet-utxo-lifecycle.md). + ## Labels vs Tags | Concept | Scope | Used by | diff --git a/docs/architecture/wallet-utxo-lifecycle.md b/docs/architecture/wallet-utxo-lifecycle.md new file mode 100644 index 000000000..6047b9da8 --- /dev/null +++ b/docs/architecture/wallet-utxo-lifecycle.md @@ -0,0 +1,819 @@ +--- +id: architecture-wallet-utxo-lifecycle +title: Wallet UTXO Lifecycle +kind: meta +domain: wallet +version: 'n/a' +last_updated: '2026-08-13' +last_verified: '2026-08-13' +review_cadence_days: 30 +status: stable +tags: ['architecture', 'BRC-100', 'wallet', 'utxo', 'storage'] +--- + +# Wallet UTXO Lifecycle + +[BRC-100](../specs/brc-100-wallet.md) defines _what_ a wallet method accepts and returns. +This page covers the other half: what actually happens between the call and the return — +which layer does the work, which storage rows change, and what state a transaction and its +outputs are left in. + +It is written against two implementations, [`@bsv/wallet-toolbox`](../packages/wallet/wallet-toolbox.md) +(TypeScript) and [`go-wallet-toolbox`](https://github.com/bsv-blockchain/go-wallet-toolbox) +(Go). The BRC-100 specification is the reference; both implementations are described +against it, and the places where either one deviates are collected in +[Implementation differences](#implementation-differences). + +## How to read the diagrams + +Each lane is a layer, and time runs downward. The lanes below appear in every diagram on +this page in the same order. + +| Lane | TypeScript | Go | +| -------- | --------------------------------------------------------- | ----------------------------------------------------------- | +| App | `packages/sdk/src/wallet/substrates/` | caller | +| Wallet | `wallet-toolbox/src/Wallet.ts` | `pkg/wallet/wallet.go` | +| Signer | `src/signer/methods/` | `pkg/wallet/internal/actions/` | +| Manager | `src/storage/WalletStorageManager.ts` | `pkg/storage/storage_manager.go` | +| Storage | `src/storage/StorageProvider.ts` + `src/storage/methods/` | `pkg/storage/provider.go` + `pkg/storage/internal/actions/` | +| Database | `StorageKnex` / `StorageIdb` / `StorageClient` | `pkg/internal/storage/repo` over GORM | +| Services | `WalletServices` — broadcast, chaintracker, status | `pkg/services` | +| Monitor | `src/monitor/tasks/` | `pkg/monitor` | + +Two decorator layers sit between App and Wallet in TypeScript and are omitted from the +diagrams to keep them readable: `WalletPermissionsManager` (permission gating) and +`CWIStyleWalletManager` / `SimpleWalletManager` (authentication and key management). They +forward every BRC-100 method without changing its storage behavior. + +Methods are ordered throughout this page by their wire call number from +`packages/sdk/src/wallet/substrates/WalletWireCalls.ts` — `createAction` is 1 and +`getVersion` is 28. + +## The action lifecycle + +A transaction that the wallet creates passes through up to four BRC-100 calls and a +background settling phase. `createAction` funds and records it; `signAction` completes it +when the application supplies unlocking scripts; broadcast hands it to the network; and +the Monitor converges its status on chain reality long after the original call returned. + +### createAction — funding and planning + +```mermaid +sequenceDiagram + autonumber + participant App + participant Wallet + participant Signer + participant Manager + participant Storage + participant DB as Database + + App->>Wallet: createAction args + Wallet->>Wallet: validateCreateActionArgs + Wallet->>Signer: createAction + Signer->>Manager: storage.createAction + Manager->>Storage: runAsWriter + getAuth + + Note over Storage,DB: Planning — outside the write transaction + Storage->>DB: findOutputBaskets 'default' + Storage->>DB: findOutputsByOutpoints for explicit inputs + Storage->>DB: findReservedActionBatchOutputIds + Storage->>DB: findAvailableManagedChangeInputCandidates + Storage->>DB: findTransactionStatusesByIds + Storage->>Storage: generateChangeSdk over CanonicalChangeSelector + + Note over Storage,DB: Commit — one database transaction + Storage->>DB: insertTransaction status 'unsigned' + Storage->>DB: findOrInsertTxLabelsBulk + Storage->>DB: findFundingOutputsForUpdate SELECT FOR UPDATE + Storage->>DB: markChangeInputsSpent + Storage->>DB: insertCommission + Storage->>DB: insertOutputs + insertOutputTagMap + Storage->>DB: updateTransaction satoshis + + Storage-->>Signer: StorageCreateActionResult with reference + Signer->>Signer: buildSignableTransaction + + alt every input has an unlocking script + Signer->>Signer: completeSignedTransaction + Signer->>Signer: verifyUnlockScripts + Signer->>Storage: processAction + Storage-->>App: txid and tx + else an input has only unlockingScriptLength + Signer->>Wallet: cache in pendingSignActions + Wallet-->>App: signableTransaction with reference + end +``` + +The write transaction opens at `storage/methods/createAction.ts:196` and every call from +`insertTransaction` onward is inside it. The transaction row is born `unsigned` +(`createAction.ts:690`). If anything downstream throws, the cleanup path drives it to +`failed` (`:309`) and records a forensic row (`:312`) rather than deleting evidence. + +`markChangeInputsSpent` (`:1398`) is the moment funding becomes exclusive: it flips the +selected change outputs to `{spendable: false, spentBy: transactionId}` under the row +locks taken by `findFundingOutputsForUpdate` (`:1376`). + +### Storage call ledger + +Every storage-interface call each BRC-100 UTXO method makes, in execution order. + +| Method | Storage calls | +| ------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `createAction` | `findOutputBaskets` · `findOutputsByOutpoints` / `…ForUpdate` · `findReservedActionBatchOutputIds` · `findAvailableManagedChangeInputCandidates` · `findTransactionStatusesByIds` · `getBeefForTransactions` · **transaction opens** · `insertTransaction` · `findOrInsertTxLabelsBulk` · `findOrInsertTxLabelMap` · `findFundingOutputsForUpdate` · `markChangeInputsSpent` · `validateOutputScript` · `getRawTxOfKnownValidTransaction` · `updateTransaction` · `findOrInsertOutputBasketsBulk` · `findOrInsertOutputTagsBulk` · `insertCommission` · `insertOutputs` · `insertOutputTagMap` | +| `signAction` | none directly — the only storage touch is `processAction` | +| `processAction` | `findTransactions` · `findOutputs` · `findCommissions` · **transaction opens** · `ProvenTxReq.insertOrMerge` · `updateOutput` per output · `updateTransaction` · then `updateProvenTxReq` + `updateTransaction` (delayed) or `attemptToPostReqsToNetwork` (immediate) | +| `abortAction` | `findAbortableTransaction` · `checkAbortChainProtection` · `updateTransactionStatus 'failed'` · ProvenTxReq → `invalid` | +| `internalizeAction` | `findTransactions` · `findOutputs` · `findOutputBaskets` · `findOrInsertOutputBasket` · `findOrInsertProvenTx` · `findOrInsertTransaction` · `updateOutput` (mark inputs spent / restore) · `insertOutput` · `updateTransaction` · `findOrInsertTxLabel` · `shareReqsWithWorld` | +| `listActions` | `listActions` — read-only, `runAsReader` | +| `listOutputs` | `listOutputs` — read-only, `runAsReader` | +| `relinquishOutput` | `findOutputs` · `updateOutput` clearing `basketId` | + +`allocateChangeInput` is still declared on `StorageProvider` and implemented in both +`StorageKnex` and `StorageIdb`, but the current `createAction` path does not call it. +Coin selection runs in memory through `CanonicalChangeSelector` and is committed by +`markChangeInputsSpent`. Treat `allocateChangeInput` as legacy surface, not as part of +this flow. + +### signAction — completing a signable transaction + +```mermaid +sequenceDiagram + autonumber + participant App + participant Wallet + participant Signer + participant Storage + participant DB as Database + participant Net as Services + + App->>Wallet: signAction reference and spends + Wallet->>Wallet: look up pendingSignActions by reference + + alt reference not in this session + Wallet-->>App: WERR_NOT_IMPLEMENTED + else reference found + Wallet->>Signer: signAction + Signer->>Signer: completeSignedTransaction with supplied spends + Signer->>Storage: processAction + Storage->>DB: commit rawTx, outputs, status + Storage->>Net: broadcast unless delayed or noSend + Storage-->>Signer: sendWithResults + Signer->>Signer: verifyUnlockScripts + Signer-->>App: txid and tx + end +``` + +The `pendingSignActions` cache is process memory on the `Wallet` instance. A `reference` +issued by one process cannot be signed by another, and cannot survive a restart — +`Wallet.ts:1056` throws `WERR_NOT_IMPLEMENTED` rather than attempting recovery. Go stores +these in a pluggable repository instead; see [difference 6](#implementation-differences). + +### processAction — commit and broadcast + +`processAction` is the shared tail of both `createAction` and `signAction`. It validates +the signed transaction against what storage planned, commits it, and then either +broadcasts or queues. + +```mermaid +sequenceDiagram + autonumber + participant Signer + participant Storage + participant DB as Database + participant Net as Services + participant Mon as Monitor + + Signer->>Storage: processAction rawTx, txid, reference + + Storage->>DB: findTransactions by reference + Storage->>DB: findOutputs, findCommissions + Storage->>Net: nLockTimeIsFinal + Note over Storage: status must be 'unsigned' or 'unprocessed' + Storage->>Storage: determineReqTxStatus + + Note over Storage,DB: commitNewTxToStorage — one transaction + Storage->>DB: ProvenTxReq insertOrMerge with rawTx and inputBEEF + Storage->>DB: updateOutput per output, script offsets + Storage->>DB: updateTransaction status and txid, drop rawTx + + alt noSend + Storage-->>Signer: no broadcast, noSendChange returned + else delayed broadcast + Storage->>DB: ProvenTxReq 'unsent', transaction 'sending' + Mon-->>Net: TaskSendWaiting broadcasts later + else immediate broadcast + Storage->>Net: attemptToPostReqsToNetwork + Net-->>Storage: aggregate result + Storage->>DB: applyAggregateStatus + Storage-->>Signer: sendWithResults and notDelayedResults + end +``` + +Which statuses a new transaction gets is decided entirely by the three options +`noSend`, `sendWith`, and `acceptDelayedBroadcast`: + +| Case | ProvenTxReq before | Transaction before | ProvenTxReq after send | Transaction after send | +| -------------------------- | ------------------ | ------------------ | ---------------------- | ---------------------- | +| `noSend` and no `sendWith` | `nosend` | `nosend` | — | — | +| not `noSend`, delayed | `unsent` | `unprocessed` | — | — | +| not `noSend`, immediate | `unprocessed` | `unprocessed` | `unmined` | `unproven` | + +Only the third row broadcasts before `createAction` returns. The first two leave the +transaction for the Monitor or for a later `sendWith` batch. + +When a broadcast does happen, its outcome maps to statuses like this: + +| Aggregate broadcast result | ProvenTxReq | Transaction | +| ------------------------------ | ------------- | ----------- | +| success | `unmined` | `unproven` | +| double spend | `doubleSpend` | `failed` | +| invalid transaction | `invalid` | `failed` | +| service error, attempt counted | `sending` | `sending` | + +A guard prevents degradation: a request already `completed` or `unmined` is never moved +backward by a late result. + +## Transaction status + +Two rows track every transaction. `transactions.status` is what `listActions` reports to +the application. `proven_tx_reqs.status` is the broadcast and proof state machine that the +Monitor drives. They advance together but are not the same set of values. + +```mermaid +stateDiagram-v2 + [*] --> unsigned : createAction commits the plan + unsigned --> unprocessed : signAction or immediate sign + unsigned --> nosend : noSend option + unprocessed --> sending : queued for delayed broadcast + unprocessed --> unproven : broadcast accepted + nosend --> sending : later sendWith batch + sending --> unproven : broadcast accepted + sending --> sending : service error, retry + unproven --> completed : proof found and validated + unproven --> failed : request went terminal + unsigned --> failed : abortAction + unprocessed --> failed : abortAction + nosend --> failed : abortAction + sending --> failed : double spend or invalid + failed --> unfail : operator retry + unfail --> unproven : proof found + unfail --> failed : no proof found + completed --> [*] + failed --> [*] +``` + +The proof-request machine underneath it: + +```mermaid +stateDiagram-v2 + [*] --> unprocessed : request created + [*] --> nosend : noSend option + unprocessed --> unsent : queued for delayed broadcast + unprocessed --> sending : immediate broadcast + unsent --> sending : TaskSendWaiting picks it up + nosend --> unsent : later sendWith batch + sending --> unmined : broadcast accepted + sending --> callback : accepted, callback service registered + sending --> sending : service error, attempts incremented + sending --> doubleSpend : rejected as double spend + sending --> invalid : rejected as invalid + callback --> unconfirmed : proof received, not yet confirmed + unconfirmed --> completed : chaintracks confirms + unmined --> completed : merkle path found and validated + doubleSpend --> unfail : review finds a false positive + invalid --> unfail : operator sets unfail + unfail --> unmined : proof found, attempts reset + unfail --> invalid : no proof found + completed --> [*] + doubleSpend --> [*] + invalid --> [*] +``` + +Terminal states are `completed`, `invalid`, and `doubleSpend`. Everything else is +non-terminal and eligible for Monitor attention. + +### The transition primitive + +`StorageProvider.updateTransactionStatus` is where transaction status and output +spendability are coupled. It enforces two invariants and one cascade: + +- A `completed` transaction, or one with a `provenTxId`, cannot change status. +- A `failed` transaction cannot be un-failed by this method. +- Moving to `failed` runs `releaseInputsAllocatedToFailedTransaction` — every output this + transaction consumed goes back to `{spendable: true, spentBy: undefined}` — and + `markFailedTransactionOutputsNotSpendable`, which makes the outputs it produced + unspendable. + +Restoring inputs optimistically is deliberate: most failures are transient. When a +broadcaster reports evidence that an input really is gone — a double-spend or +missing-inputs verdict with positive `isUtxo === false` confirmation — +`recordStaleInputEvidence` overrides the restore for exactly those inputs, so the wallet +does not select the same dead UTXO on the next `createAction`. Inputs whose failure was +malformed-transaction or fee-related are left spendable and retry normally. + +## Outputs and UTXO state + +There is no output status column. An output's state is a tuple: `spendable`, `spentBy`, +`basketId`, and the status of its parent transaction. + +```mermaid +stateDiagram-v2 + [*] --> planned : createAction plans a change output + planned --> created : insertOutputs, spendable true + created --> visible : parent tx reaches a listable status + visible --> allocated : markChangeInputsSpent, spendable false + allocated --> spent : spending transaction completes + allocated --> visible : spending transaction fails, inputs released + visible --> unbasketed : relinquishOutput clears basketId + created --> dead : parent transaction failed + spent --> [*] + dead --> [*] +``` + +`listOutputs` does not read `spendable` alone. Visibility is a join: the parent +transaction status must be one of `completed`, `unproven`, `nosend`, or `sending`, **and** +`spendable` must be true. An output belonging to an `unsigned` or `failed` transaction is +invisible regardless of its own column. + +`relinquishOutput` clears `basketId` and nothing else. It removes the output from the +basket index; it does not make it unspendable and does not mark it spent. BRC-100 +describes it as removing an output from a basket without spending it, which is what this +implements — but note that an unbasketed output is no longer returned by any +`listOutputs` call, since `basket` is a required argument. + +### Funding and coin selection + +```mermaid +sequenceDiagram + autonumber + participant Plan as Planner + participant DB as Database + participant Sel as CanonicalChangeSelector + participant Gen as generateChangeSdk + + Plan->>DB: findAvailableManagedChangeInputCandidates + Note over DB: spendable = true, managed change fields,
parent status in the current tier,
minus action-batch reservations + DB-->>Plan: candidate outputs + + loop tier: completed, then +unproven, then +sending + Plan->>Sel: load candidates + Plan->>Gen: generateChangeSdk with fee model and basket policy + Gen->>Sel: allocate — exact, else least-over, else largest-under + Sel-->>Gen: chosen inputs + Gen-->>Plan: inputs, change outputs, fee + Plan->>Plan: compare serialized BEEF cost, keep cheapest + end + + Plan->>DB: findFundingOutputsForUpdate — row locks + Plan->>Plan: re-verify id, satoshis, basket, txid, vout + alt plan still valid + Plan->>DB: markChangeInputsSpent + else outputs moved under us + Plan->>Plan: replan, up to 3 attempts + end +``` + +Selection order is exact match, then smallest sufficient, then largest insufficient, with +`outputId` breaking ties. Change is shaped by the basket's own policy — target UTXO count +and minimum desired value — and any change slice below the dust floor is donated to the +fee rather than created. The dust floor is twice the fee of spending a minimal P2PKH +input, on the principle that an output not worth its own spend cost should never exist. + +Planning happens before the write transaction opens and the claim happens inside it, so a +concurrent `createAction` that takes the same outputs first causes a re-plan rather than a +lock wait. Three failed claims in a row raise `WERR_INVALID_OPERATION` telling the caller +to retry. + +## Importing and cancelling + +### internalizeAction + +```mermaid +sequenceDiagram + autonumber + participant App + participant Wallet + participant Signer + participant Storage + participant DB as Database + participant Net as Services + + App->>Wallet: internalizeAction tx, outputs, description + Wallet->>Signer: internalizeAction + Signer->>Net: verify AtomicBEEF against chaintracker + Signer->>Signer: verify BRC-29 payment scripts + Signer->>Storage: storage.internalizeAction + + Storage->>DB: findTransactions for this txid + Storage->>DB: findOutputs, mark wallet inputs spent + Storage->>DB: findOrInsertProvenTx from BUMP if present + Storage->>DB: findOrInsertOutputBasket per insertion + + alt transaction carries a merkle proof + Storage->>DB: updateTransaction provenTxId, status completed + else no proof yet + Storage->>DB: updateTransaction status unproven + end + + Storage->>DB: insertOutput or updateOutput per internalized output + Storage->>Net: shareReqsWithWorld + Storage-->>App: accepted true +``` + +`internalizeAction` is the only BRC-100 method that adds spendable outputs without +`createAction` having planned them. Two protocols are supported: `wallet payment`, which +uses the BRC-29 derivation prefix and suffix to prove the output belongs to this wallet, +and `basket insertion`, which files an arbitrary output into a named basket with +custom instructions and tags. + +### abortAction + +```mermaid +sequenceDiagram + autonumber + participant App + participant Wallet + participant Storage + participant DB as Database + participant Net as Services + + App->>Wallet: abortAction reference + Wallet->>Storage: storage.abortAction + Storage->>DB: findAbortableTransaction by reference then txid + + alt status is completed, failed, sending or unproven + Storage-->>App: not abortable + else status is nosend and a txid exists + Storage->>Net: getStatusForTxids + alt network reports mined or known + Storage-->>App: aborted false + else unknown, or services unreachable + Storage->>DB: audit note, then invalidate + Storage->>DB: transaction failed, request invalid + Storage->>DB: release inputs, kill own outputs + Storage-->>App: aborted true + end + else any other abortable status + Storage->>DB: transaction failed, request invalid + Storage-->>App: aborted true + end +``` + +The chain-status check exists because a `noSend` transaction can be broadcast by the +application itself. Aborting it after it has propagated would orphan every output it +created, including the wallet's own change. Refusal requires positive confirmation; if the +service is unreachable the abort proceeds and writes an audit note, because BRC-100 +callers must retain the ability to abort offline. + +## Settling — the Monitor + +Everything above returns long before a transaction is final. The Monitor is the lane that +moves `unproven` to `completed`, retries what the network dropped, and repairs state that +drifted. + +```mermaid +sequenceDiagram + autonumber + participant Mon as Monitor + participant Storage + participant DB as Database + participant Net as Services + + loop on each task interval + Mon->>Storage: TaskSendWaiting + Storage->>Net: broadcast unsent and stale sending requests + + Mon->>Storage: TaskCheckForProofs + Storage->>Net: fetch merkle paths for unmined requests + Net-->>Storage: merkle path + Storage->>DB: insert ProvenTx, request completed, transaction completed + + Mon->>Storage: TaskReviewStatus + Storage->>DB: reconcile transactions against their requests + + Mon->>Storage: TaskReviewDoubleSpends + Net-->>Storage: is the competing txid actually known + Storage->>DB: false positive to unfail, true positive stays terminal + + Mon->>Storage: TaskFailAbandoned + Storage->>DB: aged non-terminal transactions to failed + + Mon->>Storage: TaskReviewUtxos + Storage->>DB: repair change outputs that drifted + end +``` + +Nineteen tasks ship in `src/monitor/tasks/`. Beyond those above, `TaskReorg` and +`TaskNewHeader` handle chain reorganisation, `TaskCheckNoSends` settles `nosend` +transactions, `TaskUnFail` retries operator-flagged failures, `TaskArcSSE` consumes +broadcaster push events, `TaskPurge` and `TaskCleanupActionBatches` reclaim storage, and +`TaskSyncWhenIdle` replicates to backup stores. + +## The Go implementation + +Go follows the same overall shape — wallet, signer, manager, provider, database — but +differs in three mechanisms that materially change UTXO behavior. + +```mermaid +sequenceDiagram + autonumber + participant App + participant Wallet + participant Act as Actions + participant Storage + participant Fund as Funder + participant DB as Repo layer + + App->>Wallet: CreateAction + Wallet->>Act: actions.CreateAction + Act->>Act: arm release compensation + Act->>Storage: Storage.CreateAction + Storage->>DB: CountUTXOs — outside the transaction + + Note over Storage,DB: UnitOfWork transaction, retried on contention + Storage->>DB: FindBasketByName + Storage->>Fund: FundWithConstraints + Fund->>DB: FindSmallestSufficientUTXOForUpdate + Fund->>DB: FindLargestInsufficientUTXOsForUpdate + Note over Fund,DB: FOR UPDATE SKIP LOCKED on Postgres and MySQL + Fund-->>Storage: allocated UTXOs, change count, fee + Storage->>DB: reserveUTXOs — CAS on reserved_by_id IS NULL + Storage->>DB: markReservedOutputsAsNotSpendable — guarded on spent_by IS NULL + Storage->>DB: CreateTransactionInTx status unsigned + + alt reservation lost the race + DB-->>Storage: ErrUTXOContention + Storage->>Storage: jittered retry, up to 3 attempts + end + + Storage-->>Act: reference + Act->>Act: assemble and sign + Act->>Act: disarm — point of no return + Act->>Storage: ProcessAction + Storage-->>App: txid and tx +``` + +**Reservation is two-layered.** A dedicated `bsv_user_utxos` index carries +`reserved_by_id`, and `bsv_outputs` carries `spendable` and `spent_by`. `reserveUTXOs` +performs a compare-and-set on `reserved_by_id IS NULL` and treats a row-count mismatch as +`ErrUTXOContention`; `markReservedOutputsAsNotSpendable` separately guards on +`spent_by IS NULL` and raises a stale-index or provided-input conflict. Both run inside +one database transaction but fail for different reasons, and only the first is retried. + +**Compensation is explicit.** `CreateAction` arms a release before touching storage and +disarms it once signing succeeds. If anything fails in between, the release calls +`AbortAction` on a detached context with a ten-second timeout. TypeScript has no +equivalent lane; it relies on the database transaction and the `failed` status cascade. + +**Change becomes claimable at two different moments.** On the delayed path, change is +promoted at queue time. On the immediate path it is promoted only when the network accepts +the transaction. TypeScript sets `spendable: true` at commit and gates visibility through +the parent-status join instead. + +Four scheduled Monitor tasks are registered in Go — check for proofs, send waiting, fail +abandoned, and unfail — alongside event-driven consumers for broadcast status, reorgs, and +new tips. See [difference 11](#11-background-convergence-uses-different-mechanisms). + +## BRC-100 method inventory + +Every method in the interface, in wire-call order, with where it lands. + +### Actions and outputs + +| # | Method | Storage reached | Writes | +| --- | ------------------- | ------------------- | ------------------------------------------------------------ | +| 1 | `createAction` | yes | transaction, outputs, baskets, tags, labels, commission | +| 2 | `signAction` | via `processAction` | request, outputs, transaction status | +| 3 | `abortAction` | yes | transaction `failed`, request `invalid`, output spendability | +| 4 | `listActions` | read only | — | +| 5 | `internalizeAction` | yes | transaction, outputs, baskets, proven transaction | +| 6 | `listOutputs` | read only | — | +| 7 | `relinquishOutput` | yes | output `basketId` cleared | + +### Keys and cryptography + +Methods 8 through 16 — `getPublicKey`, `revealCounterpartyKeyLinkage`, +`revealSpecificKeyLinkage`, `encrypt`, `decrypt`, `createHmac`, `verifyHmac`, +`createSignature`, `verifySignature` — reach the key deriver and never touch storage. + +```mermaid +sequenceDiagram + autonumber + participant App + participant Wallet + participant Perm as Permissions + participant Key as KeyDeriver + + App->>Wallet: protocolID, keyID, counterparty + Wallet->>Perm: seekPermission unless disabled + Perm-->>Wallet: granted + Wallet->>Key: derive child key + Key-->>Wallet: key material + Wallet-->>App: result +``` + +`privileged: true` routes derivation through the privileged key manager, which may prompt +the user with `privilegedReason`. + +### Certificates and identity + +Methods 17 through 22 — `acquireCertificate`, `listCertificates`, `proveCertificate`, +`relinquishCertificate`, `discoverByIdentityKey`, `discoverByAttributes`. + +```mermaid +sequenceDiagram + autonumber + participant App + participant Wallet + participant Storage + participant DB as Database + participant Net as Certifier or Overlay + + alt acquireCertificate issuance + App->>Wallet: type, certifier, fields + Wallet->>Net: request issuance + Net-->>Wallet: signed certificate + Wallet->>Storage: insertCertificate + Storage->>DB: certificate and fields + else listCertificates or proveCertificate + App->>Wallet: certifiers and types + Wallet->>Storage: listCertificates + Storage->>DB: read certificates and keyring + else discoverBy... + App->>Wallet: identity key or attributes + Wallet->>Net: overlay lookup + Net-->>Wallet: identity certificates + end + + Wallet-->>App: result +``` + +`relinquishCertificate` deletes the certificate row. The discovery methods query overlay +services and do not persist. + +### Wallet state + +Methods 23 through 28 — `isAuthenticated`, `waitForAuthentication`, `getHeight`, +`getHeaderForHeight`, `getNetwork`, `getVersion`. The first two are answered by the +authentication manager; `getHeight` and `getHeaderForHeight` query chain services; +`getNetwork` and `getVersion` are constants. None touch storage. + +### Methods beyond the specification + +Both implementations expose helpers outside BRC-100. They are useful, and they are not +portable — an application that calls them is no longer wallet-agnostic. + +| TypeScript | Go | +| ------------------------------------------------------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------- | +| `sweepTo`, `balanceAndUtxos`, `balance`, `reviewSpendableOutputs`, `setWalletChangeParams`, `listNoSendActions`, `listFailedActions` | `FanOutFuel`, `ListFailedActions`, `ListTransactions`, `Balance`, `GetBeefParty` | + +## Implementation differences + +Each entry states what BRC-100 requires, then how each implementation behaves. Where the +specification is silent, that is said rather than assumed. + +### 1. Failed transactions treat spent inputs oppositely + +BRC-100 does not specify what happens to inputs of a transaction that fails to broadcast. + +TypeScript releases them. `updateTransactionStatus('failed')` restores every consumed +output to `{spendable: true, spentBy: undefined}`, and then `recordStaleInputEvidence` +selectively re-marks only those inputs the chain positively confirms are gone. + +Go never releases them. The broadcast handler marks created outputs unspendable and +leaves inputs spent, with the reasoning recorded in the code: a missing-inputs or +double-spend verdict can be a false positive, and re-spending an input that is still valid +risks a real double spend. Only `AbortAction` and the abandoned-transaction sweep restore +inputs. + +Both are defensible. They produce different balances after the same failure, which is why +this is the most consequential difference on the list. + +### 2. Change becomes spendable at different times + +BRC-100 does not specify when change from an in-flight transaction becomes visible to +`listOutputs`. + +TypeScript marks change `spendable: true` at commit and gates visibility on the parent +transaction's status. Go promotes change at queue time on the delayed path and at network +acceptance on the immediate path. The same wallet state can therefore yield different +`listOutputs` results across implementations while a transaction is in flight. + +### 3. `getNetwork` returns non-specification values in Go + +BRC-100 requires `'mainnet'` or `'testnet'`. The Go SDK returns `main` and `test`, the +values stored internally and in the database. The Go conformance suite documents this and +configures its vectors around it. TypeScript returns the specified values. + +### 4. Protocol name minimum length is unspecified and diverges + +BRC-100 does not state a minimum length for the protocol string in a `WalletProtocol` +tuple. The Go SDK enforces five characters; TypeScript does not. Fifty-one `getPublicKey` +conformance vectors using the three-character name `app` are skipped on the Go side. This +is a specification gap before it is an implementation gap. + +### 5. Remote storage client returns empty results instead of failing + +TypeScript's `StorageClientBase` implements the full storage surface over HTTP. + +Go's V1 client leaves four methods unimplemented, and two of them fail silently: +`findOutputBasketsAuth` and `findOutputsAuth` return empty collections with a nil error, +which a caller cannot distinguish from a genuine empty result. `SetActive` and +`ProcessSyncChunk` at least return errors. + +### 6. Signable-transaction references do not survive the session in TypeScript + +BRC-100 does not bound the lifetime of a `signableTransaction` reference. + +TypeScript holds pending sign actions in process memory and throws +`WERR_NOT_IMPLEMENTED` for any reference it does not recognise, so a reference cannot +cross a process boundary or a restart. Go supports a pluggable pending-sign-actions +repository and can persist them. Go is ahead here. + +### 7. Status vocabularies differ + +The proof-request terminal failure state is `invalid` in TypeScript and `invalidTx` in +Go. Go additionally defines `reorg`, which TypeScript handles through a Monitor task +rather than a status value. + +Go also defines a tenth transaction status, `aborted`, distinguishing a retryable abort +from a permanent failure. TypeScript folds both into `failed`. The Go design record for +this status already documents it as Go-only, with TypeScript parity deferred, and +describes it as a known BRC-100 wire-parity ceiling. + +### 8. Abort protection uses different evidence + +BRC-100 says `abortAction` cancels an action before it is finalized, without defining +finalized. + +TypeScript asks the network whether the transaction is already mined or known, and refuses +on positive confirmation. Go requires proof the transaction never reached a broadcaster — +never-posted status, no broadcast flag, zero attempts — and refuses otherwise. TypeScript +proceeds when services are unreachable and writes an audit note; Go's guard is local and +does not depend on network reachability. + +Both prevent the same failure mode. Go's is stricter and cannot be defeated by a network +outage; TypeScript's preserves the ability to abort offline. + +### 9. Action batching exists only in TypeScript + +The TypeScript storage interface carries `getCapabilities` plus seven batch methods and a +whole output-reservation surface, letting a client plan many actions against reserved +outputs and commit them together. The Go storage interface has none of it. This is +additive on the TypeScript side and does not affect BRC-100 conformance. + +### 10. Storage interface surface differs in both directions + +Go adds `ListTransactions` and `GetBalance` to the storage provider interface; TypeScript +has neither there and answers the equivalent questions through `listActions` and +list-outputs special operations. + +### 11. Background convergence uses different mechanisms + +TypeScript ships nineteen registered Monitor tasks; Go registers four. That comparison is +misleading on its own, because Go moves much of the same work off the scheduler: + +- **Event consumers.** `pkg/monitor` runs an SSE broadcast-event pipeline with a persisted + replay cursor (`arcade_sse_last_event_id`) plus reorg and new-tip consumers. Reorg + handling is real — `Provider.HandleReorg` invalidates merkle proofs for orphaned blocks — + it is simply event-driven rather than polled. +- **Inline verification.** `confirmDoubleSpends` re-verifies every aggregated double-spend + verdict before it becomes terminal, downgrading false positives to `serviceError` for + retry. TypeScript does the equivalent in a scheduled `TaskReviewDoubleSpends`. + +What TypeScript has and Go does not reproduce is the `reviewStatus` cascade, which +reconciles transaction rows against their proof requests. Statuses that only that cascade +advances will not advance in Go. Purge and action-batch cleanup also have no Go +counterpart, the latter because Go has no action batching at all. + +### 12. Conformance vectors are vendored and stale in Go + +`go-wallet-toolbox` vendors ten of the twenty-seven BRC-100 vector files, pinned to a +ts-stack commit fetched in May 2026. Seventeen method vector files are not exercised +against the Go implementation at all. + +### 13. Known open gaps tracked on the Go side + +Recorded in `go-wallet-toolbox/plans/` and reproduced here so the matrix is complete: +`internalizeAction` broadcasts in band in TypeScript but only queues in Go; +`WERR_REVIEW_ACTIONS` does not carry `txid`, `tx`, `sendWithResults`, +`reviewActionResults`, or `noSendChange` in Go, and `signAction` `noSendChange` remains +incomplete; `listOutputs` lacks `includeLabels`; `knownTxids` handling, BRC-114 time +labels in `listActions`, the `inputBEEF` JSON array wire format, and the certificate +type and serial wire format all have open parity work. + +### 14. Documentation drift + +`go-wallet-toolbox/docs/wallet.md` stated that the certificate APIs were placeholders. +They are implemented — acquisition by both issuance and direct receipt, listing, proving, +relinquishing, and both discovery methods. That note is corrected in the companion page. + +## Related + +- [BRC-100 Wallet Interface](../specs/brc-100-wallet.md) — method reference +- [BRC-100 architecture](./brc-100.md) — why the boundary exists +- [Storage adapter](../specs/storage-adapter.md) — the storage layer contract +- [BRC-29 peer payment](../specs/brc-29-peer-payment.md) — the derivation scheme every managed change output uses +- [@bsv/wallet-toolbox](../packages/wallet/wallet-toolbox.md) +- [Conformance](../conformance/index.md) diff --git a/docs/specs/brc-100-wallet.md b/docs/specs/brc-100-wallet.md index 0185f8bd9..360374cdb 100644 --- a/docs/specs/brc-100-wallet.md +++ b/docs/specs/brc-100-wallet.md @@ -19,6 +19,9 @@ The authoritative sources for this page are: - `specs/sdk/brc-100-wallet.json` (wire-format schema) - [BRC-100 in the BRC repository](https://github.com/bitcoin-sv/BRCs/blob/master/wallet/0100.md) +For how these methods behave once called — storage interactions, status transitions, and +implementation differences — see [Wallet UTXO Lifecycle](../architecture/wallet-utxo-lifecycle.md). + This MD is a human-readable summary. Implement against the TS interfaces and JSON schema. The TypeScript interface passes `originator?: OriginatorDomainNameStringUnder250Bytes` as an optional second method parameter (wire formats carry it in the request envelope). See `ReviewActionResult` for delayed-broadcast responses. ## Data Conventions