From ceb8fa6450508ae2af5d4f784b4e796738b9c30f Mon Sep 17 00:00:00 2001 From: Ty J Everett Date: Mon, 3 Aug 2026 17:21:05 -0700 Subject: [PATCH 1/4] fix(wallet): tolerate partial UMP lookup failure --- docs/packages/wallet/wallet-toolbox-client.md | 7 +- docs/packages/wallet/wallet-toolbox-mobile.md | 7 +- docs/packages/wallet/wallet-toolbox.md | 10 +- docs/reference/package-api-migrations.md | 82 ++++---- governance/package-release-notes.json | 14 +- packages/wallet/wallet-toolbox/CHANGELOG.md | 8 + .../docs/authentication-observability.md | 20 +- .../src/CWIStyleWalletManager.ts | 118 +++++------ .../CWIAuthenticationDiagnostics.test.ts | 197 +++++++++++++----- 9 files changed, 283 insertions(+), 180 deletions(-) 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..9080b7c29 100644 --- a/packages/wallet/wallet-toolbox/CHANGELOG.md +++ b/packages/wallet/wallet-toolbox/CHANGELOG.md @@ -34,6 +34,13 @@ 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. Multiple + distinct verified tokens and lookups with no usable response remain errors; + WAB existing-account continuity still prevents replacement-wallet onboarding. - 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 +232,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..18a51064b 100644 --- a/packages/wallet/wallet-toolbox/docs/authentication-observability.md +++ b/packages/wallet/wallet-toolbox/docs/authentication-observability.md @@ -58,20 +58,22 @@ 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 remain 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..dddfe7a65 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,39 @@ 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. + if (matchingTokens.length > 1) { 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) - } - }) - return tokens[0] + // 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) } private emptyLookupDiagnostics(correlationId?: string): UMPTokenLookupDiagnostics { @@ -797,6 +766,26 @@ export class OverlayUMPTokenInteractor implements UMPTokenInteractor { } } + private captureLookupCompleted( + lookupKind: 'presentation' | 'recovery', + result: 'found' | 'not-found', + diagnostics: UMPTokenLookupDiagnostics, + startedAt: number + ): 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) + } + }) + } + private captureLookupFailure( lookupKind: 'presentation' | 'recovery' | 'outpoint', reason: UMPTokenLookupFailureReason, @@ -1128,16 +1117,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..1d83d7f41 100644 --- a/packages/wallet/wallet-toolbox/src/__tests/CWIAuthenticationDiagnostics.test.ts +++ b/packages/wallet/wallet-toolbox/src/__tests/CWIAuthenticationDiagnostics.test.ts @@ -20,14 +20,72 @@ 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 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 +93,90 @@ describe('CWI account lookup diagnostics', () => { }) ) + 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('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: 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, - emptyHosts: 0, - failedHosts: 0, - rejectedHosts: 0, - freeformHosts: 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]) + 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) + }) + + 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 +184,22 @@ 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('keeps authentication unknown until lookup succeeds and rejects invalid initial state', async () => { const interactor: UMPTokenInteractor = { findByPresentationKeyHash: jest.fn(async () => undefined), From 60fdab9e9980a2ceacafe1ab35823bc51f027ee8 Mon Sep 17 00:00:00 2001 From: Ty J Everett Date: Mon, 3 Aug 2026 17:43:35 -0700 Subject: [PATCH 2/4] test(wallet): cover indeterminate outpoint lookup --- .../CWIAuthenticationDiagnostics.test.ts | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/packages/wallet/wallet-toolbox/src/__tests/CWIAuthenticationDiagnostics.test.ts b/packages/wallet/wallet-toolbox/src/__tests/CWIAuthenticationDiagnostics.test.ts index 1d83d7f41..a5d7b87f8 100644 --- a/packages/wallet/wallet-toolbox/src/__tests/CWIAuthenticationDiagnostics.test.ts +++ b/packages/wallet/wallet-toolbox/src/__tests/CWIAuthenticationDiagnostics.test.ts @@ -113,6 +113,32 @@ describe('CWI account lookup diagnostics', () => { 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: 0, + emptyHosts: 0, + failedHosts: 2, + outputCount: 0 + } + } satisfies Partial) + }) + it('rejects lookup when every host fails', async () => { const interactor = new OverlayUMPTokenInteractor( resolverWith(async () => { From e37d95d4e1db5d659588632b795348a88b1818ce Mon Sep 17 00:00:00 2001 From: Brayden Langley Date: Tue, 4 Aug 2026 12:05:03 -0700 Subject: [PATCH 3/4] fix(wallet): resolve competing UMP tokens via on-chain supersession MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When distinct verified UMP tokens compete for the same account hash, resolve on on-chain proof instead of always erroring: - A candidate spent anywhere in another candidate's BEEF ancestry is superseded. The walk is iterative (deep update chains cannot exhaust the stack) and evidence is merged across hosts serving the same token at different BEEF depths, so a shallow copy cannot mask a deeper copy's proof. - Forked candidates with no spend relationship resolve only when exactly one provably consumed a same-identity predecessor token (matching presentation or recovery hash), which requires the account's keys and demonstrates continuity of control. A freshly minted competitor is typically the residue of an erroneous historical re-onboarding. - Anything less decisive — true forks, competing proven continuations, or candidates without examinable evidence — still raises UMPTokenLookupError so a wrong token can never be chosen silently, and multiple verified tokens can never be mistaken for a new account. - Resolved conflicts report a supersededTokens count in the completed lookup telemetry event (counts only, consistent with the privacy boundary). Covers the edge-case matrix in CWIAuthenticationDiagnostics.test.ts: direct and multi-hop supersession, supersession through intermediates absent from the lookup answer, host-order independence, duplicate records, merged shallow/deep evidence, 1200-link chains, rotated presentation keys, twelve-field predecessors, malformed identity hashes, and the fail-closed fork cases. Co-Authored-By: Claude Fable 5 --- packages/wallet/wallet-toolbox/CHANGELOG.md | 14 +- .../docs/authentication-observability.md | 12 +- .../src/CWIStyleWalletManager.ts | 134 ++++++- .../CWIAuthenticationDiagnostics.test.ts | 346 +++++++++++++++++- 4 files changed, 499 insertions(+), 7 deletions(-) diff --git a/packages/wallet/wallet-toolbox/CHANGELOG.md b/packages/wallet/wallet-toolbox/CHANGELOG.md index 9080b7c29..96aa3397f 100644 --- a/packages/wallet/wallet-toolbox/CHANGELOG.md +++ b/packages/wallet/wallet-toolbox/CHANGELOG.md @@ -38,9 +38,17 @@ attention to changes that materially alter behavior or extend functionality. 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. Multiple - distinct verified tokens and lookups with no usable response remain errors; - WAB existing-account continuity still prevents replacement-wallet onboarding. + 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 diff --git a/packages/wallet/wallet-toolbox/docs/authentication-observability.md b/packages/wallet/wallet-toolbox/docs/authentication-observability.md index 18a51064b..5b235b0ba 100644 --- a/packages/wallet/wallet-toolbox/docs/authentication-observability.md +++ b/packages/wallet/wallet-toolbox/docs/authentication-observability.md @@ -63,7 +63,17 @@ 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 remain ambiguous, and a lookup with +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. diff --git a/packages/wallet/wallet-toolbox/src/CWIStyleWalletManager.ts b/packages/wallet/wallet-toolbox/src/CWIStyleWalletManager.ts index dddfe7a65..d8f84e929 100644 --- a/packages/wallet/wallet-toolbox/src/CWIStyleWalletManager.ts +++ b/packages/wallet/wallet-toolbox/src/CWIStyleWalletManager.ts @@ -701,7 +701,18 @@ export class OverlayUMPTokenInteractor implements UMPTokenInteractor { // 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) @@ -724,6 +735,123 @@ export class OverlayUMPTokenInteractor implements UMPTokenInteractor { 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)) + ) + }) + 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 { return { hostCount: 0, @@ -770,7 +898,8 @@ export class OverlayUMPTokenInteractor implements UMPTokenInteractor { lookupKind: 'presentation' | 'recovery', result: 'found' | 'not-found', diagnostics: UMPTokenLookupDiagnostics, - startedAt: number + startedAt: number, + extraAttributes: Record = {} ): void { this.telemetry.capture({ name: 'wallet-toolbox.ump.lookup.completed', @@ -781,7 +910,8 @@ export class OverlayUMPTokenInteractor implements UMPTokenInteractor { lookupKind, result, durationMs: Date.now() - startedAt, - ...this.lookupDiagnosticAttributes(diagnostics) + ...this.lookupDiagnosticAttributes(diagnostics), + ...extraAttributes } }) } diff --git a/packages/wallet/wallet-toolbox/src/__tests/CWIAuthenticationDiagnostics.test.ts b/packages/wallet/wallet-toolbox/src/__tests/CWIAuthenticationDiagnostics.test.ts index a5d7b87f8..1d97ceacc 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, @@ -66,6 +74,50 @@ function tokenForPresentationHash(hashByte: number, outpointByte = 'a'): UMPToke } } +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) @@ -226,6 +278,298 @@ describe('CWI account lookup diagnostics', () => { }) }) + it('returns the newest rendition when a competing token is a spent predecessor', 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([oldTx.toBEEF(true), newTx.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('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), From dcf5a64179c8bff7b4b12ca233c971ef4b2705e4 Mon Sep 17 00:00:00 2001 From: Ty J Everett Date: Tue, 4 Aug 2026 13:09:49 -0700 Subject: [PATCH 4/4] test(wallet): cover malformed predecessor evidence --- .../CWIAuthenticationDiagnostics.test.ts | 24 ++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/packages/wallet/wallet-toolbox/src/__tests/CWIAuthenticationDiagnostics.test.ts b/packages/wallet/wallet-toolbox/src/__tests/CWIAuthenticationDiagnostics.test.ts index 1d97ceacc..32c230140 100644 --- a/packages/wallet/wallet-toolbox/src/__tests/CWIAuthenticationDiagnostics.test.ts +++ b/packages/wallet/wallet-toolbox/src/__tests/CWIAuthenticationDiagnostics.test.ts @@ -225,6 +225,7 @@ describe('CWI account lookup diagnostics', () => { ) 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 () => { @@ -281,10 +282,12 @@ describe('CWI account lookup diagnostics', () => { 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)], { + resolutionWithBeefs([oldTx.toBEEF(true), newTx.toBEEF(true), unrelatedTx.toBEEF(true)], { hostCount: 2, successfulHosts: 2 }), @@ -491,6 +494,25 @@ describe('CWI account lookup diagnostics', () => { }) }) + 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 })