diff --git a/docs/packages/wallet/wallet-toolbox-client.md b/docs/packages/wallet/wallet-toolbox-client.md index ee746bc6a..3ce9b4887 100644 --- a/docs/packages/wallet/wallet-toolbox-client.md +++ b/docs/packages/wallet/wallet-toolbox-client.md @@ -4,8 +4,8 @@ title: '@bsv/wallet-toolbox-client' kind: package domain: wallet version: '2.5.0' -last_updated: '2026-07-31' -last_verified: '2026-07-31' +last_updated: '2026-08-04' +last_verified: '2026-08-04' review_cadence_days: 30 npm: 'https://www.npmjs.com/package/@bsv/wallet-toolbox-client' repo: 'https://github.com/bsv-blockchain/ts-stack/tree/main/packages/wallet/wallet-toolbox/client' @@ -20,6 +20,9 @@ It includes the BRC-100 wallet, signer, services, IndexedDB storage, and remote storage client without Node-only Knex, SQLite, MySQL, or filesystem adapters. Opt-in remote-storage timing spans retain trace and parent-span correlation in the telemetry sink without adding headers to authenticated requests. +Browser authentication accepts one verified matching UMP token as an existing +account. When no token verifies, one clean empty overlay response establishes a +new account even if other hosts fail or return malformed records. ## Install diff --git a/docs/packages/wallet/wallet-toolbox-mobile.md b/docs/packages/wallet/wallet-toolbox-mobile.md index 2be3ba923..24fde933a 100644 --- a/docs/packages/wallet/wallet-toolbox-mobile.md +++ b/docs/packages/wallet/wallet-toolbox-mobile.md @@ -4,8 +4,8 @@ title: '@bsv/wallet-toolbox-mobile' kind: package domain: wallet version: '2.5.0' -last_updated: '2026-07-31' -last_verified: '2026-07-31' +last_updated: '2026-08-04' +last_verified: '2026-08-04' review_cadence_days: 30 npm: 'https://www.npmjs.com/package/@bsv/wallet-toolbox-mobile' repo: 'https://github.com/bsv-blockchain/ts-stack/tree/main/packages/wallet/wallet-toolbox/mobile' @@ -20,6 +20,9 @@ Toolbox distribution. It includes wallet, signer, services, monitoring, and remote storage surfaces without Knex, SQLite/MySQL, IndexedDB, or Node-only IO. Opt-in remote-storage timing spans retain trace and parent-span correlation in the telemetry sink without adding headers to authenticated requests. +Mobile authentication accepts one verified matching UMP token as an existing +account. When no token verifies, one clean empty overlay response establishes a +new account even if other hosts fail or return malformed records. ## Install diff --git a/docs/packages/wallet/wallet-toolbox.md b/docs/packages/wallet/wallet-toolbox.md index 8243ae77e..b3fae2f50 100644 --- a/docs/packages/wallet/wallet-toolbox.md +++ b/docs/packages/wallet/wallet-toolbox.md @@ -5,8 +5,8 @@ kind: package domain: wallet npm: '@bsv/wallet-toolbox' version: '2.5.0' -last_updated: '2026-07-31' -last_verified: '2026-07-31' +last_updated: '2026-08-04' +last_verified: '2026-08-04' review_cadence_days: 30 status: stable tags: ['wallet', 'brc100'] @@ -23,6 +23,12 @@ Opt-in remote-storage timing spans retain trace and parent-span correlation in the telemetry sink without adding headers to authenticated requests. BRC-103, BRC-104, AuthFetch, and the storage RPC wire contract remain unchanged. +UMP account lookup accepts one verified matching token as an existing account. +When no token verifies, one clean empty overlay response establishes a new +account even if other hosts fail or return malformed records. Multiple distinct +verified tokens and lookups with no usable response remain errors; WAB +existing-account continuity still prevents replacement-wallet onboarding. + ## Install ```bash diff --git a/docs/reference/package-api-migrations.md b/docs/reference/package-api-migrations.md index 28df7e268..0e0259b60 100644 --- a/docs/reference/package-api-migrations.md +++ b/docs/reference/package-api-migrations.md @@ -3,8 +3,8 @@ id: package-api-migrations title: 'Package API, Declarations, and Migration Ledger' kind: reference version: '1.0.0' -last_updated: '2026-08-02' -last_verified: '2026-08-02' +last_updated: '2026-08-03' +last_verified: '2026-08-03' review_cadence_days: 30 status: stable tags: [reference, packages, api, declarations, migrations, release-notes] @@ -23,39 +23,39 @@ and clean-consumer tests remain the executable type authority. ## Current release boundary -| Package | npm baseline | Source | Candidate | API | Migration | -| --------------------------------- | ------------ | ------- | --------- | --------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `@bsv/402-pay` | `0.2.1` | `0.2.4` | patch | [API and usage](../packages/middleware/402-pay.md) | No consumer migration is required; client and server exports, payment protocol behavior, and runtime defaults are unchanged. | -| `@bsv/air-gap` | `0.0.0` | `0.1.1` | minor | [API and usage](../packages/helpers/air-gap.md) | No consumer migration is required; this is the first published release of a new package with no prior public API. The experimental pre-release framing that circulated on the unmerged feature branch is not accepted by the v1 decoder. | -| `@bsv/amountinator` | `2.1.1` | `2.1.4` | patch | [API and usage](../packages/helpers/amountinator.md) | No consumer migration is required; this is a backward-compatible patch candidate. | -| `@bsv/auth` | `0.1.1` | `0.1.3` | patch | [API and usage](../packages/middleware/auth.md) | No consumer migration is required; authentication APIs, wire behavior, and runtime defaults are unchanged. | -| `@bsv/auth-express-middleware` | `2.1.2` | `2.1.7` | patch | [API and usage](../packages/middleware/auth-express-middleware.md) | No consumer migration is required; existing public CORS defaults and middleware APIs are retained, authentication callback failures now produce a controlled HTTP error, and Express 4 and 5 applications use their own peer-provided Express installation. | -| `@bsv/authsocket` | `2.1.1` | `2.1.5` | patch | [API and usage](../packages/messaging/authsocket.md) | Valid traffic and the wire contract are unchanged. A socket is now disconnected when its authentication processing exceeds the concurrency limit or a callback fails; use onError for diagnostics and maxPendingAuthMessages to tune the default limit of 32. | -| `@bsv/authsocket-client` | `2.1.1` | `2.1.4` | patch | [API and usage](../packages/messaging/authsocket-client.md) | Valid traffic and supported imports are unchanged. The client now disconnects from a server that causes authentication failure or exceeds the concurrency limit; use onError for diagnostics and maxPendingAuthMessages to tune the default limit of 32. | -| `@bsv/btms` | `1.1.1` | `1.1.4` | patch | [API and usage](../packages/wallet/btms.md) | No consumer migration is required; token and lookup wire contracts are unchanged. | -| `@bsv/btms-permission-module` | `1.1.1` | `1.1.3` | patch | [API and usage](../packages/wallet/btms-permission-module.md) | No consumer migration is required; permission-module APIs and token semantics are unchanged. | -| `@bsv/did` | `0.2.1` | `0.2.4` | patch | [API and usage](../packages/helpers/did.md) | No consumer migration is required; DID APIs, encodings, credential behavior, and supported import forms are unchanged. | -| `@bsv/did-client` | `1.2.1` | `1.2.3` | patch | [API and usage](../packages/helpers/did-client.md) | No consumer migration is required; DID client APIs, encodings, and supported imports are unchanged. | -| `@bsv/fund-wallet` | `1.4.1` | `1.4.3` | patch | [API and usage](../packages/helpers/fund-wallet.md) | No consumer migration is required; wallet funding APIs and transaction behavior are unchanged. | -| `@bsv/gasp` | `1.3.1` | `1.3.5` | patch | [API and usage](../packages/overlays/gasp.md) | No consumer migration is required; existing constructor calls, imports, synchronization behavior, and wire semantics are unchanged. | -| `@bsv/message-box-client` | `2.2.2` | `2.2.6` | patch | [API and usage](../packages/messaging/message-box-client.md) | No consumer migration is required; Message Box protocol and client entry points are unchanged. | -| `@bsv/overlay` | `2.2.1` | `2.2.7` | patch | [API and usage](../packages/overlays/overlay.md) | No consumer migration is required; existing imports, submission results, notification contracts, storage order, and network behavior remain unchanged. | -| `@bsv/overlay-discovery-services` | `2.1.1` | `2.1.6` | patch | [API and usage](../packages/overlays/overlay-discovery-services.md) | No consumer migration is required; discovery records and public network behavior are unchanged. | -| `@bsv/overlay-express` | `2.4.2` | `2.4.9` | patch | [API and usage](../packages/overlays/overlay-express.md) | No consumer migration is required; wildcard credential-free public access remains the default and runtimes may opt into the new close method. | -| `@bsv/overlay-topics` | `1.6.1` | `1.6.8` | patch | [API and usage](../packages/overlays/overlay-topics.md) | No consumer migration is required; topic IDs, lookup contracts, and persisted formats are unchanged. | -| `@bsv/paymail` | `2.4.2` | `2.4.6` | patch | [API and usage](../packages/messaging/paymail.md) | Existing Paymail client APIs and protocol semantics are retained. Consumers provide one Express 4.18 or 5 runtime and matching type graph; browser bundles continue to exclude the server router implementation. | -| `@bsv/payment-express-middleware` | `2.1.1` | `2.1.5` | patch | [API and usage](../packages/middleware/payment-express-middleware.md) | No consumer migration is required; legacy x-bsv-payment JSON behavior remains supported, and Express 4 and 5 applications use their own peer-provided Express installation. | -| `@bsv/sdk` | `2.2.18` | `2.3.0` | minor | [API and usage](../packages/sdk/bsv-sdk.md) | No consumer migration is required. The new APIs are additive; BEEF ordering and bytes, proof validity, signatures, synchronous signing, SDK 2.x wire encodings, BRC-103/104 behavior, and supported imports are unchanged. | -| `@bsv/simple` | `0.4.1` | `0.4.8` | patch | [API and usage](../packages/helpers/simple.md) | No consumer migration is required; the browser and server entry points remain compatible. | -| `@bsv/templates` | `1.9.1` | `1.9.6` | patch | [API and usage](../packages/helpers/templates.md) | No consumer migration is required; template APIs, supported imports, and generated script semantics are unchanged. | -| `@bsv/teranode-listener` | `1.1.1` | `1.1.4` | patch | [API and usage](../packages/network/teranode-listener.md) | No consumer migration is required; listener APIs, topics, and network configuration are unchanged. | -| `@bsv/verifast` | `0.3.0` | `0.3.4` | patch | [API and usage](../packages/sdk/verifast.md) | No consumer migration is required; exports, verification behavior, worker protocols, package paths, and runtime defaults are unchanged. | -| `@bsv/wallet-helper` | `0.1.1` | `0.1.6` | patch | [API and usage](../packages/helpers/wallet-helper.md) | No consumer migration is required; fluent builder APIs and transaction semantics are unchanged. | -| `@bsv/wallet-relay` | `0.2.2` | `0.3.4` | minor | [API and usage](../packages/wallet/wallet-relay.md) | QRPairingCode now renders a native button and accepts button wrapper attributes. Existing className, style, data, and ARIA props continue to work; update div-specific wrapper selectors or explicitly typed div event handlers. Express integrations now use the host application's matching Express runtime and type graph. | -| `@bsv/wallet-toolbox` | `2.4.22` | `2.5.0` | minor | [API and usage](../packages/wallet/wallet-toolbox.md) | No consumer migration is required. Storage-provider additions are backward-compatible with fallbacks, existing databases use the normal migration path, and wallet results, BRC-103/104, AuthFetch, Auth Express Middleware, AuthSocket, JSON-RPC, provider calls, and wallet wire behavior are unchanged. | -| `@bsv/wallet-toolbox-client` | `2.4.22` | `2.5.0` | minor | [API and usage](../packages/wallet/wallet-toolbox-client.md) | No consumer migration is required; older compatible SDK peers retain the validated sequential proof fallback, IndexedDB upgrades automatically, and BRC-103/104, AuthFetch, browser entry points, JSON-RPC, and remote storage contracts remain unchanged. | -| `@bsv/wallet-toolbox-mobile` | `2.4.22` | `2.5.0` | minor | [API and usage](../packages/wallet/wallet-toolbox-mobile.md) | No consumer migration is required; older compatible SDK peers retain the validated sequential proof fallback, and BRC-103/104, AuthFetch, React Native, the mobile bridge, JSON-RPC, and remote storage contracts remain unchanged. | -| `create-bsv-app` | `1.0.2` | `1.0.4` | patch | [API and usage](../packages/helpers/create-bsv-app.md) | No consumer migration is required; generated application structure and CLI behavior are unchanged. | +| Package | npm baseline | Source | Candidate | API | Migration | +| --------------------------------- | ------------ | ------- | --------- | --------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `@bsv/402-pay` | `0.2.1` | `0.2.4` | patch | [API and usage](../packages/middleware/402-pay.md) | No consumer migration is required; client and server exports, payment protocol behavior, and runtime defaults are unchanged. | +| `@bsv/air-gap` | `0.0.0` | `0.1.1` | minor | [API and usage](../packages/helpers/air-gap.md) | No consumer migration is required; this is the first published release of a new package with no prior public API. The experimental pre-release framing that circulated on the unmerged feature branch is not accepted by the v1 decoder. | +| `@bsv/amountinator` | `2.1.1` | `2.1.4` | patch | [API and usage](../packages/helpers/amountinator.md) | No consumer migration is required; this is a backward-compatible patch candidate. | +| `@bsv/auth` | `0.1.1` | `0.1.3` | patch | [API and usage](../packages/middleware/auth.md) | No consumer migration is required; authentication APIs, wire behavior, and runtime defaults are unchanged. | +| `@bsv/auth-express-middleware` | `2.1.2` | `2.1.7` | patch | [API and usage](../packages/middleware/auth-express-middleware.md) | No consumer migration is required; existing public CORS defaults and middleware APIs are retained, authentication callback failures now produce a controlled HTTP error, and Express 4 and 5 applications use their own peer-provided Express installation. | +| `@bsv/authsocket` | `2.1.1` | `2.1.5` | patch | [API and usage](../packages/messaging/authsocket.md) | Valid traffic and the wire contract are unchanged. A socket is now disconnected when its authentication processing exceeds the concurrency limit or a callback fails; use onError for diagnostics and maxPendingAuthMessages to tune the default limit of 32. | +| `@bsv/authsocket-client` | `2.1.1` | `2.1.4` | patch | [API and usage](../packages/messaging/authsocket-client.md) | Valid traffic and supported imports are unchanged. The client now disconnects from a server that causes authentication failure or exceeds the concurrency limit; use onError for diagnostics and maxPendingAuthMessages to tune the default limit of 32. | +| `@bsv/btms` | `1.1.1` | `1.1.4` | patch | [API and usage](../packages/wallet/btms.md) | No consumer migration is required; token and lookup wire contracts are unchanged. | +| `@bsv/btms-permission-module` | `1.1.1` | `1.1.3` | patch | [API and usage](../packages/wallet/btms-permission-module.md) | No consumer migration is required; permission-module APIs and token semantics are unchanged. | +| `@bsv/did` | `0.2.1` | `0.2.4` | patch | [API and usage](../packages/helpers/did.md) | No consumer migration is required; DID APIs, encodings, credential behavior, and supported import forms are unchanged. | +| `@bsv/did-client` | `1.2.1` | `1.2.3` | patch | [API and usage](../packages/helpers/did-client.md) | No consumer migration is required; DID client APIs, encodings, and supported imports are unchanged. | +| `@bsv/fund-wallet` | `1.4.1` | `1.4.3` | patch | [API and usage](../packages/helpers/fund-wallet.md) | No consumer migration is required; wallet funding APIs and transaction behavior are unchanged. | +| `@bsv/gasp` | `1.3.1` | `1.3.5` | patch | [API and usage](../packages/overlays/gasp.md) | No consumer migration is required; existing constructor calls, imports, synchronization behavior, and wire semantics are unchanged. | +| `@bsv/message-box-client` | `2.2.2` | `2.2.6` | patch | [API and usage](../packages/messaging/message-box-client.md) | No consumer migration is required; Message Box protocol and client entry points are unchanged. | +| `@bsv/overlay` | `2.2.1` | `2.2.7` | patch | [API and usage](../packages/overlays/overlay.md) | No consumer migration is required; existing imports, submission results, notification contracts, storage order, and network behavior remain unchanged. | +| `@bsv/overlay-discovery-services` | `2.1.1` | `2.1.6` | patch | [API and usage](../packages/overlays/overlay-discovery-services.md) | No consumer migration is required; discovery records and public network behavior are unchanged. | +| `@bsv/overlay-express` | `2.4.2` | `2.4.9` | patch | [API and usage](../packages/overlays/overlay-express.md) | No consumer migration is required; wildcard credential-free public access remains the default and runtimes may opt into the new close method. | +| `@bsv/overlay-topics` | `1.6.1` | `1.6.8` | patch | [API and usage](../packages/overlays/overlay-topics.md) | No consumer migration is required; topic IDs, lookup contracts, and persisted formats are unchanged. | +| `@bsv/paymail` | `2.4.2` | `2.4.6` | patch | [API and usage](../packages/messaging/paymail.md) | Existing Paymail client APIs and protocol semantics are retained. Consumers provide one Express 4.18 or 5 runtime and matching type graph; browser bundles continue to exclude the server router implementation. | +| `@bsv/payment-express-middleware` | `2.1.1` | `2.1.5` | patch | [API and usage](../packages/middleware/payment-express-middleware.md) | No consumer migration is required; legacy x-bsv-payment JSON behavior remains supported, and Express 4 and 5 applications use their own peer-provided Express installation. | +| `@bsv/sdk` | `2.2.18` | `2.3.0` | minor | [API and usage](../packages/sdk/bsv-sdk.md) | No consumer migration is required. The new APIs are additive; BEEF ordering and bytes, proof validity, signatures, synchronous signing, SDK 2.x wire encodings, BRC-103/104 behavior, and supported imports are unchanged. | +| `@bsv/simple` | `0.4.1` | `0.4.8` | patch | [API and usage](../packages/helpers/simple.md) | No consumer migration is required; the browser and server entry points remain compatible. | +| `@bsv/templates` | `1.9.1` | `1.9.6` | patch | [API and usage](../packages/helpers/templates.md) | No consumer migration is required; template APIs, supported imports, and generated script semantics are unchanged. | +| `@bsv/teranode-listener` | `1.1.1` | `1.1.4` | patch | [API and usage](../packages/network/teranode-listener.md) | No consumer migration is required; listener APIs, topics, and network configuration are unchanged. | +| `@bsv/verifast` | `0.3.0` | `0.3.4` | patch | [API and usage](../packages/sdk/verifast.md) | No consumer migration is required; exports, verification behavior, worker protocols, package paths, and runtime defaults are unchanged. | +| `@bsv/wallet-helper` | `0.1.1` | `0.1.6` | patch | [API and usage](../packages/helpers/wallet-helper.md) | No consumer migration is required; fluent builder APIs and transaction semantics are unchanged. | +| `@bsv/wallet-relay` | `0.2.2` | `0.3.4` | minor | [API and usage](../packages/wallet/wallet-relay.md) | QRPairingCode now renders a native button and accepts button wrapper attributes. Existing className, style, data, and ARIA props continue to work; update div-specific wrapper selectors or explicitly typed div event handlers. Express integrations now use the host application's matching Express runtime and type graph. | +| `@bsv/wallet-toolbox` | `2.4.22` | `2.5.0` | minor | [API and usage](../packages/wallet/wallet-toolbox.md) | No API or persistence migration is required. Storage-provider additions are backward-compatible with fallbacks and existing databases use the normal migration path. Applications may now enter new-user flow when at least one overlay host returns a clean empty result despite malformed or unavailable peers; one verified token still establishes an existing account, multiple unresolved verified tokens remain an error, and WAB existing-account continuity still blocks replacement-wallet onboarding. Wallet results, BRC-103/104, AuthFetch, Auth Express Middleware, AuthSocket, JSON-RPC, provider calls, and wallet wire behavior are otherwise unchanged. | +| `@bsv/wallet-toolbox-client` | `2.4.22` | `2.5.0` | minor | [API and usage](../packages/wallet/wallet-toolbox-client.md) | No API or persistence migration is required. Browser wallets receive the resilient partial-host UMP lookup behavior described for @bsv/wallet-toolbox; older compatible SDK peers retain the validated sequential proof fallback, IndexedDB upgrades automatically, and BRC-103/104, AuthFetch, browser entry points, JSON-RPC, and remote storage contracts remain unchanged. | +| `@bsv/wallet-toolbox-mobile` | `2.4.22` | `2.5.0` | minor | [API and usage](../packages/wallet/wallet-toolbox-mobile.md) | No API or persistence migration is required. Mobile wallets receive the resilient partial-host UMP lookup behavior described for @bsv/wallet-toolbox; older compatible SDK peers retain the validated sequential proof fallback, and BRC-103/104, AuthFetch, React Native, the mobile bridge, JSON-RPC, and remote storage contracts remain unchanged. | +| `create-bsv-app` | `1.0.2` | `1.0.4` | patch | [API and usage](../packages/helpers/create-bsv-app.md) | No consumer migration is required; generated application structure and CLI behavior are unchanged. | `none` means the source manifest matches the recorded npm baseline. Any other value is an unpublished candidate. Publication, tags, releases, registry @@ -477,8 +477,8 @@ CLI entry points: `{"wallet-relay":"./bin/init.mjs"}`. - Package documentation: [docs/packages/wallet/wallet-toolbox.md](../packages/wallet/wallet-toolbox.md) - Source: [packages/wallet/wallet-toolbox](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/wallet/wallet-toolbox) -- Release note: Makes the successful fragmented createAction path atomic and set-based, overlaps batched proof reads with persistence, batch-validates compound proofs and canonical P2PKH signatures, shares BRC-42 derivation work, removes unused commit reads, bulk-inserts outputs, coalesces authenticated timestamp-only Knex session touches, and adds timings for every remaining material phase. -- Migration: No consumer migration is required. Storage-provider additions are backward-compatible with fallbacks, existing databases use the normal migration path, and wallet results, BRC-103/104, AuthFetch, Auth Express Middleware, AuthSocket, JSON-RPC, provider calls, and wallet wire behavior are unchanged. +- Release note: Makes the successful fragmented createAction path atomic and set-based, overlaps batched proof reads with persistence, batch-validates compound proofs and canonical P2PKH signatures, shares BRC-42 derivation work, removes unused commit reads, bulk-inserts outputs, coalesces authenticated timestamp-only Knex session touches, adds timings for every remaining material phase, and makes UMP account lookup resilient to partial overlay failure. +- Migration: No API or persistence migration is required. Storage-provider additions are backward-compatible with fallbacks and existing databases use the normal migration path. Applications may now enter new-user flow when at least one overlay host returns a clean empty result despite malformed or unavailable peers; one verified token still establishes an existing account, multiple unresolved verified tokens remain an error, and WAB existing-account continuity still blocks replacement-wallet onboarding. Wallet results, BRC-103/104, AuthFetch, Auth Express Middleware, AuthSocket, JSON-RPC, provider calls, and wallet wire behavior are otherwise unchanged. | Public subpath | Runtime target(s) | Declaration target(s) | | ---------------- | ---------------------------------------------------- | -------------------------- | @@ -491,8 +491,8 @@ CLI entry points: `{"wallet-relay":"./bin/init.mjs"}`. - Package documentation: [docs/packages/wallet/wallet-toolbox-client.md](../packages/wallet/wallet-toolbox-client.md) - Source: [packages/wallet/wallet-toolbox/client](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/wallet/wallet-toolbox/client) -- Release note: Carries the lockstep browser build with batched proof assembly, linear funding/signing work, canonical P2PKH verification, expired-reservation filtering, and complete privacy-safe createAction timings. -- Migration: No consumer migration is required; older compatible SDK peers retain the validated sequential proof fallback, IndexedDB upgrades automatically, and BRC-103/104, AuthFetch, browser entry points, JSON-RPC, and remote storage contracts remain unchanged. +- Release note: Carries the lockstep browser build with batched proof assembly, linear funding/signing work, canonical P2PKH verification, expired-reservation filtering, complete privacy-safe createAction timings, and resilient UMP account lookup. +- Migration: No API or persistence migration is required. Browser wallets receive the resilient partial-host UMP lookup behavior described for @bsv/wallet-toolbox; older compatible SDK peers retain the validated sequential proof fallback, IndexedDB upgrades automatically, and BRC-103/104, AuthFetch, browser entry points, JSON-RPC, and remote storage contracts remain unchanged. | Public subpath | Runtime target(s) | Declaration target(s) | | ---------------- | -------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------- | @@ -503,8 +503,8 @@ CLI entry points: `{"wallet-relay":"./bin/init.mjs"}`. - Package documentation: [docs/packages/wallet/wallet-toolbox-mobile.md](../packages/wallet/wallet-toolbox-mobile.md) - Source: [packages/wallet/wallet-toolbox/mobile](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/wallet/wallet-toolbox/mobile) -- Release note: Carries the lockstep mobile build with batched proof assembly, linear funding/signing work, canonical P2PKH verification, expired-reservation filtering, and complete privacy-safe createAction timings. -- Migration: No consumer migration is required; older compatible SDK peers retain the validated sequential proof fallback, and BRC-103/104, AuthFetch, React Native, the mobile bridge, JSON-RPC, and remote storage contracts remain unchanged. +- Release note: Carries the lockstep mobile build with batched proof assembly, linear funding/signing work, canonical P2PKH verification, expired-reservation filtering, complete privacy-safe createAction timings, and resilient UMP account lookup. +- Migration: No API or persistence migration is required. Mobile wallets receive the resilient partial-host UMP lookup behavior described for @bsv/wallet-toolbox; older compatible SDK peers retain the validated sequential proof fallback, and BRC-103/104, AuthFetch, React Native, the mobile bridge, JSON-RPC, and remote storage contracts remain unchanged. | Public subpath | Runtime target(s) | Declaration target(s) | | ---------------- | -------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------- | diff --git a/governance/package-release-notes.json b/governance/package-release-notes.json index 6d8c36ee6..e1478d437 100644 --- a/governance/package-release-notes.json +++ b/governance/package-release-notes.json @@ -1,6 +1,6 @@ { "schemaVersion": 1, - "lastReviewed": "2026-08-02", + "lastReviewed": "2026-08-03", "owner": "ts-stack-maintainers", "entries": [ { @@ -196,22 +196,22 @@ "name": "@bsv/wallet-toolbox", "publishedVersion": "2.4.22", "releaseType": "minor", - "summary": "Makes the successful fragmented createAction path atomic and set-based, overlaps batched proof reads with persistence, batch-validates compound proofs and canonical P2PKH signatures, shares BRC-42 derivation work, removes unused commit reads, bulk-inserts outputs, coalesces authenticated timestamp-only Knex session touches, and adds timings for every remaining material phase.", - "migration": "No consumer migration is required. Storage-provider additions are backward-compatible with fallbacks, existing databases use the normal migration path, and wallet results, BRC-103/104, AuthFetch, Auth Express Middleware, AuthSocket, JSON-RPC, provider calls, and wallet wire behavior are unchanged." + "summary": "Makes the successful fragmented createAction path atomic and set-based, overlaps batched proof reads with persistence, batch-validates compound proofs and canonical P2PKH signatures, shares BRC-42 derivation work, removes unused commit reads, bulk-inserts outputs, coalesces authenticated timestamp-only Knex session touches, adds timings for every remaining material phase, and makes UMP account lookup resilient to partial overlay failure.", + "migration": "No API or persistence migration is required. Storage-provider additions are backward-compatible with fallbacks and existing databases use the normal migration path. Applications may now enter new-user flow when at least one overlay host returns a clean empty result despite malformed or unavailable peers; one verified token still establishes an existing account, multiple unresolved verified tokens remain an error, and WAB existing-account continuity still blocks replacement-wallet onboarding. Wallet results, BRC-103/104, AuthFetch, Auth Express Middleware, AuthSocket, JSON-RPC, provider calls, and wallet wire behavior are otherwise unchanged." }, { "name": "@bsv/wallet-toolbox-client", "publishedVersion": "2.4.22", "releaseType": "minor", - "summary": "Carries the lockstep browser build with batched proof assembly, linear funding/signing work, canonical P2PKH verification, expired-reservation filtering, and complete privacy-safe createAction timings.", - "migration": "No consumer migration is required; older compatible SDK peers retain the validated sequential proof fallback, IndexedDB upgrades automatically, and BRC-103/104, AuthFetch, browser entry points, JSON-RPC, and remote storage contracts remain unchanged." + "summary": "Carries the lockstep browser build with batched proof assembly, linear funding/signing work, canonical P2PKH verification, expired-reservation filtering, complete privacy-safe createAction timings, and resilient UMP account lookup.", + "migration": "No API or persistence migration is required. Browser wallets receive the resilient partial-host UMP lookup behavior described for @bsv/wallet-toolbox; older compatible SDK peers retain the validated sequential proof fallback, IndexedDB upgrades automatically, and BRC-103/104, AuthFetch, browser entry points, JSON-RPC, and remote storage contracts remain unchanged." }, { "name": "@bsv/wallet-toolbox-mobile", "publishedVersion": "2.4.22", "releaseType": "minor", - "summary": "Carries the lockstep mobile build with batched proof assembly, linear funding/signing work, canonical P2PKH verification, expired-reservation filtering, and complete privacy-safe createAction timings.", - "migration": "No consumer migration is required; older compatible SDK peers retain the validated sequential proof fallback, and BRC-103/104, AuthFetch, React Native, the mobile bridge, JSON-RPC, and remote storage contracts remain unchanged." + "summary": "Carries the lockstep mobile build with batched proof assembly, linear funding/signing work, canonical P2PKH verification, expired-reservation filtering, complete privacy-safe createAction timings, and resilient UMP account lookup.", + "migration": "No API or persistence migration is required. Mobile wallets receive the resilient partial-host UMP lookup behavior described for @bsv/wallet-toolbox; older compatible SDK peers retain the validated sequential proof fallback, and BRC-103/104, AuthFetch, React Native, the mobile bridge, JSON-RPC, and remote storage contracts remain unchanged." }, { "name": "create-bsv-app", diff --git a/packages/wallet/wallet-toolbox/CHANGELOG.md b/packages/wallet/wallet-toolbox/CHANGELOG.md index 163ce8af0..96aa3397f 100644 --- a/packages/wallet/wallet-toolbox/CHANGELOG.md +++ b/packages/wallet/wallet-toolbox/CHANGELOG.md @@ -34,6 +34,21 @@ attention to changes that materially alter behavior or extend functionality. - Make expired action-batch reservations non-blocking in indexed queries and repair MySQL rollback support indexes. Existing databases migrate through the normal Knex path, and full PXC down-to-empty/re-upgrade is regression-tested. +- Make UMP account lookup resilient to stale SLAP advertisements and partial + overlay failure. One verified matching token establishes an existing account; + otherwise one clean empty response establishes a new account. Malformed, + rejected, empty, and unavailable peers cannot veto a verified token, and + malformed or unavailable peers cannot veto a clean empty response. Lookups + with no usable response remain errors; WAB existing-account continuity still + prevents replacement-wallet onboarding. +- Resolve competing verified UMP tokens on on-chain proof. A candidate spent + anywhere in another candidate's BEEF ancestry is superseded (evidence merged + across hosts serving different depths; ancestry walked iteratively so deep + update chains cannot exhaust the stack). Forked candidates resolve only when + exactly one provably consumed a same-identity predecessor token, which + requires the account's keys; anything less decisive stays an error so a + wrong token can never be chosen silently. Resolved conflicts report a + `supersededTokens` count in lookup telemetry. - Plan legacy `createAction` funding against the exact unreserved managed-change set before persistence, claim the selected inputs atomically in one storage transaction, and fail economically impossible fragmented wallets before @@ -225,6 +240,7 @@ attention to changes that materially alter behavior or extend functionality. - audit fix + ## wallet-toolbox 2.1.14 - fix update timestamp on all updated currencies diff --git a/packages/wallet/wallet-toolbox/docs/authentication-observability.md b/packages/wallet/wallet-toolbox/docs/authentication-observability.md index 76b0ca568..5b235b0ba 100644 --- a/packages/wallet/wallet-toolbox/docs/authentication-observability.md +++ b/packages/wallet/wallet-toolbox/docs/authentication-observability.md @@ -58,20 +58,32 @@ not affect authentication or wallet behavior. `authenticationFlow` starts as `unknown`. A password cannot create a wallet until account lookup has completed. -An empty UMP answer means `new-user` only when every selected overlay host -settled successfully and returned an empty output list. Timeout, host failure, -malformed response, semantic rejection, or a malformed UMP token raises -`UMPTokenLookupError`. Multiple distinct valid UMP tokens are also treated as -ambiguous instead of choosing one by response order. Applications should -present retry and account-recovery options for these errors, never a -new-password prompt. +UMP account lookup prefers usable evidence over peer unanimity. Exactly one +verified token matching the requested presentation or recovery hash means +`existing-user`, regardless of empty, malformed, rejected, or unavailable peer +responses. When no token verifies, one host's valid empty output list is enough +to mean `new-user`; malformed and unavailable peers cannot veto that result. +Multiple distinct verified UMP tokens resolve on on-chain proof alone. A token +update spends its predecessor's outpoint, so a candidate spent anywhere in +another candidate's transaction ancestry (walked through each candidate's BEEF, +with evidence merged across hosts serving different depths) is superseded. +Forked candidates with no spend relationship resolve only when exactly one of +them provably consumed a same-identity UMP token — spending a token requires +the account's keys, so this demonstrates continuity of control, whereas an +independently minted competitor is typically the residue of an erroneous +re-onboarding. A resolved conflict reports `supersededTokens` in the completed +event. Anything less decisive — true forks, competing proven continuations, or +candidates without examinable evidence — remains ambiguous, and a lookup with +neither a verified token nor a clean empty response raises +`UMPTokenLookupError`. Applications should present retry and account-recovery +options for these errors, never a new-password prompt. WAB completion uses an explicit `accountStatus` or `existingUser` response when available. For older WAB servers it derives the status from the established protocol: a new account returns the temporary presentation key, while an existing account returns its stored key. If WAB identifies an existing account -but UMP authoritatively appears absent, `WABAccountContinuityError` prevents -new-wallet creation. +but UMP lookup returns no verified token, `WABAccountContinuityError` prevents +new-wallet creation even when an overlay host returned a clean empty result. Temporary WAB authentication state expires after ten minutes by default and is cleared when authentication is cancelled, completed, or destroyed. diff --git a/packages/wallet/wallet-toolbox/src/CWIStyleWalletManager.ts b/packages/wallet/wallet-toolbox/src/CWIStyleWalletManager.ts index 4037d3908..d8f84e929 100644 --- a/packages/wallet/wallet-toolbox/src/CWIStyleWalletManager.ts +++ b/packages/wallet/wallet-toolbox/src/CWIStyleWalletManager.ts @@ -522,7 +522,7 @@ export interface UMPTokenInteractor { * * @param hash The hash of the presentation key. * @returns The UMP token if found; otherwise, undefined. - * @throws Implementations should throw when absence cannot be established authoritatively. + * @throws Implementations should throw when no verified token or clean empty response is available. */ findByPresentationKeyHash: (hash: number[]) => Promise @@ -532,7 +532,7 @@ export interface UMPTokenInteractor { * * @param hash The hash of the recovery key. * @returns The UMP token if found; otherwise, undefined. - * @throws Implementations should throw when absence cannot be established authoritatively. + * @throws Implementations should throw when no verified token or clean empty response is available. */ findByRecoveryKeyHash: (hash: number[]) => Promise @@ -569,7 +569,7 @@ export type UMPTokenLookupFailureReason = 'lookup-unavailable' | 'lookup-incomplete' | 'token-malformed' | 'token-ambiguous' /** - * Raised when UMP absence cannot be established authoritatively. + * Raised when a UMP lookup yields neither a verified token nor a clean empty response. * * Callers must offer retry/recovery rather than treating this error as a new * account. Diagnostics contain counts only and never hashes, keys, or tokens. @@ -689,70 +689,167 @@ export class OverlayUMPTokenInteractor implements UMPTokenInteractor { } const diagnostics = this.toLookupDiagnostics(resolution) - if (resolution.answer.outputs.length === 0) { - const authoritative = - resolution.progress.isFinal && - resolution.progress.hostCount > 0 && - resolution.progress.completedHosts === resolution.progress.hostCount && - resolution.progress.successfulHosts === resolution.progress.hostCount && - resolution.progress.emptyHosts === resolution.progress.hostCount && - resolution.progress.failedHosts === 0 && - resolution.progress.rejectedHosts === 0 && - resolution.progress.freeformHosts === 0 - - if (!authoritative) { - this.captureLookupFailure(lookupKind, 'lookup-incomplete', diagnostics, startedAt) - throw new UMPTokenLookupError('lookup-incomplete', diagnostics) - } - - this.telemetry.capture({ - name: 'wallet-toolbox.ump.lookup.completed', - component: 'wallet-toolbox.ump', - severity: 'info', - correlationId: diagnostics.correlationId, - attributes: { - lookupKind, - result: 'not-found', - durationMs: Date.now() - startedAt, - ...this.lookupDiagnosticAttributes(diagnostics) - } - }) - return undefined - } - const tokens = this.parseLookupAnswers(resolution.answer) const expectedHash = question.query[lookupKind === 'presentation' ? 'presentationHash' : 'recoveryHash'].toLowerCase() - const everyOutputValidAndMatching = - tokens.length === resolution.answer.outputs.length && - tokens.every( - token => - Utils.toHex(lookupKind === 'presentation' ? token.presentationHash : token.recoveryHash).toLowerCase() === - expectedHash - ) - if (!everyOutputValidAndMatching || tokens.length === 0) { - this.captureLookupFailure(lookupKind, 'token-malformed', diagnostics, startedAt) - throw new UMPTokenLookupError('token-malformed', diagnostics) - } - if (tokens.length !== 1) { + const matchingTokens = tokens.filter( + token => + Utils.toHex(lookupKind === 'presentation' ? token.presentationHash : token.recoveryHash).toLowerCase() === + expectedHash + ) + + // A verified token is positive account-existence evidence. Empty, malformed, + // rejected, or unavailable peers cannot override it. The resolver de-duplicates + // identical outputs, so multiple matching tokens represent distinct records. + // Competing records are usually stale renditions: a token update spends its + // predecessor's outpoint, so the newest rendition is the sole candidate not + // spent by another candidate's transaction history. Only unrelated (forked) + // tokens remain indeterminate. + if (matchingTokens.length > 1) { + const newest = this.resolveNewestToken(matchingTokens, resolution.answer.outputs) + if (newest != null) { + this.captureLookupCompleted(lookupKind, 'found', diagnostics, startedAt, { + supersededTokens: matchingTokens.length - 1 + }) + return newest + } const reason = 'token-ambiguous' this.captureLookupFailure(lookupKind, reason, diagnostics, startedAt) throw new UMPTokenLookupError(reason, diagnostics) } + if (matchingTokens.length === 1) { + this.captureLookupCompleted(lookupKind, 'found', diagnostics, startedAt) + return matchingTokens[0] + } - this.telemetry.capture({ - name: 'wallet-toolbox.ump.lookup.completed', - component: 'wallet-toolbox.ump', - severity: 'info', - correlationId: diagnostics.correlationId, - attributes: { - lookupKind, - result: 'found', - durationMs: Date.now() - startedAt, - ...this.lookupDiagnosticAttributes(diagnostics) + // A clean empty response is sufficient negative evidence once no verified + // token exists. Malformed outputs and failed peers are ignored so they cannot + // deny onboarding by advertising corrupt data or remaining unavailable. + if (resolution.progress.emptyHosts > 0) { + this.captureLookupCompleted(lookupKind, 'not-found', diagnostics, startedAt) + return undefined + } + + const reason = resolution.answer.outputs.length > 0 ? 'token-malformed' : 'lookup-incomplete' + this.captureLookupFailure(lookupKind, reason, diagnostics, startedAt) + throw new UMPTokenLookupError(reason, diagnostics) + } + + /** + * Picks the newest rendition among distinct verified tokens, when possible. + * + * The on-chain UMP protocol expresses token updates by consumption: the + * transaction creating a new rendition spends the previous rendition's + * outpoint (there is no rendition counter field in the current format). + * A candidate is therefore superseded when any other candidate's ancestry + * (available from its BEEF) spends the candidate's outpoint. + * + * @returns The single unsuperseded candidate, or undefined when supersession + * cannot be established for every stale candidate (e.g. forked tokens). + */ + private resolveNewestToken(matchingTokens: UMPToken[], outputs: LookupAnswer['outputs']): UMPToken | undefined { + const candidates = new Map() + for (const token of matchingTokens) { + if (token.currentOutpoint == null) return undefined + candidates.set(token.currentOutpoint, token) + } + + // Hosts may serve the same token with different BEEF depth, so evidence + // for a candidate is merged across every copy rather than first-wins: a + // shallow copy must not mask the supersession proof a deeper copy carries. + const evidenceByCandidate = new Map }>() + for (const output of outputs) { + try { + const tx = Transaction.fromBEEF(output.beef) + const outpoint = `${tx.id('hex')}.${output.outputIndex}` + if (!candidates.has(outpoint)) continue + const evidence = evidenceByCandidate.get(outpoint) ?? { txs: [], spent: new Set() } + evidence.txs.push(tx) + this.collectSpentOutpoints(tx, evidence.spent, new Set()) + evidenceByCandidate.set(outpoint, evidence) + } catch { + // Malformed outputs never produced candidates; nothing to correlate. } + } + + // Without an evidence transaction for every candidate, an unexamined + // candidate would trivially survive — refuse to guess. + if (evidenceByCandidate.size !== candidates.size) return undefined + + const survivors = [...candidates.keys()].filter( + outpoint => + ![...evidenceByCandidate.entries()].some(([other, { spent }]) => other !== outpoint && spent.has(outpoint)) + ) + if (survivors.length === 1) return candidates.get(survivors[0]) + + // Forked candidates: no spend relationship connects them. Updating a token + // requires unlocking its predecessor, so a candidate whose transaction + // provably consumed a same-identity token demonstrates continuity of key + // control; a freshly minted competitor (funding inputs only) is the anomaly + // — typically a historical erroneous re-onboarding. Prefer the sole proven + // continuation; anything less decisive stays indeterminate. + const provenContinuations = survivors.filter(outpoint => { + const evidence = evidenceByCandidate.get(outpoint) + const token = candidates.get(outpoint) + return ( + evidence != null && token != null && evidence.txs.some(tx => this.consumesSameIdentityToken(tx, token)) + ) }) - return tokens[0] + if (provenContinuations.length !== 1) return undefined + return candidates.get(provenContinuations[0]) + } + + /** + * Whether `tx` spends an input whose source output (available in the BEEF) + * decodes as a UMP token sharing the candidate's presentation or recovery + * hash — on-chain proof that the candidate is an update of a same-identity + * predecessor rather than an independently minted token. + */ + private consumesSameIdentityToken(tx: Transaction, token: UMPToken): boolean { + const presentationHash = Utils.toHex(token.presentationHash) + const recoveryHash = Utils.toHex(token.recoveryHash) + for (const input of tx.inputs) { + const source = input.sourceTransaction + if (source == null || input.sourceOutputIndex == null) continue + const sourceOutput = source.outputs[input.sourceOutputIndex] + if (sourceOutput == null) continue + try { + const decoded = PushDrop.decode(sourceOutput.lockingScript) + if (decoded.fields == null) continue + const fields = stripVerifiedPushDropSignature(decoded.fields, decoded.lockingPublicKey) + if (fields.length < 11 || fields[6]?.length !== 32 || fields[7]?.length !== 32) continue + if (Utils.toHex(fields[6]) === presentationHash || Utils.toHex(fields[7]) === recoveryHash) { + return true + } + } catch { + continue + } + } + return false + } + + /** + * Accumulates every outpoint spent by `tx` and by the ancestor transactions + * embedded in its BEEF, so supersession is detected even when intermediate + * renditions are absent from the lookup answer. Iterative so arbitrarily + * long update chains cannot exhaust the call stack. + */ + private collectSpentOutpoints(tx: Transaction, spent: Set, visited: Set): void { + const pending: Transaction[] = [tx] + while (pending.length > 0) { + const current = pending.pop() as Transaction + const txid = current.id('hex') + if (visited.has(txid)) continue + visited.add(txid) + for (const input of current.inputs) { + const sourceTxid = input.sourceTXID ?? input.sourceTransaction?.id('hex') + if (sourceTxid == null || input.sourceOutputIndex == null) continue + spent.add(`${sourceTxid}.${input.sourceOutputIndex}`) + if (input.sourceTransaction != null) { + pending.push(input.sourceTransaction) + } + } + } } private emptyLookupDiagnostics(correlationId?: string): UMPTokenLookupDiagnostics { @@ -797,6 +894,28 @@ export class OverlayUMPTokenInteractor implements UMPTokenInteractor { } } + private captureLookupCompleted( + lookupKind: 'presentation' | 'recovery', + result: 'found' | 'not-found', + diagnostics: UMPTokenLookupDiagnostics, + startedAt: number, + extraAttributes: Record = {} + ): void { + this.telemetry.capture({ + name: 'wallet-toolbox.ump.lookup.completed', + component: 'wallet-toolbox.ump', + severity: 'info', + correlationId: diagnostics.correlationId, + attributes: { + lookupKind, + result, + durationMs: Date.now() - startedAt, + ...this.lookupDiagnosticAttributes(diagnostics), + ...extraAttributes + } + }) + } + private captureLookupFailure( lookupKind: 'presentation' | 'recovery' | 'outpoint', reason: UMPTokenLookupFailureReason, @@ -1128,16 +1247,7 @@ export class OverlayUMPTokenInteractor implements UMPTokenInteractor { } if (resolution.answer.outputs.length === 0) { const p = resolution.progress - const authoritative = - p.isFinal && - p.hostCount > 0 && - p.completedHosts === p.hostCount && - p.successfulHosts === p.hostCount && - p.emptyHosts === p.hostCount && - p.failedHosts === 0 && - p.rejectedHosts === 0 && - p.freeformHosts === 0 - if (!authoritative) { + if (p.emptyHosts === 0) { const diagnostics = this.toLookupDiagnostics(resolution) this.captureLookupFailure('outpoint', 'lookup-incomplete', diagnostics, startedAt) throw new UMPTokenLookupError('lookup-incomplete', diagnostics) diff --git a/packages/wallet/wallet-toolbox/src/__tests/CWIAuthenticationDiagnostics.test.ts b/packages/wallet/wallet-toolbox/src/__tests/CWIAuthenticationDiagnostics.test.ts index ada7c2e71..32c230140 100644 --- a/packages/wallet/wallet-toolbox/src/__tests/CWIAuthenticationDiagnostics.test.ts +++ b/packages/wallet/wallet-toolbox/src/__tests/CWIAuthenticationDiagnostics.test.ts @@ -1,4 +1,12 @@ -import { LookupResolver, LookupResolution, WalletInterface } from '@bsv/sdk' +import { + LookupResolver, + LookupResolution, + LockingScript, + PrivateKey, + Transaction, + UnlockingScript, + WalletInterface +} from '@bsv/sdk' import { CWIStyleWalletManager, OverlayUMPTokenInteractor, @@ -20,14 +28,116 @@ function resolverWith(lookup: (host: string) => Promise<{ type: 'output-list'; o }) } +function resolutionWith(outputCount: number, progress: Partial = {}): LookupResolution { + const outputs = Array.from({ length: outputCount }, (_, index) => ({ + beef: [index + 1], + outputIndex: 0 + })) + const hostCount = progress.hostCount ?? 1 + return { + answer: { + type: 'output-list', + outputs + }, + progress: { + type: 'output-list', + outputs, + txIds: outputs.map((_, index) => (index + 1).toString(16).padStart(64, '0')), + isFinal: progress.isFinal ?? true, + hostCount, + completedHosts: progress.completedHosts ?? hostCount, + successfulHosts: progress.successfulHosts ?? hostCount, + emptyHosts: progress.emptyHosts ?? 0, + failedHosts: progress.failedHosts ?? 0, + rejectedHosts: progress.rejectedHosts ?? 0, + freeformHosts: progress.freeformHosts ?? 0, + ...(progress.correlationId !== undefined ? { correlationId: progress.correlationId } : {}) + } + } +} + +function tokenForPresentationHash(hashByte: number, outpointByte = 'a'): UMPToken { + const fields = Array.from({ length: 11 }, () => Array.from({ length: 32 }).fill(1)) + return { + passwordSalt: fields[0], + passwordPresentationPrimary: fields[1], + passwordRecoveryPrimary: fields[2], + presentationRecoveryPrimary: fields[3], + passwordPrimaryPrivileged: fields[4], + presentationRecoveryPrivileged: fields[5], + presentationHash: Array.from({ length: 32 }).fill(hashByte), + recoveryHash: fields[7], + presentationKeyEncrypted: fields[8], + passwordKeyEncrypted: fields[9], + recoveryKeyEncrypted: fields[10], + currentOutpoint: `${outpointByte.repeat(64)}.0` + } +} + +function tokenAtOutpoint(hashByte: number, outpoint: string): UMPToken { + return { ...tokenForPresentationHash(hashByte), currentOutpoint: outpoint } +} + +function chainedTx(sourceTransaction?: Transaction): Transaction { + const tx = new Transaction() + tx.addInput({ + ...(sourceTransaction != null ? { sourceTransaction } : { sourceTXID: '00'.repeat(32) }), + sourceOutputIndex: 0, + unlockingScript: new UnlockingScript([]), + sequence: 0xffffffff + }) + tx.addOutput({ satoshis: 1, lockingScript: new LockingScript([]) }) + return tx +} + +function umpTokenLockingScript( + hashByte: number, + opts: { recoveryByte?: number; fieldCount?: number; presentationLength?: number; recoveryLength?: number } = {} +): LockingScript { + const { recoveryByte = 1, fieldCount = 11, presentationLength = 32, recoveryLength = 32 } = opts + const pubkey = new PrivateKey(42).toPublicKey().encode(true) as number[] + const fields = Array.from({ length: fieldCount }, (_, i) => { + if (i === 6) return Array.from({ length: presentationLength }).fill(hashByte) as number[] + if (i === 7) return Array.from({ length: recoveryLength }).fill(recoveryByte) as number[] + return Array.from({ length: 32 }).fill(1) as number[] + }) + return new LockingScript([ + { op: pubkey.length, data: pubkey }, + { op: 172 }, // OP_CHECKSIG + ...fields.map(f => ({ op: f.length, data: f })) + ]) +} + +function withoutSourceTransactions(tx: Transaction): Transaction { + return Transaction.fromBinary(tx.toBinary()) +} + +function resolutionWithBeefs(beefs: number[][], progress: Partial = {}): LookupResolution { + const base = resolutionWith(beefs.length, progress) + base.answer.outputs = beefs.map(beef => ({ beef, outputIndex: 0 })) + return base +} + +function interactorWithParsedTokens(resolution: LookupResolution, tokens: UMPToken[]): OverlayUMPTokenInteractor { + const resolver = { + queryDetailed: jest.fn(async () => resolution) + } as unknown as LookupResolver + const interactor = new OverlayUMPTokenInteractor(resolver) + const parser = interactor as unknown as { + parseLookupAnswers: () => UMPToken[] + } + jest.spyOn(parser, 'parseLookupAnswers').mockReturnValue(tokens) + return interactor +} + describe('CWI account lookup diagnostics', () => { - it('returns not-found only when every host authoritatively returned empty', async () => { + it('returns not-found when every host returned empty', async () => { const interactor = new OverlayUMPTokenInteractor(resolverWith(async () => ({ type: 'output-list', outputs: [] }))) await expect(interactor.findByPresentationKeyHash(Array.from({ length: 32 }).fill(1))).resolves.toBeUndefined() }) - it('does not classify an availability failure as a new account', async () => { + it('returns not-found when one host is empty and another host fails', async () => { const interactor = new OverlayUMPTokenInteractor( resolverWith(async host => { if (host.includes('two')) throw new Error('overlay unavailable') @@ -35,63 +145,117 @@ describe('CWI account lookup diagnostics', () => { }) ) - await expect(interactor.findByPresentationKeyHash(Array.from({ length: 32 }).fill(2))).rejects.toMatchObject({ + await expect(interactor.findByPresentationKeyHash(Array.from({ length: 32 }).fill(2))).resolves.toBeUndefined() + await expect(interactor.findByRecoveryKeyHash(Array.from({ length: 32 }).fill(2))).resolves.toBeUndefined() + }) + + it('uses the same partial-empty rule when resolving an old token outpoint', async () => { + const interactor = new OverlayUMPTokenInteractor( + resolverWith(async host => { + if (host.includes('two')) throw new Error('overlay unavailable') + return { type: 'output-list', outputs: [] } + }) + ) + const findByOutpoint = ( + interactor as unknown as { + findByOutpoint: (outpoint: string) => Promise<{ beef: number[]; outputIndex: number } | undefined> + } + ).findByOutpoint.bind(interactor) + + await expect(findByOutpoint(`${'a'.repeat(64)}.0`)).resolves.toBeUndefined() + }) + + it('keeps an old token outpoint indeterminate when no host responds cleanly', async () => { + const interactor = new OverlayUMPTokenInteractor( + resolverWith(async () => { + throw new Error('overlay unavailable') + }) + ) + const findByOutpoint = ( + interactor as unknown as { + findByOutpoint: (outpoint: string) => Promise<{ beef: number[]; outputIndex: number } | undefined> + } + ).findByOutpoint.bind(interactor) + + await expect(findByOutpoint(`${'a'.repeat(64)}.0`)).rejects.toMatchObject({ name: 'UMPTokenLookupError', code: 'WERR_UMP_LOOKUP_INDETERMINATE', reason: 'lookup-incomplete', diagnostics: { hostCount: 2, - successfulHosts: 1, - emptyHosts: 1, - failedHosts: 1, + successfulHosts: 0, + emptyHosts: 0, + failedHosts: 2, outputCount: 0 } } satisfies Partial) }) - it('rejects a UMP token that is not bound to the queried presentation hash', async () => { - const resolution: LookupResolution = { - answer: { - type: 'output-list', - outputs: [{ beef: [1], outputIndex: 0 }] - }, - progress: { - type: 'output-list', - outputs: [{ beef: [1], outputIndex: 0 }], - txIds: ['a'.repeat(64)], - isFinal: true, - hostCount: 1, - completedHosts: 1, - successfulHosts: 1, + it('rejects lookup when every host fails', async () => { + const interactor = new OverlayUMPTokenInteractor( + resolverWith(async () => { + throw new Error('overlay unavailable') + }) + ) + + await expect(interactor.findByPresentationKeyHash(Array.from({ length: 32 }).fill(2))).rejects.toMatchObject({ + name: 'UMPTokenLookupError', + code: 'WERR_UMP_LOOKUP_INDETERMINATE', + reason: 'lookup-incomplete', + diagnostics: { + hostCount: 2, + successfulHosts: 0, emptyHosts: 0, - failedHosts: 0, - rejectedHosts: 0, - freeformHosts: 0 + failedHosts: 2, + outputCount: 0 } - } - const resolver = { - queryDetailed: jest.fn(async () => resolution) - } as unknown as LookupResolver - const interactor = new OverlayUMPTokenInteractor(resolver) - const fields = Array.from({ length: 11 }, () => Array.from({ length: 32 }).fill(1)) - const mismatchedToken: UMPToken = { - passwordSalt: fields[0], - passwordPresentationPrimary: fields[1], - passwordRecoveryPrimary: fields[2], - presentationRecoveryPrimary: fields[3], - passwordPrimaryPrivileged: fields[4], - presentationRecoveryPrivileged: fields[5], - presentationHash: Array.from({ length: 32 }).fill(9), - recoveryHash: fields[7], - presentationKeyEncrypted: fields[8], - passwordKeyEncrypted: fields[9], - recoveryKeyEncrypted: fields[10], - currentOutpoint: `${'a'.repeat(64)}.0` - } - const parser = interactor as unknown as { - parseLookupAnswers: () => UMPToken[] - } - jest.spyOn(parser, 'parseLookupAnswers').mockReturnValue([mismatchedToken]) + } satisfies Partial) + }) + + it('uses one verified token despite empty, malformed, and failing peers', async () => { + const token = tokenForPresentationHash(3) + const interactor = interactorWithParsedTokens( + resolutionWith(2, { + hostCount: 3, + successfulHosts: 2, + emptyHosts: 1, + failedHosts: 1 + }), + [token] + ) + + await expect(interactor.findByPresentationKeyHash(Array.from({ length: 32 }).fill(3))).resolves.toBe(token) + await expect(interactor.findByRecoveryKeyHash(Array.from({ length: 32 }).fill(1))).resolves.toBe(token) + }) + + it('returns not-found when malformed output is accompanied by a clean empty response', async () => { + const interactor = interactorWithParsedTokens( + resolutionWith(1, { + hostCount: 2, + successfulHosts: 2, + emptyHosts: 1 + }), + [] + ) + + await expect(interactor.findByPresentationKeyHash(Array.from({ length: 32 }).fill(3))).resolves.toBeUndefined() + }) + + it('returns not-found when a mismatched token is accompanied by a clean empty response', async () => { + const interactor = interactorWithParsedTokens( + resolutionWith(1, { + hostCount: 2, + successfulHosts: 2, + emptyHosts: 1 + }), + [tokenForPresentationHash(9)] + ) + + await expect(interactor.findByPresentationKeyHash(Array.from({ length: 32 }).fill(3))).resolves.toBeUndefined() + }) + + it('rejects malformed output when no host supplies a token or clean empty response', async () => { + const interactor = interactorWithParsedTokens(resolutionWith(1), []) await expect(interactor.findByPresentationKeyHash(Array.from({ length: 32 }).fill(3))).rejects.toMatchObject({ name: 'UMPTokenLookupError', @@ -99,6 +263,335 @@ describe('CWI account lookup diagnostics', () => { }) }) + it('rejects more than one distinct verified token despite a clean empty response', async () => { + const interactor = interactorWithParsedTokens( + resolutionWith(2, { + hostCount: 2, + successfulHosts: 2, + emptyHosts: 1 + }), + [tokenForPresentationHash(3, 'a'), tokenForPresentationHash(3, 'b')] + ) + + await expect(interactor.findByPresentationKeyHash(Array.from({ length: 32 }).fill(3))).rejects.toMatchObject({ + name: 'UMPTokenLookupError', + reason: 'token-ambiguous' + }) + }) + + it('returns the newest rendition when a competing token is a spent predecessor', async () => { + const oldTx = chainedTx() + const newTx = chainedTx(oldTx) + const unrelatedTx = chainedTx() + unrelatedTx.addOutput({ satoshis: 2, lockingScript: new LockingScript([]) }) + const oldToken = tokenAtOutpoint(3, `${oldTx.id('hex')}.0`) + const newToken = tokenAtOutpoint(3, `${newTx.id('hex')}.0`) + const interactor = interactorWithParsedTokens( + resolutionWithBeefs([oldTx.toBEEF(true), newTx.toBEEF(true), unrelatedTx.toBEEF(true)], { + hostCount: 2, + successfulHosts: 2 + }), + [oldToken, newToken] + ) + + await expect(interactor.findByPresentationKeyHash(Array.from({ length: 32 }).fill(3))).resolves.toBe(newToken) + }) + + it('resolves a multi-hop rendition chain to its tip', async () => { + const first = chainedTx() + const second = chainedTx(first) + const third = chainedTx(second) + const tokens = [first, second, third].map(tx => tokenAtOutpoint(3, `${tx.id('hex')}.0`)) + const interactor = interactorWithParsedTokens( + resolutionWithBeefs( + [first, second, third].map(tx => tx.toBEEF(true)), + { hostCount: 2, successfulHosts: 2 } + ), + tokens + ) + + await expect(interactor.findByPresentationKeyHash(Array.from({ length: 32 }).fill(3))).resolves.toBe(tokens[2]) + }) + + it('detects supersession through an intermediate rendition absent from the lookup answer', async () => { + const first = chainedTx() + const second = chainedTx(first) + const third = chainedTx(second) + const firstToken = tokenAtOutpoint(3, `${first.id('hex')}.0`) + const thirdToken = tokenAtOutpoint(3, `${third.id('hex')}.0`) + const interactor = interactorWithParsedTokens( + resolutionWithBeefs([first.toBEEF(true), third.toBEEF(true)], { + hostCount: 2, + successfulHosts: 2 + }), + [firstToken, thirdToken] + ) + + await expect(interactor.findByPresentationKeyHash(Array.from({ length: 32 }).fill(3))).resolves.toBe(thirdToken) + }) + + it('still rejects competing tokens with no supersession relationship', async () => { + const forkA = chainedTx() + const forkB = chainedTx() + forkB.addOutput({ satoshis: 2, lockingScript: new LockingScript([]) }) + const interactor = interactorWithParsedTokens( + resolutionWithBeefs([forkA.toBEEF(true), forkB.toBEEF(true)], { + hostCount: 2, + successfulHosts: 2 + }), + [tokenAtOutpoint(3, `${forkA.id('hex')}.0`), tokenAtOutpoint(3, `${forkB.id('hex')}.0`)] + ) + + await expect(interactor.findByPresentationKeyHash(Array.from({ length: 32 }).fill(3))).rejects.toMatchObject({ + name: 'UMPTokenLookupError', + reason: 'token-ambiguous' + }) + }) + + it('prefers the proven continuation over an independently minted forked token', async () => { + const predecessor = chainedTx() + predecessor.outputs[0].lockingScript = umpTokenLockingScript(3) + const continuation = chainedTx(predecessor) + const freshMint = chainedTx() + freshMint.addOutput({ satoshis: 2, lockingScript: new LockingScript([]) }) + const continuationToken = tokenAtOutpoint(3, `${continuation.id('hex')}.0`) + const freshMintToken = tokenAtOutpoint(3, `${freshMint.id('hex')}.0`) + const interactor = interactorWithParsedTokens( + resolutionWithBeefs([freshMint.toBEEF(true), continuation.toBEEF(true)], { + hostCount: 2, + successfulHosts: 2 + }), + [freshMintToken, continuationToken] + ) + + await expect(interactor.findByPresentationKeyHash(Array.from({ length: 32 }).fill(3))).resolves.toBe( + continuationToken + ) + }) + + it('resolves regardless of the order in which hosts returned the tokens', async () => { + const oldTx = chainedTx() + const newTx = chainedTx(oldTx) + const oldToken = tokenAtOutpoint(3, `${oldTx.id('hex')}.0`) + const newToken = tokenAtOutpoint(3, `${newTx.id('hex')}.0`) + const interactor = interactorWithParsedTokens( + resolutionWithBeefs([newTx.toBEEF(true), oldTx.toBEEF(true)], { + hostCount: 2, + successfulHosts: 2 + }), + [newToken, oldToken] + ) + + await expect(interactor.findByPresentationKeyHash(Array.from({ length: 32 }).fill(3))).resolves.toBe(newToken) + }) + + it('treats duplicate records of the same outpoint as one token, not a conflict', async () => { + const tx = chainedTx() + const first = tokenAtOutpoint(3, `${tx.id('hex')}.0`) + const second = tokenAtOutpoint(3, `${tx.id('hex')}.0`) + const interactor = interactorWithParsedTokens( + resolutionWithBeefs([tx.toBEEF(true)], { hostCount: 2, successfulHosts: 2 }), + [first, second] + ) + + await expect(interactor.findByPresentationKeyHash(Array.from({ length: 32 }).fill(3))).resolves.toBe(second) + }) + + it('stays indeterminate when a competing token lacks an outpoint', async () => { + const tx = chainedTx() + const anonymous: UMPToken = { ...tokenForPresentationHash(3), currentOutpoint: undefined } + const interactor = interactorWithParsedTokens( + resolutionWithBeefs([tx.toBEEF(true)], { hostCount: 2, successfulHosts: 2 }), + [tokenAtOutpoint(3, `${tx.id('hex')}.0`), anonymous] + ) + + await expect(interactor.findByPresentationKeyHash(Array.from({ length: 32 }).fill(3))).rejects.toMatchObject({ + name: 'UMPTokenLookupError', + reason: 'token-ambiguous' + }) + }) + + it('refuses to pick a winner when a competing token has no examinable evidence', async () => { + const predecessor = chainedTx() + predecessor.outputs[0].lockingScript = umpTokenLockingScript(3) + const continuation = chainedTx(predecessor) + const interactor = interactorWithParsedTokens( + resolutionWithBeefs([continuation.toBEEF(true), [1, 2, 3]], { + hostCount: 2, + successfulHosts: 2 + }), + [tokenAtOutpoint(3, `${continuation.id('hex')}.0`), tokenAtOutpoint(3, 'b'.repeat(64) + '.0')] + ) + + await expect(interactor.findByPresentationKeyHash(Array.from({ length: 32 }).fill(3))).rejects.toMatchObject({ + name: 'UMPTokenLookupError', + reason: 'token-ambiguous' + }) + }) + + it('stays indeterminate when both forked tokens prove same-identity continuation', async () => { + const predecessorA = chainedTx() + predecessorA.outputs[0].lockingScript = umpTokenLockingScript(3) + const predecessorB = chainedTx() + predecessorB.outputs[0].lockingScript = umpTokenLockingScript(3) + predecessorB.addOutput({ satoshis: 2, lockingScript: new LockingScript([]) }) + const continuationA = chainedTx(predecessorA) + const continuationB = chainedTx(predecessorB) + const interactor = interactorWithParsedTokens( + resolutionWithBeefs([continuationA.toBEEF(true), continuationB.toBEEF(true)], { + hostCount: 2, + successfulHosts: 2 + }), + [tokenAtOutpoint(3, `${continuationA.id('hex')}.0`), tokenAtOutpoint(3, `${continuationB.id('hex')}.0`)] + ) + + await expect(interactor.findByPresentationKeyHash(Array.from({ length: 32 }).fill(3))).rejects.toMatchObject({ + name: 'UMPTokenLookupError', + reason: 'token-ambiguous' + }) + }) + + it('recognizes continuation through the recovery hash when the presentation key rotated', async () => { + const predecessor = chainedTx() + predecessor.outputs[0].lockingScript = umpTokenLockingScript(9, { recoveryByte: 1 }) + const continuation = chainedTx(predecessor) + const freshMint = chainedTx() + freshMint.addOutput({ satoshis: 2, lockingScript: new LockingScript([]) }) + const continuationToken = tokenAtOutpoint(3, `${continuation.id('hex')}.0`) + const interactor = interactorWithParsedTokens( + resolutionWithBeefs([continuation.toBEEF(true), freshMint.toBEEF(true)], { + hostCount: 2, + successfulHosts: 2 + }), + [continuationToken, tokenAtOutpoint(3, `${freshMint.id('hex')}.0`)] + ) + + await expect(interactor.findByPresentationKeyHash(Array.from({ length: 32 }).fill(3))).resolves.toBe( + continuationToken + ) + }) + + it('ignores a predecessor with malformed identity hashes', async () => { + const predecessor = chainedTx() + predecessor.outputs[0].lockingScript = umpTokenLockingScript(3, { + presentationLength: 31, + recoveryLength: 31 + }) + const continuation = chainedTx(predecessor) + const freshMint = chainedTx() + freshMint.addOutput({ satoshis: 2, lockingScript: new LockingScript([]) }) + const interactor = interactorWithParsedTokens( + resolutionWithBeefs([continuation.toBEEF(true), freshMint.toBEEF(true)], { + hostCount: 2, + successfulHosts: 2 + }), + [tokenAtOutpoint(3, `${continuation.id('hex')}.0`), tokenAtOutpoint(3, `${freshMint.id('hex')}.0`)] + ) + + await expect(interactor.findByPresentationKeyHash(Array.from({ length: 32 }).fill(3))).rejects.toMatchObject({ + name: 'UMPTokenLookupError', + reason: 'token-ambiguous' + }) + }) + + it('ignores a predecessor whose locking script cannot be decoded', async () => { + const malformedPredecessor = chainedTx() + const continuation = chainedTx(malformedPredecessor) + const freshMint = chainedTx() + freshMint.addOutput({ satoshis: 2, lockingScript: new LockingScript([]) }) + const interactor = interactorWithParsedTokens( + resolutionWithBeefs([continuation.toBEEF(true), freshMint.toBEEF(true)], { + hostCount: 2, + successfulHosts: 2 + }), + [tokenAtOutpoint(3, `${continuation.id('hex')}.0`), tokenAtOutpoint(3, `${freshMint.id('hex')}.0`)] + ) + + await expect(interactor.findByPresentationKeyHash(Array.from({ length: 32 }).fill(3))).rejects.toMatchObject({ + name: 'UMPTokenLookupError', + reason: 'token-ambiguous' + }) + }) + + it('recognizes a twelve-field predecessor token (profiles present)', async () => { + const predecessor = chainedTx() + predecessor.outputs[0].lockingScript = umpTokenLockingScript(3, { fieldCount: 12 }) + const continuation = chainedTx(predecessor) + const freshMint = chainedTx() + freshMint.addOutput({ satoshis: 2, lockingScript: new LockingScript([]) }) + const continuationToken = tokenAtOutpoint(3, `${continuation.id('hex')}.0`) + const interactor = interactorWithParsedTokens( + resolutionWithBeefs([continuation.toBEEF(true), freshMint.toBEEF(true)], { + hostCount: 2, + successfulHosts: 2 + }), + [continuationToken, tokenAtOutpoint(3, `${freshMint.id('hex')}.0`)] + ) + + await expect(interactor.findByPresentationKeyHash(Array.from({ length: 32 }).fill(3))).resolves.toBe( + continuationToken + ) + }) + + it('picks the continuation among a stale predecessor, its update, and an unrelated fork', async () => { + const stale = chainedTx() + stale.outputs[0].lockingScript = umpTokenLockingScript(3) + const continuation = chainedTx(stale) + const unrelatedFork = chainedTx() + unrelatedFork.addOutput({ satoshis: 2, lockingScript: new LockingScript([]) }) + const continuationToken = tokenAtOutpoint(3, `${continuation.id('hex')}.0`) + const interactor = interactorWithParsedTokens( + resolutionWithBeefs([stale.toBEEF(true), continuation.toBEEF(true), unrelatedFork.toBEEF(true)], { + hostCount: 3, + successfulHosts: 3 + }), + [ + tokenAtOutpoint(3, `${stale.id('hex')}.0`), + continuationToken, + tokenAtOutpoint(3, `${unrelatedFork.id('hex')}.0`) + ] + ) + + await expect(interactor.findByPresentationKeyHash(Array.from({ length: 32 }).fill(3))).resolves.toBe( + continuationToken + ) + }) + + it('merges evidence when hosts serve the same token at different BEEF depths', async () => { + const predecessor = chainedTx() + const intermediate = chainedTx(predecessor) + const tip = chainedTx(intermediate) + const shallowTip = withoutSourceTransactions(tip) + const predecessorToken = tokenAtOutpoint(3, `${predecessor.id('hex')}.0`) + const tipToken = tokenAtOutpoint(3, `${tip.id('hex')}.0`) + const interactor = interactorWithParsedTokens( + resolutionWithBeefs([shallowTip.toBEEF(true), predecessor.toBEEF(true), tip.toBEEF(true)], { + hostCount: 3, + successfulHosts: 3 + }), + [tipToken, predecessorToken] + ) + + await expect(interactor.findByPresentationKeyHash(Array.from({ length: 32 }).fill(3))).resolves.toBe(tipToken) + }) + + it('resolves a very deep update chain without exhausting the stack', async () => { + let tip = chainedTx() + const root = tip + for (let i = 0; i < 1200; i++) tip = chainedTx(tip) + const rootToken = tokenAtOutpoint(3, `${root.id('hex')}.0`) + const tipToken = tokenAtOutpoint(3, `${tip.id('hex')}.0`) + const interactor = interactorWithParsedTokens( + resolutionWithBeefs([root.toBEEF(true), tip.toBEEF(true)], { + hostCount: 2, + successfulHosts: 2 + }), + [rootToken, tipToken] + ) + + await expect(interactor.findByPresentationKeyHash(Array.from({ length: 32 }).fill(3))).resolves.toBe(tipToken) + }) + it('keeps authentication unknown until lookup succeeds and rejects invalid initial state', async () => { const interactor: UMPTokenInteractor = { findByPresentationKeyHash: jest.fn(async () => undefined),