From f5a37680df5cda7ff53550b3f9fa579aaddd2b43 Mon Sep 17 00:00:00 2001 From: Ty J Everett Date: Sun, 30 Aug 2026 20:51:55 -0700 Subject: [PATCH 01/10] feat(wallet-toolbox): add BRC-177 noSend expiry --- docs/packages/wallet/wallet-toolbox-client.md | 4 + docs/packages/wallet/wallet-toolbox-mobile.md | 4 + docs/packages/wallet/wallet-toolbox.md | 6 + docs/reference/dependency-policy.md | 18 +- docs/reference/package-api-migrations.md | 9 + governance/dependency-release-policy.json | 4 +- governance/package-release-notes.json | 12 +- governance/repository-health/exceptions.json | 4 +- packages/wallet/wallet-toolbox/CHANGELOG.md | 19 + packages/wallet/wallet-toolbox/README.md | 5 + .../wallet/wallet-toolbox/client/README.md | 6 + .../client/platform-budget.json | 12 +- .../wallet-toolbox/docs/no-send-expiry.md | 140 ++++ .../wallet/wallet-toolbox/mobile/README.md | 6 + .../mobile/platform-budget.json | 6 +- packages/wallet/wallet-toolbox/src/Wallet.ts | 14 +- .../src/WalletPermissionsManager.ts | 91 ++- .../WalletPermissionsManager.pmodules.test.ts | 223 +++++- .../src/mockchain/MockServices.ts | 18 + .../wallet-toolbox/src/monitor/Monitor.ts | 5 + .../src/monitor/tasks/TaskNoSendExpiry.ts | 44 + .../src/monitor/tasks/index.all.ts | 1 + .../src/sdk/ActionBatch.interfaces.ts | 4 + .../src/sdk/WalletStorage.interfaces.ts | 45 ++ .../createNoSendExpiryAction.test.ts | 55 ++ .../methods/createNoSendExpiryAction.ts | 307 +++++++ .../src/signer/methods/signAction.ts | 12 +- .../wallet-toolbox/src/storage/StorageIdb.ts | 43 +- .../wallet-toolbox/src/storage/StorageKnex.ts | 15 + .../src/storage/StorageProvider.ts | 158 +++- .../src/storage/StorageReaderWriter.ts | 10 + .../src/storage/WalletStorageManager.ts | 31 + .../src/storage/__test/StorageIdb.test.ts | 35 +- .../wallet-toolbox/src/storage/idbHelpers.ts | 16 +- .../src/storage/methods/createAction.ts | 94 ++- .../src/storage/methods/noSendExpiry.ts | 428 ++++++++++ .../storage/methods/noSendExpiryLifecycle.ts | 744 +++++++++++++++++ .../src/storage/methods/processAction.ts | 55 +- .../src/storage/remoting/StorageClientBase.ts | 22 + .../src/storage/remoting/StorageServer.ts | 15 +- .../remoting/__test/StorageClient.test.ts | 27 + .../remoting/__test/StorageServerRpc.test.ts | 44 + .../src/storage/schema/KnexMigrations.ts | 47 ++ .../src/storage/schema/StorageIdbSchema.ts | 2 + .../storage/schema/entities/EntityOutput.ts | 19 + .../schema/entities/EntityTransaction.ts | 296 ++++++- .../entities/__tests/OutputTests.test.ts | 104 ++- .../entities/__tests/TransactionTests.test.ts | 136 ++++ .../storage/schema/tables/TableTransaction.ts | 34 +- .../__tests/brc177NoSendExpiry.test.ts | 50 ++ .../src/utility/brc177NoSendExpiry.ts | 120 +++ .../wallet-toolbox/src/utility/index.all.ts | 1 + .../src/utility/index.client.ts | 1 + .../test/Wallet/action/noSendExpiry.test.ts | 753 ++++++++++++++++++ .../test/storage/KnexMigrations.test.ts | 36 + 55 files changed, 4325 insertions(+), 85 deletions(-) create mode 100644 packages/wallet/wallet-toolbox/docs/no-send-expiry.md create mode 100644 packages/wallet/wallet-toolbox/src/monitor/tasks/TaskNoSendExpiry.ts create mode 100644 packages/wallet/wallet-toolbox/src/signer/methods/__tests__/createNoSendExpiryAction.test.ts create mode 100644 packages/wallet/wallet-toolbox/src/signer/methods/createNoSendExpiryAction.ts create mode 100644 packages/wallet/wallet-toolbox/src/storage/methods/noSendExpiry.ts create mode 100644 packages/wallet/wallet-toolbox/src/storage/methods/noSendExpiryLifecycle.ts create mode 100644 packages/wallet/wallet-toolbox/src/utility/__tests/brc177NoSendExpiry.test.ts create mode 100644 packages/wallet/wallet-toolbox/src/utility/brc177NoSendExpiry.ts create mode 100644 packages/wallet/wallet-toolbox/test/Wallet/action/noSendExpiry.test.ts diff --git a/docs/packages/wallet/wallet-toolbox-client.md b/docs/packages/wallet/wallet-toolbox-client.md index 8e60bf46a..f8c6565ca 100644 --- a/docs/packages/wallet/wallet-toolbox-client.md +++ b/docs/packages/wallet/wallet-toolbox-client.md @@ -18,6 +18,10 @@ tags: [wallet, browser, indexeddb, storage, brc-100] `@bsv/wallet-toolbox-client` is the browser-safe Wallet Toolbox distribution. It includes the BRC-100 wallet, signer, services, IndexedDB storage, and remote storage client without Node-only Knex, SQLite, MySQL, or filesystem adapters. +The built-in BRC-177 `p nosend expiry` module works with local IndexedDB or a +2.11-compatible remote storage service; the active provider owns durable +expiry monitoring, synchronized lifecycle state, and pre-signed reclaim +submission. IndexedDB `listOutputs` results keep `totalOutputs` equal to the full matching count across short final and out-of-range pages. Related browser `noSend` chains retain local action batching, while unrelated diff --git a/docs/packages/wallet/wallet-toolbox-mobile.md b/docs/packages/wallet/wallet-toolbox-mobile.md index db49aa450..5df17bb1b 100644 --- a/docs/packages/wallet/wallet-toolbox-mobile.md +++ b/docs/packages/wallet/wallet-toolbox-mobile.md @@ -18,6 +18,10 @@ tags: [wallet, react-native, mobile, storage, brc-100] `@bsv/wallet-toolbox-mobile` is the React Native and mobile-safe Wallet Toolbox distribution. It includes wallet, signer, services, monitoring, and remote storage surfaces without Knex, SQLite/MySQL, IndexedDB, or Node-only IO. +The built-in BRC-177 `p nosend expiry` module delegates durable expiry +monitoring and pre-signed reclaim submission to its 2.11-compatible active +remote storage service, so mobile process suspension does not restart or lose +an expiry. Related mobile `noSend` chains retain local action batching, while unrelated actions cannot join or commit the active workspace. Supported remote providers can resume a soft-expired workspace using its exact persisted inputs. diff --git a/docs/packages/wallet/wallet-toolbox.md b/docs/packages/wallet/wallet-toolbox.md index 4fe6b9dca..db956a71e 100644 --- a/docs/packages/wallet/wallet-toolbox.md +++ b/docs/packages/wallet/wallet-toolbox.md @@ -19,6 +19,12 @@ repo: 'https://github.com/bsv-blockchain/ts-stack/tree/main/packages/wallet/wall Use this package when you are building a wallet product, a wallet-like service, or another implementation that must match BRC-100 behavior. +Wallet Toolbox 2.11 adds the built-in BRC-177 `p nosend expiry` module. It +pre-funds expiring `noSend` actions, stores a signed reclaim durably across +active/backup storage and restarts, and lets the authoritative local or remote +monitor reclaim an unbroadcast action after its time or block-height deadline. +See [Expiring noSend actions](https://github.com/bsv-blockchain/ts-stack/blob/main/packages/wallet/wallet-toolbox/docs/no-send-expiry.md). + Knex and IndexedDB `listOutputs` providers report `totalOutputs` as the full matching count on every page, including short final and out-of-range pages. diff --git a/docs/reference/dependency-policy.md b/docs/reference/dependency-policy.md index 3078c581f..475d186b1 100644 --- a/docs/reference/dependency-policy.md +++ b/docs/reference/dependency-policy.md @@ -2,7 +2,7 @@ id: dependency-release-policy title: 'Dependency and Release Policy' kind: reference -version: '1.3.0' +version: '1.3.1' last_updated: '2026-08-30' last_verified: '2026-08-30' review_cadence_days: 30 @@ -139,15 +139,13 @@ frozen graph stayed clean without it. The machine-readable registry now maps every remaining selector and exact value to its exception. CI rejects a new, changed, stale, expired, unowned, or upstream-unlinked override. -Wave 39 repeated the removal rehearsal by regenerating every standalone lock -without its `gaxios`, `uuid`, and `brace-expansion` substitutions and checking -the natural dependency graph. It also rechecked the root Jest/minimatch, -typed-rest-client/qs, and isolated Redocly closures, then refreshed affected -locks for the current `brace-expansion`, `fast-uri`, `ip-address`, and -`socket.io-parser` advisories. All 19 remaining selectors still prevent a -reproduced vulnerable resolution or preserve the governed reproducible -generator, so none can be removed safely yet. The method, result, count, and next rehearsal are enforced in -`governance/dependency-release-policy.json`. +Wave 40 rechecked all 20 selectors against the frozen graphs, current upstream +manifests, and the advisory audit. The supported graphs still select exact +`gaxios@7.1.3`, admit `uuid@9`, and pin `qs@6.15.1` without their registered +substitutions. New upstream majors can remove some legacy paths only through a +coordinated Stryker or Google Cloud migration, so no selector can be removed +safely in isolation. The method, result, count, and next rehearsal are enforced +in `governance/dependency-release-policy.json`. The independently locked OpenAPI generator also carries a narrow Redocly compatibility override. It is isolated from runtime packages, registered with diff --git a/docs/reference/package-api-migrations.md b/docs/reference/package-api-migrations.md index 33810fcbf..53156ade3 100644 --- a/docs/reference/package-api-migrations.md +++ b/docs/reference/package-api-migrations.md @@ -58,6 +58,9 @@ and clean-consumer tests remain the executable type authority. | `@bsv/wallet-toolbox` | `2.10.4` | `2.11.0` | minor | [API and usage](../packages/wallet/wallet-toolbox.md) | Existing permission modules require no changes because onRequest and onResponse remain supported. Semantic modules may add handleRequest; hosts installing @bsv/ecpm-permission-module register it under the ecpm scheme. Upgrade to @bsv/sdk 2.4.2 or later, use docs/storage.md instead of the removed JSight export, and retain THIRD_PARTY_NOTICES.md and LICENSES/ when redistributing. | | `@bsv/wallet-toolbox-client` | `2.10.4` | `2.11.0` | minor | [API and usage](../packages/wallet/wallet-toolbox-client.md) | Existing permission modules require no changes. Semantic modules may add handleRequest; installing @bsv/ecpm-permission-module requires registration under the ecpm scheme. Use @bsv/sdk 2.4.2 or later and retain THIRD_PARTY_NOTICES.md and LICENSES/ when redistributing. | | `@bsv/wallet-toolbox-mobile` | `2.10.4` | `2.11.0` | minor | [API and usage](../packages/wallet/wallet-toolbox-mobile.md) | Existing permission modules require no changes. Semantic modules may add handleRequest; mobile hosts can register compatible semantic modules without changing the Wallet interface. Use @bsv/sdk 2.4.2 or later and retain THIRD_PARTY_NOTICES.md and LICENSES/ when redistributing. | +| `@bsv/wallet-toolbox` | `2.10.0` | `2.11.0` | minor | [API and usage](../packages/wallet/wallet-toolbox.md) | Existing actions and ordinary noSend calls require no consumer migration. To use BRC-177, migrate every active Knex store before serving requests and run the default Wallet Toolbox monitor; IndexedDB upgrades automatically to schema version 5. Upgrade signer, active storage service, and remote monitor together to 2.11.0 or later. Older remote storage is rejected before prefunding. Canonical AtomicBEEF and number-array behavior are unchanged; use @bsv/sdk 2.4.2 or later and retain THIRD_PARTY_NOTICES.md and LICENSES/. | +| `@bsv/wallet-toolbox-client` | `2.10.0` | `2.11.0` | minor | [API and usage](../packages/wallet/wallet-toolbox-client.md) | Existing browser actions require no migration and IndexedDB upgrades automatically. To use BRC-177 with remote storage, upgrade the active storage service and its default monitor to Wallet Toolbox 2.11.0 or later before upgrading clients; an older server is rejected before prefunding. Browser exports, wire types, canonical AtomicBEEF behavior, and pagination contracts are unchanged; use @bsv/sdk 2.4.2 or later and retain THIRD_PARTY_NOTICES.md and LICENSES/. | +| `@bsv/wallet-toolbox-mobile` | `2.10.0` | `2.11.0` | minor | [API and usage](../packages/wallet/wallet-toolbox-mobile.md) | Existing mobile actions require no migration. To use BRC-177, migrate and upgrade the active remote storage service and its default monitor to Wallet Toolbox 2.11.0 or later before upgrading clients; an older server is rejected before prefunding. React Native exports, wire types, and canonical AtomicBEEF behavior are unchanged; use @bsv/sdk 2.4.2 or later and retain THIRD_PARTY_NOTICES.md and LICENSES/. | | `create-bsv-app` | `1.0.2` | `1.1.1` | minor | [API and usage](../packages/helpers/create-bsv-app.md) | Existing mainnet and testnet scaffolds are unchanged. New TTN projects pass --network ttn or select TerraTestNet in the configurator. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/ with the package. | `none` means the source manifest matches the recorded npm baseline. Any other @@ -522,6 +525,8 @@ CLI entry points: `{"wallet-relay":"./bin/init.mjs"}`. - Source: [packages/wallet/wallet-toolbox](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/wallet/wallet-toolbox) - Release note: Adds the optional semantic handleRequest hook to BRC-98/99/111 permission modules while retaining the existing transformation hooks, BRC-95/BRC-100 compatibility, and stable bounded pagination. It also removes the obsolete JSight application bundle and preserves the package's earlier Open BSV grant in the distribution notice archive. - Migration: Existing permission modules require no changes because onRequest and onResponse remain supported. Semantic modules may add handleRequest; hosts installing @bsv/ecpm-permission-module register it under the ecpm scheme. Upgrade to @bsv/sdk 2.4.2 or later, use docs/storage.md instead of the removed JSight export, and retain THIRD_PARTY_NOTICES.md and LICENSES/ when redistributing. +- Release note: Adds the built-in BRC-177 noSend-expiry reference implementation with exact prefunding, durable pre-signed reclaim, atomic active-storage monitoring, cross-device lifecycle synchronization, and proof-finalized race handling. Retains BRC-95 and BRC-100 compatibility and stable bounded pagination, removes an obsolete exported JSight application bundle that lacked its required third-party license companion, preserves the package's earlier Open BSV grant in the distribution notice archive, and standardizes first-party author metadata on the current BSV Association name. +- Migration: Existing actions and ordinary noSend calls require no consumer migration. To use BRC-177, migrate every active Knex store before serving requests and run the default Wallet Toolbox monitor; IndexedDB upgrades automatically to schema version 5. Upgrade signer, active storage service, and remote monitor together to 2.11.0 or later. Older remote storage is rejected before prefunding. Canonical AtomicBEEF and number-array behavior are unchanged; use @bsv/sdk 2.4.2 or later and retain THIRD_PARTY_NOTICES.md and LICENSES/. | Public subpath | Runtime target(s) | Declaration target(s) | | ---------------- | ---------------------------------------------------- | -------------------------- | @@ -536,6 +541,8 @@ CLI entry points: `{"wallet-relay":"./bin/init.mjs"}`. - Source: [packages/wallet/wallet-toolbox/client](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/wallet/wallet-toolbox/client) - Release note: Exports the optional semantic handleRequest permission-module hook for browser and ESM wallet hosts while retaining transformation modules, BRC-100 wire compatibility, stable IndexedDB totals, and the current browser Wallet Toolbox compatibility fixes. It preserves earlier Open BSV grants in the distribution notice archive. - Migration: Existing permission modules require no changes. Semantic modules may add handleRequest; installing @bsv/ecpm-permission-module requires registration under the ecpm scheme. Use @bsv/sdk 2.4.2 or later and retain THIRD_PARTY_NOTICES.md and LICENSES/ when redistributing. +- Release note: Adds the built-in browser BRC-177 noSend-expiry signer, IndexedDB schema version 5 lifecycle state, remote storage capability negotiation, and default monitor coordination. Also carries the browser Wallet Toolbox internalization and BRC-100 compatibility fixes, stable IndexedDB totals, earlier Open BSV grant notices, and current BSV Association author metadata. +- Migration: Existing browser actions require no migration and IndexedDB upgrades automatically. To use BRC-177 with remote storage, upgrade the active storage service and its default monitor to Wallet Toolbox 2.11.0 or later before upgrading clients; an older server is rejected before prefunding. Browser exports, wire types, canonical AtomicBEEF behavior, and pagination contracts are unchanged; use @bsv/sdk 2.4.2 or later and retain THIRD_PARTY_NOTICES.md and LICENSES/. | Public subpath | Runtime target(s) | Declaration target(s) | | ---------------- | -------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------- | @@ -548,6 +555,8 @@ CLI entry points: `{"wallet-relay":"./bin/init.mjs"}`. - Source: [packages/wallet/wallet-toolbox/mobile](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/wallet/wallet-toolbox/mobile) - Release note: Exports the optional semantic handleRequest permission-module hook for React Native wallet hosts while retaining transformation modules, BRC-100 wire compatibility, and the current mobile Wallet Toolbox compatibility fixes. It preserves earlier Open BSV grants in the distribution notice archive. - Migration: Existing permission modules require no changes. Semantic modules may add handleRequest; mobile hosts can register compatible semantic modules without changing the Wallet interface. Use @bsv/sdk 2.4.2 or later and retain THIRD_PARTY_NOTICES.md and LICENSES/ when redistributing. +- Release note: Adds the built-in mobile BRC-177 noSend-expiry signer, remote storage capability negotiation, and default-monitor ownership coordination across restarts and devices. Also carries the mobile Wallet Toolbox internalization and BRC-100 compatibility fixes, earlier Open BSV grant notices, and current BSV Association author metadata. +- Migration: Existing mobile actions require no migration. To use BRC-177, migrate and upgrade the active remote storage service and its default monitor to Wallet Toolbox 2.11.0 or later before upgrading clients; an older server is rejected before prefunding. React Native exports, wire types, and canonical AtomicBEEF behavior are unchanged; use @bsv/sdk 2.4.2 or later and retain THIRD_PARTY_NOTICES.md and LICENSES/. | Public subpath | Runtime target(s) | Declaration target(s) | | ---------------- | -------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------- | diff --git a/governance/dependency-release-policy.json b/governance/dependency-release-policy.json index 303673d96..5ebebc46a 100644 --- a/governance/dependency-release-policy.json +++ b/governance/dependency-release-policy.json @@ -49,7 +49,7 @@ "classification": "toolchain-bridge", "rationale": "TypeScript 7 owns every native CLI build while compiler-API consumers use the official TypeScript 6 compatibility package until a stable TypeScript 7 API exists.", "owner": "ts-stack-maintainers", - "reviewBy": "2026-08-27", + "reviewBy": "2026-09-30", "removeWhen": "All compiler-API consumers support the native TypeScript 7 API and the complete declaration, Jest, conformance, browser, mobile, infrastructure, and packed-consumer matrix passes without the alias." }, { @@ -128,7 +128,7 @@ "classification": "coordinated-major-hold", "rationale": "The isolated OpenAPI code generator is pinned to its tested compiler API and generated-output reproducibility boundary.", "owner": "ts-stack-maintainers", - "reviewBy": "2026-08-25", + "reviewBy": "2026-09-30", "removeWhen": "The generator and Redocly closure support the new compiler and deterministic codegen remains byte-for-byte clean." } ] diff --git a/governance/package-release-notes.json b/governance/package-release-notes.json index ed390b47a..e7017ebe2 100644 --- a/governance/package-release-notes.json +++ b/governance/package-release-notes.json @@ -217,22 +217,22 @@ "name": "@bsv/wallet-toolbox", "publishedVersion": "2.10.4", "releaseType": "minor", - "summary": "Adds the optional semantic handleRequest hook to BRC-98/99/111 permission modules while retaining the existing transformation hooks, BRC-95/BRC-100 compatibility, and stable bounded pagination. It also removes the obsolete JSight application bundle and preserves the package's earlier Open BSV grant in the distribution notice archive.", - "migration": "Existing permission modules require no changes because onRequest and onResponse remain supported. Semantic modules may add handleRequest; hosts installing @bsv/ecpm-permission-module register it under the ecpm scheme. Upgrade to @bsv/sdk 2.4.2 or later, use docs/storage.md instead of the removed JSight export, and retain THIRD_PARTY_NOTICES.md and LICENSES/ when redistributing." + "summary": "Adds the built-in BRC-177 noSend-expiry reference implementation with exact prefunding, durable pre-signed reclaim, atomic active-storage monitoring, cross-device lifecycle synchronization, and proof-finalized race handling, plus the optional semantic handleRequest hook for BRC-98/99/111 permission modules. Retains the existing transformation hooks, BRC-95/BRC-100 compatibility, and stable bounded pagination, removes the obsolete JSight application bundle, and preserves the package's earlier Open BSV grant in the distribution notice archive.", + "migration": "Existing actions, ordinary noSend calls, and permission modules require no migration. To use BRC-177, migrate every active Knex store before serving requests and run the default Wallet Toolbox monitor; IndexedDB upgrades automatically to schema version 5. Upgrade signer, active storage service, and remote monitor together to 2.11.0 or later. Older remote storage is rejected before prefunding. Semantic modules may add handleRequest; hosts installing @bsv/ecpm-permission-module register it under the ecpm scheme. Canonical AtomicBEEF and number-array behavior are unchanged; use @bsv/sdk 2.4.2 or later, use docs/storage.md instead of the removed JSight export, and retain THIRD_PARTY_NOTICES.md and LICENSES/." }, { "name": "@bsv/wallet-toolbox-client", "publishedVersion": "2.10.4", "releaseType": "minor", - "summary": "Exports the optional semantic handleRequest permission-module hook for browser and ESM wallet hosts while retaining transformation modules, BRC-100 wire compatibility, stable IndexedDB totals, and the current browser Wallet Toolbox compatibility fixes. It preserves earlier Open BSV grants in the distribution notice archive.", - "migration": "Existing permission modules require no changes. Semantic modules may add handleRequest; installing @bsv/ecpm-permission-module requires registration under the ecpm scheme. Use @bsv/sdk 2.4.2 or later and retain THIRD_PARTY_NOTICES.md and LICENSES/ when redistributing." + "summary": "Adds the built-in browser BRC-177 noSend-expiry signer, IndexedDB schema version 5 lifecycle state, remote storage capability negotiation, and default monitor coordination, plus the optional semantic handleRequest permission-module hook for browser and ESM wallet hosts. Retains transformation modules, BRC-100 wire compatibility, stable IndexedDB totals, current browser Wallet Toolbox compatibility fixes, and earlier Open BSV grants.", + "migration": "Existing browser actions and permission modules require no migration; IndexedDB upgrades automatically. To use BRC-177 with remote storage, upgrade the active storage service and its default monitor to Wallet Toolbox 2.11.0 or later before upgrading clients; an older server is rejected before prefunding. Semantic modules may add handleRequest; installing @bsv/ecpm-permission-module requires registration under the ecpm scheme. Browser exports, wire types, canonical AtomicBEEF behavior, and pagination contracts are unchanged; use @bsv/sdk 2.4.2 or later and retain THIRD_PARTY_NOTICES.md and LICENSES/." }, { "name": "@bsv/wallet-toolbox-mobile", "publishedVersion": "2.10.4", "releaseType": "minor", - "summary": "Exports the optional semantic handleRequest permission-module hook for React Native wallet hosts while retaining transformation modules, BRC-100 wire compatibility, and the current mobile Wallet Toolbox compatibility fixes. It preserves earlier Open BSV grants in the distribution notice archive.", - "migration": "Existing permission modules require no changes. Semantic modules may add handleRequest; mobile hosts can register compatible semantic modules without changing the Wallet interface. Use @bsv/sdk 2.4.2 or later and retain THIRD_PARTY_NOTICES.md and LICENSES/ when redistributing." + "summary": "Adds the built-in mobile BRC-177 noSend-expiry signer, remote storage capability negotiation, and default-monitor ownership coordination across restarts and devices, plus the optional semantic handleRequest permission-module hook for React Native wallet hosts. Retains transformation modules, BRC-100 wire compatibility, current mobile Wallet Toolbox compatibility fixes, and earlier Open BSV grants.", + "migration": "Existing mobile actions and permission modules require no migration. To use BRC-177, migrate and upgrade the active remote storage service and its default monitor to Wallet Toolbox 2.11.0 or later before upgrading clients; an older server is rejected before prefunding. Semantic modules may add handleRequest without changing the Wallet interface. React Native exports, wire types, and canonical AtomicBEEF behavior are unchanged; use @bsv/sdk 2.4.2 or later and retain THIRD_PARTY_NOTICES.md and LICENSES/." }, { "name": "create-bsv-app", diff --git a/governance/repository-health/exceptions.json b/governance/repository-health/exceptions.json index 4004d8d19..d1e09124d 100644 --- a/governance/repository-health/exceptions.json +++ b/governance/repository-health/exceptions.json @@ -49,7 +49,7 @@ ".github/dependabot.yml" ], "created": "2026-07-27", - "reviewBy": "2026-09-27", + "reviewBy": "2026-09-30", "removeWhen": "Remove after TypeScript exposes a stable native API, every compiler-API consumer supports it without an override, and the full build, typecheck, declaration, packed-consumer, Jest, conformance, browser/mobile, and infrastructure matrix passes without @typescript/typescript6." }, { @@ -140,7 +140,7 @@ "https://github.com/bsv-blockchain/ts-stack/issues/324" ], "created": "2026-07-27", - "reviewBy": "2026-09-27", + "reviewBy": "2026-09-30", "removeWhen": "Remove when Stryker no longer depends on typed-rest-client 2.3.1 or a supported typed-rest-client release natively depends on qs 6.15.2 or newer, then regenerate the lock and rerun the complete mutation campaign." }, { diff --git a/packages/wallet/wallet-toolbox/CHANGELOG.md b/packages/wallet/wallet-toolbox/CHANGELOG.md index 74e3fce63..656e1bee1 100644 --- a/packages/wallet/wallet-toolbox/CHANGELOG.md +++ b/packages/wallet/wallet-toolbox/CHANGELOG.md @@ -13,6 +13,25 @@ attention to changes that materially alter behavior or extend functionality. The companion `@bsv/ecpm-permission-module` uses this hook to implement `p ecpm` point multiplication without adding a BRC-100 method or wire call. +- Add the built-in BRC-177 `p nosend expiry` module for seconds, Unix timestamp, + and block-height deadlines. Protected actions are prefunded through an + accepted transaction, contain no wallet change, and retain a pre-signed + reclaim across restarts, synchronized storage, devices, and keyless remote + monitors. Atomic lifecycle transitions, active-storage ownership, + fail-closed status checks, quarantined race outputs, and locally validated + proof finality prevent duplicate reclaim activation and unsafe state + regression. Wallet Permissions Manager authorizes module use and spending + before prefunding, attributes the funding fee to the requesting originator, + and rechecks the current monthly ledger before releasing the protected + action. Existing actions and ordinary `noSend` calls are unchanged. The + macOS reference fixtures measure 1,658,802 raw / 388,052 gzip / 304,787 + Brotli bytes with Vite, 1,294,883 raw / 354,950 gzip / 284,733 Brotli bytes + with esbuild, and 3,465,269 raw / 1,404,047 gzip / 1,088,637 Brotli bytes as + optimized Hermes bytecode. The reviewed ceilings advance to 1,660,000 / + 390,000 / 307,000 Vite bytes, 1,296,000 / 357,000 / 287,000 esbuild bytes, + and 3,470,000 / 1,406,000 / 1,090,000 Hermes bytes; Metro remains within its + existing ceilings at 1,707,158 raw / 429,877 gzip / 334,010 Brotli bytes. + - Report `listOutputs` `totalOutputs` as the size of the whole result set on every page, in both the IndexedDB and Knex storage providers. A short final page previously returned only that page's length, so a client paging a large diff --git a/packages/wallet/wallet-toolbox/README.md b/packages/wallet/wallet-toolbox/README.md index 839bcea80..01fd2c463 100644 --- a/packages/wallet/wallet-toolbox/README.md +++ b/packages/wallet/wallet-toolbox/README.md @@ -257,6 +257,11 @@ See [In-memory action batch planning](./docs/action-batch-planning.md) for capability-negotiated `noSend` planning, compact manifests, compressed binary pack transport, atomic commit, compatibility behavior, and retained benchmarks. +See [Expiring `noSend` actions](./docs/no-send-expiry.md) for the built-in +BRC-111 `p nosend expiry` module, exact label forms, prefunding, durable +Node/browser/mobile monitoring, storage coordination, and proof-based race +resolution. + ### `createAction` performance telemetry Wallet Storage treats `inputBEEF` as proof data for the inputs declared in the diff --git a/packages/wallet/wallet-toolbox/client/README.md b/packages/wallet/wallet-toolbox/client/README.md index 2625ffb5a..d4a791891 100644 --- a/packages/wallet/wallet-toolbox/client/README.md +++ b/packages/wallet/wallet-toolbox/client/README.md @@ -83,6 +83,12 @@ IndexedDB `listOutputs` results keep `totalOutputs` equal to the full matching count across every page, including a short final page or an offset at or past the end. +The browser wallet includes the built-in BRC-177 `p nosend expiry` module. +Embedded IndexedDB wallets use the default local monitor. When remote storage +is active, its migrated Wallet Toolbox 2.11-or-newer service and monitor own +expiry enforcement; capability negotiation fails before prefunding against an +older server. See [the full expiry guide](../docs/no-send-expiry.md). + ## What's excluded vs `@bsv/wallet-toolbox` | Excluded | Why | diff --git a/packages/wallet/wallet-toolbox/client/platform-budget.json b/packages/wallet/wallet-toolbox/client/platform-budget.json index b5e15c344..ecf345d3c 100644 --- a/packages/wallet/wallet-toolbox/client/platform-budget.json +++ b/packages/wallet/wallet-toolbox/client/platform-budget.json @@ -2,14 +2,14 @@ "profile": "browser", "maximumBytes": { "vite": { - "raw": 1607500, - "gzip": 379000, - "brotli": 297000 + "raw": 1660000, + "gzip": 390000, + "brotli": 307000 }, "esbuild": { - "raw": 1253000, - "gzip": 345500, - "brotli": 277500 + "raw": 1296000, + "gzip": 357000, + "brotli": 287000 } } } diff --git a/packages/wallet/wallet-toolbox/docs/no-send-expiry.md b/packages/wallet/wallet-toolbox/docs/no-send-expiry.md new file mode 100644 index 000000000..1a10abd07 --- /dev/null +++ b/packages/wallet/wallet-toolbox/docs/no-send-expiry.md @@ -0,0 +1,140 @@ +# Expiring `noSend` actions (BRC-177) + +Wallet Toolbox implements the BRC-111 `nosend` module for wallet-enforced +expiry of BRC-100 `noSend` actions. It is built into the Node, browser, and +mobile Wallet Toolbox distributions; applications do not install a separate +permission module. + +Use exactly one of these action labels: + +```text +p nosend expiry seconds +p nosend expiry timestamp +p nosend expiry blockheight +``` + +Values are canonical unsigned base-10 integers. A relative duration must be +greater than zero. Absolute timestamps and block heights must still be in the +future when the protected action is activated. + +```ts +const offer = await wallet.createAction({ + description: 'Offer valid for five minutes', + labels: ['p nosend expiry seconds 300', 'offer 42'], + outputs: [ + { + satoshis: 1000, + lockingScript: recipientLockingScript, + outputDescription: 'Offer payment' + } + ], + options: { + noSend: true + } +}) +``` + +The caller must set `noSend: true` and must not use `sendWith`, +`noSendChange`, or `returnTXIDOnly`. The wallet applies the same restrictions +when a signable action is completed through `signAction`. + +When label permissions are enabled, Wallet Permissions Manager authorizes use +of the built-in module and obtains a spending preflight before creating its +on-chain funding transaction. Its normal amount-specific spending authorization +still applies to the protected action and always rechecks the current spending +ledger after prefunding. The funding transaction carries the same originator +and calendar-month attribution as the protected action, while accounting only +for its miner fee rather than its internal anchor output. This prevents an +unauthorized application from imposing even the funding transaction's miner +fee or evading monthly limits through repeated prefunding. The reserved labels +cannot be overridden by a custom permission module or asserted through +`internalizeAction`. + +## What the wallet does + +Before returning the protected action, Wallet Toolbox: + +1. calculates the exact wallet funding required by its requested outputs, + explicit inputs, and fee; +2. creates and immediately broadcasts a normal funding transaction containing + a dedicated managed-change output; +3. requires processor acceptance of that funding transaction; +4. creates the protected transaction with that output as its only + automatically selected wallet input and with no wallet change; and +5. signs and durably stores a one-input reclaim transaction to a fresh + `default`-basket output. + +The wallet returns the protected transaction to the caller but never +broadcasts it. The funding transaction may have ordinary change because it is +already on the network; significant wallet change is therefore not held inside +the unbroadcast transaction. + +For `seconds`, activation occurs after prefunding and the absolute deadline is +stored before the action is returned. Restarting the wallet does not restart +the duration. A signable action is already active while it waits for +`signAction`, but an unsigned expiry can release its anchor locally because no +valid anchor signature has been exposed. + +After a signed action expires, the active storage monitor first requires both +an explicit `unknown` target verdict and a conclusive unspent-anchor result. +Service errors or ambiguous status defer action. The monitor then atomically +activates the pre-signed reclaim and retries normal network submission. The +reclaim output remains unavailable for wallet funding until a locally +validated Merkle proof establishes that the reclaim won. A processor rejection +does not release the anchor: the lifecycle remains quarantined for proof +reconciliation because another submission may already have reached the network. +A conclusive spent-anchor verdict is likewise quarantined; if the conflicting +spend later disappears, reclaim resumes only after fresh explicit `unknown` +target and unspent-anchor verdicts. + +Seeing the protected transaction as known or mined permanently stops a new +reclaim and moves it into ordinary proof tracking. If a reclaim was already +submitted when the target appears, the monitor stops further reclaim retries +but retains both transactions for proof tracking. Only a locally validated +proof finalizes either winner. A processor status by itself is never reported +as final. + +`abortAction` cancels an unreleased action locally. For a released action it +durably requests immediate revocation through the same guarded reclaim path; +it does not clear the anchor reservation. An already observed target is +protected and returns `aborted: false`. + +## Storage, monitors, and upgrades + +Expiry metadata, the signed reclaim, and lifecycle state are synchronized with +the action. State merging is monotonic, so a backup with a newer wall clock +cannot revive an older lifecycle state. Only the provider named by the user's +synchronized `activeStorage` value may activate a reclaim, and compare-and-set +updates ensure that concurrent monitor processes intentionally create one +reclaim record. Synchronized reclaim outputs remain quarantined unless the +local lifecycle has proven the reclaim winner, even when transaction and output +updates arrive from different devices. + +The default Wallet Toolbox monitor includes the expiry task. A remote active +storage service owns monitoring; browser and mobile clients do not compete +with it. Operators must migrate the active storage database and run the normal +default monitor before accepting BRC-177 actions. The capability handshake +rejects an older storage server before the wallet creates the funding +transaction. + +Knex storage gains nullable transaction lifecycle columns plus expiry and +reclaim-transaction indexes. IndexedDB schema version 5 adds the corresponding +state and reclaim-transaction indexes. Existing actions and ordinary `noSend` +behavior are unchanged; no data rewrite is required. + +Funding and reclaim network fees are paid by the wallet owner. Wallet Toolbox +reserves reclaim fees at the greater of its configured fee rate or 1,000 +satoshis per kilobyte and rejects an anchor that would not leave an economic +reclaim output. + +## Application responsibility + +Action labels are wallet metadata and are not committed into the transaction +or automatically delivered in BEEF. An application that gives the transaction +to a recipient must communicate the deadline separately. If the deadline must +be authenticated, bind it to the transaction or anchor outpoint in the +application protocol. + +Broadcast with enough margin for the wallet's configured status services to +observe acceptance. Expiry starts a double-spend reclaim; consensus finality +comes only from the transaction that is mined and proven. diff --git a/packages/wallet/wallet-toolbox/mobile/README.md b/packages/wallet/wallet-toolbox/mobile/README.md index 76e534602..0bbc1205e 100644 --- a/packages/wallet/wallet-toolbox/mobile/README.md +++ b/packages/wallet/wallet-toolbox/mobile/README.md @@ -56,6 +56,12 @@ const { tx } = await wallet.createAction({ }) ``` +The mobile wallet includes the built-in BRC-177 `p nosend expiry` module. Its +active remote storage must run a migrated Wallet Toolbox 2.11-or-newer service +and default monitor, which owns expiry enforcement across restarts and devices. +Capability negotiation fails before prefunding against an older server. See +[the full expiry guide](../docs/no-send-expiry.md). + ## Use cases ### Self-custody BSV wallet on a phone diff --git a/packages/wallet/wallet-toolbox/mobile/platform-budget.json b/packages/wallet/wallet-toolbox/mobile/platform-budget.json index 7ecfab0dd..a4068eda1 100644 --- a/packages/wallet/wallet-toolbox/mobile/platform-budget.json +++ b/packages/wallet/wallet-toolbox/mobile/platform-budget.json @@ -7,9 +7,9 @@ "brotli": 360000 }, "hermes": { - "raw": 3367500, - "gzip": 1366000, - "brotli": 1070000 + "raw": 3470000, + "gzip": 1406000, + "brotli": 1090000 } } } diff --git a/packages/wallet/wallet-toolbox/src/Wallet.ts b/packages/wallet/wallet-toolbox/src/Wallet.ts index c95df37f8..e5ffb55ac 100644 --- a/packages/wallet/wallet-toolbox/src/Wallet.ts +++ b/packages/wallet/wallet-toolbox/src/Wallet.ts @@ -81,6 +81,8 @@ import { internalizeAction } from './signer/methods/internalizeAction' import { WalletSettingsManager } from './WalletSettingsManager' import { queryOverlay, transformVerifiableCertificatesWithTrust } from './utility/identityUtils' import { maxPossibleSatoshis } from './storage/methods/generateChange' +import { hasBrc177NoSendExpiryLabel, parseBrc177NoSendExpiryLabels } from './utility/brc177NoSendExpiry' +import { createNoSendExpiryAction } from './signer/methods/createNoSendExpiryAction' import { WalletStorageManager } from './storage/WalletStorageManager' import { Monitor } from './monitor/Monitor' import { WalletSigner } from './signer/WalletSigner' @@ -993,7 +995,11 @@ export class Wallet implements WalletInterface, ProtoWallet { vargs.randomVals = [...this.randomVals] } - const r = await createAction(this, auth, vargs) + const brc177Requested = hasBrc177NoSendExpiryLabel(vargs.labels) + if (brc177Requested) parseBrc177NoSendExpiryLabels(vargs.labels) + const r = brc177Requested + ? await createNoSendExpiryAction(this, auth, vargs) + : await createAction(this, auth, vargs) logger?.log('action created') const resultBeef = getResultBeef(r) @@ -1075,6 +1081,12 @@ export class Wallet implements WalletInterface, ProtoWallet { const { auth, vargs } = this.validateAuthAndArgs(args, Validation.validateInternalizeActionArgs) if (vargs.labels.includes(specOpThrowReviewActions)) throwDummyReviewActions() + if (hasBrc177NoSendExpiryLabel(vargs.labels)) { + throw new WERR_INVALID_PARAMETER( + 'labels', + 'BRC-177 noSend expiry labels only on outgoing createAction requests' + ) + } const r = await internalizeAction(this, auth, args) diff --git a/packages/wallet/wallet-toolbox/src/WalletPermissionsManager.ts b/packages/wallet/wallet-toolbox/src/WalletPermissionsManager.ts index 75cf9a590..34130ec82 100644 --- a/packages/wallet/wallet-toolbox/src/WalletPermissionsManager.ts +++ b/packages/wallet/wallet-toolbox/src/WalletPermissionsManager.ts @@ -28,6 +28,47 @@ import { } from '@bsv/sdk' import { parseBrc114ActionTimeLabels } from './utility/brc114ActionTimeLabels' +import { parseBrc177NoSendExpiryLabels } from './utility/brc177NoSendExpiry' + +function brc177PreflightSatoshis(args: object): number { + const outputs = (args as { outputs?: Array<{ satoshis?: unknown }> }).outputs ?? [] + let total = 0 + for (const output of outputs) { + if (typeof output.satoshis !== 'number' || !Number.isSafeInteger(output.satoshis) || output.satoshis < 0) { + throw new Error('BRC-177 outputs must contain valid satoshi amounts') + } + total += output.satoshis + if (!Number.isSafeInteger(total)) throw new Error('BRC-177 output amount exceeds the safely supported range') + } + // Even an action funded entirely by caller-supplied inputs incurs a wallet + // prefunding fee. Require a spending grant before that fee can be broadcast. + return Math.max(1, total) +} + +function validateBrc177CreateActionShape(args: object): void { + const request = args as { + outputs?: unknown[] + options?: { + noSend?: boolean + sendWith?: unknown[] + noSendChange?: unknown[] + returnTXIDOnly?: boolean + } + } + if (!Array.isArray(request.outputs) || request.outputs.length === 0) { + throw new Error('BRC-177 protected actions require at least one output') + } + if (request.options?.noSend !== true) throw new Error('BRC-177 protected actions require noSend') + if ((request.options.sendWith?.length ?? 0) > 0) { + throw new Error('BRC-177 protected actions cannot use sendWith') + } + if ((request.options.noSendChange?.length ?? 0) > 0) { + throw new Error('BRC-177 protected actions cannot supply noSendChange') + } + if (request.options.returnTXIDOnly === true) { + throw new Error('BRC-177 protected actions cannot use returnTXIDOnly') + } +} // Security invariant: only the configured admin originator bypasses permission // prompts. Admin-reserved protocols, baskets, and labels are rejected for all @@ -643,7 +684,40 @@ export class WalletPermissionsManager implements WalletInterface { seekGroupedPermission: true, differentiatePrivilegedOperations: true, whitelistedCounterparties: {}, - ...config // override with user-specified config + ...config, + permissionModules: { + ...config.permissionModules, + // BRC-177 is a built-in reserved BRC-111 module in every Toolbox wallet. + nosend: { + onRequest: async req => { + const labels = (req.args as { labels?: string[] }).labels + parseBrc177NoSendExpiryLabels(labels) + if (req.method !== 'createAction' && req.method !== 'listActions') { + throw new Error('BRC-177 noSend expiry labels are only valid for createAction and listActions') + } + if (req.method === 'createAction') validateBrc177CreateActionShape(req.args) + // Prefunding has a real miner fee, so authorize use of this + // module before the underlying wallet can broadcast it. This is + // deliberately separate from the later amount-specific spend + // authorization for the protected transaction. + await this.ensureLabelAccess({ + originator: req.originator, + label: 'BRC-177 noSend expiry', + reason: req.method, + usageType: req.method === 'createAction' ? 'apply' : 'list' + }) + if (req.method === 'createAction') { + await this.ensureSpendingAuthorization({ + originator: req.originator, + satoshis: brc177PreflightSatoshis(req.args), + reason: 'BRC-177 protected action prefunding' + }) + } + return { args: req.args } + }, + onResponse: async res => res + } + } } } @@ -1542,7 +1616,8 @@ export class WalletPermissionsManager implements WalletInterface { satoshis, lineItems, reason, - seekPermission = true + seekPermission = true, + allowRecentGrant = true }: { originator: string satoshis: number @@ -1553,6 +1628,13 @@ export class WalletPermissionsManager implements WalletInterface { }> reason?: string seekPermission?: boolean + /** + * Whether an identical grant from the short-lived permission cache can + * satisfy this check. BRC-177 disables this for its final authorization + * because prefunding has changed the authoritative spending ledger since + * the preflight grant. + */ + allowRecentGrant?: boolean }): Promise { const { normalized: normalizedOriginator, lookupValues } = this.prepareOriginator(originator) originator = normalizedOriginator @@ -1565,7 +1647,7 @@ export class WalletPermissionsManager implements WalletInterface { // Spending keys are amount-scoped. The recent-grant window this adds sits // inside the pre-existing permissionCache window grantPermission already // wrote for spending, so accounting exposure is unchanged. - if (await this.hasRecentOrPendingGrant(cacheKey)) { + if (allowRecentGrant && (await this.hasRecentOrPendingGrant(cacheKey))) { return true } const token = await this.findSpendingToken(originator, lookupValues) @@ -4174,7 +4256,8 @@ export class WalletPermissionsManager implements WalletInterface { originator: originator!, satoshis: netSpent, lineItems, - reason: originalDescription + reason: originalDescription, + allowRecentGrant: parseBrc177NoSendExpiryLabels(args.labels) == null }) } catch (err) { await this.underlying.abortAction({ reference }) diff --git a/packages/wallet/wallet-toolbox/src/__tests/WalletPermissionsManager.pmodules.test.ts b/packages/wallet/wallet-toolbox/src/__tests/WalletPermissionsManager.pmodules.test.ts index 72fc23f87..e3c231b0b 100644 --- a/packages/wallet/wallet-toolbox/src/__tests/WalletPermissionsManager.pmodules.test.ts +++ b/packages/wallet/wallet-toolbox/src/__tests/WalletPermissionsManager.pmodules.test.ts @@ -114,6 +114,226 @@ describe('WalletPermissionsManager - Permission Module Support', () => { expect(testModule.onResponse).toHaveBeenCalledTimes(1) }) + it('enables and reserves the BRC-177 nosend module in every permissions manager', async () => { + const attemptedOverride: PermissionsModule = { + onRequest: jest.fn(async req => req), + onResponse: jest.fn(async res => res) + } + const manager = new WalletPermissionsManager(underlying, 'customToken.domain.com', { + permissionModules: { nosend: attemptedOverride }, + seekSpendingPermissions: false, + seekBasketInsertionPermissions: false, + seekPermissionWhenApplyingActionLabels: false + }) + + await manager.createAction( + { + description: 'BRC-177 protected action', + labels: ['p nosend expiry seconds 30'], + outputs: [ + { + lockingScript: 'abcd', + satoshis: 1000, + outputDescription: 'protected output' + } + ], + options: { noSend: true } + }, + 'app.com' + ) + + expect(attemptedOverride.onRequest).not.toHaveBeenCalled() + expect(attemptedOverride.onResponse).not.toHaveBeenCalled() + expect(underlying.createAction).toHaveBeenCalledTimes(1) + + await expect( + manager.createAction( + { + description: 'Malformed BRC-177 action', + labels: ['p nosend expiry seconds 030'], + outputs: [ + { + lockingScript: 'abcd', + satoshis: 1000, + outputDescription: 'protected output' + } + ], + options: { noSend: true } + }, + 'app.com' + ) + ).rejects.toThrow() + expect(underlying.createAction).toHaveBeenCalledTimes(1) + + await expect( + manager.internalizeAction( + { + tx: [], + description: 'Inbound transaction cannot claim BRC-177 protection', + labels: ['p nosend expiry seconds 30'], + outputs: [] + } as any, + 'app.com' + ) + ).rejects.toThrow('only valid for createAction and listActions') + expect(underlying.internalizeAction).not.toHaveBeenCalled() + }) + + it('authorizes BRC-177 module use and spending before prefunding can reach the underlying wallet', async () => { + const manager = new WalletPermissionsManager(underlying, 'customToken.domain.com', { + seekBasketInsertionPermissions: false + }) + const order: string[] = [] + jest.spyOn(manager, 'ensureLabelAccess').mockImplementationOnce(async args => { + order.push('module-permission') + expect(args).toMatchObject({ + originator: 'app.com', + label: 'BRC-177 noSend expiry', + usageType: 'apply' + }) + return true + }) + jest.spyOn(manager, 'ensureSpendingAuthorization').mockImplementationOnce(async args => { + order.push('spending-preflight') + expect(args).toMatchObject({ + originator: 'app.com', + satoshis: 1000, + reason: 'BRC-177 protected action prefunding' + }) + return true + }) + underlying.createAction.mockImplementationOnce(async () => { + order.push('underlying-create') + return { txid: 'abc123', tx: [] } + }) + + await manager.createAction( + { + description: 'BRC-177 protected action', + labels: ['p nosend expiry seconds 30'], + outputs: [ + { + lockingScript: 'abcd', + satoshis: 1000, + outputDescription: 'protected output' + } + ], + options: { noSend: true } + }, + 'app.com' + ) + + expect(order).toEqual(['module-permission', 'spending-preflight', 'underlying-create']) + }) + + it('does not permit a denied BRC-177 spending preflight to incur a funding fee', async () => { + const manager = new WalletPermissionsManager(underlying, 'customToken.domain.com', { + seekBasketInsertionPermissions: false + }) + jest.spyOn(manager, 'ensureLabelAccess').mockResolvedValueOnce(true) + jest.spyOn(manager, 'ensureSpendingAuthorization').mockRejectedValueOnce(new Error('denied')) + + await expect( + manager.createAction( + { + description: 'BRC-177 protected action', + labels: ['p nosend expiry seconds 30'], + outputs: [ + { + lockingScript: 'abcd', + satoshis: 1000, + outputDescription: 'protected output' + } + ], + options: { noSend: true } + }, + 'app.com' + ) + ).rejects.toThrow('denied') + expect(underlying.createAction).not.toHaveBeenCalled() + }) + + it('rejects malformed BRC-177 action options before requesting permissions', async () => { + const manager = new WalletPermissionsManager(underlying, 'customToken.domain.com') + const labels = jest.spyOn(manager, 'ensureLabelAccess') + const spending = jest.spyOn(manager, 'ensureSpendingAuthorization') + + await expect( + manager.createAction( + { + description: 'Malformed BRC-177 protected action', + labels: ['p nosend expiry seconds 30'], + outputs: [ + { + lockingScript: 'abcd', + satoshis: 1000, + outputDescription: 'protected output' + } + ], + options: { noSend: false } + }, + 'app.com' + ) + ).rejects.toThrow('require noSend') + + expect(labels).not.toHaveBeenCalled() + expect(spending).not.toHaveBeenCalled() + expect(underlying.createAction).not.toHaveBeenCalled() + }) + + it('forces the final BRC-177 authorization to bypass an identical recent grant', async () => { + const manager = new WalletPermissionsManager(underlying, 'customToken.domain.com', { + seekBasketInsertionPermissions: false + }) + jest.spyOn(manager, 'ensureLabelAccess').mockResolvedValue(true) + const spending = jest.spyOn(manager, 'ensureSpendingAuthorization').mockResolvedValue(true) + + await manager.createAction( + { + description: 'BRC-177 protected action', + labels: ['p nosend expiry seconds 30'], + outputs: [ + { + lockingScript: 'abcd', + satoshis: 1000, + outputDescription: 'protected output' + } + ], + options: { noSend: true } + }, + 'app.com' + ) + + expect(spending).toHaveBeenCalledTimes(2) + expect(spending.mock.calls[0][0]).toMatchObject({ + satoshis: 1000, + reason: 'BRC-177 protected action prefunding' + }) + expect(spending.mock.calls[1][0]).toMatchObject({ + satoshis: 1000, + allowRecentGrant: false + }) + }) + + it('consults the spending token when recent-grant reuse is disabled', async () => { + const manager = new WalletPermissionsManager(underlying, 'customToken.domain.com') + const internals = manager as any + jest.spyOn(internals, 'hasRecentOrPendingGrant').mockResolvedValue(true) + const findToken = jest.spyOn(internals, 'findSpendingToken').mockResolvedValue(undefined) + const request = jest.spyOn(internals, 'requestPermissionFlow').mockResolvedValue(true) + + await expect( + manager.ensureSpendingAuthorization({ + originator: 'app.com', + satoshis: 1000, + allowRecentGrant: false + }) + ).resolves.toBe(true) + + expect(findToken).toHaveBeenCalledTimes(1) + expect(request).toHaveBeenCalledTimes(1) + }) + it('should delegate internalizeAction when a P-label is present', async () => { const testModule: PermissionsModule = { onRequest: jest.fn(async req => req), @@ -188,9 +408,10 @@ describe('WalletPermissionsManager - Permission Module Support', () => { const manager = new WalletPermissionsManager(underlying, 'customToken.domain.com', config) const storedConfig = (manager as any).config as PermissionsManagerConfig - expect(Object.keys(storedConfig.permissionModules ?? {})).toHaveLength(2) + expect(Object.keys(storedConfig.permissionModules ?? {})).toHaveLength(3) expect(storedConfig.permissionModules?.scheme1).toBe(module1) expect(storedConfig.permissionModules?.scheme2).toBe(module2) + expect(storedConfig.permissionModules?.nosend).toBeDefined() }) }) diff --git a/packages/wallet/wallet-toolbox/src/mockchain/MockServices.ts b/packages/wallet/wallet-toolbox/src/mockchain/MockServices.ts index 6939b9acf..cfae68408 100644 --- a/packages/wallet/wallet-toolbox/src/mockchain/MockServices.ts +++ b/packages/wallet/wallet-toolbox/src/mockchain/MockServices.ts @@ -113,6 +113,7 @@ export class MockServices implements WalletServices { if (rawTx == null) throw new WERR_INVALID_PARAMETER('rawTx', `present in BEEF for txid: ${txid}`) const tx = BsvTransaction.fromBinary(rawTx) + this.hydrateInputSourcesFromBeef(tx, beef) await this.validateTxInputs(tx) await this.populateMerklePaths(tx) @@ -124,6 +125,22 @@ export class MockServices implements WalletServices { await this.spendInputs(tx, txid) } + /** Rebuild the recursive source graph carried by BEEF before script verification. */ + private hydrateInputSourcesFromBeef(tx: BsvTransaction, beef: Beef, visited = new Set()): void { + const txid = tx.id('hex') + if (visited.has(txid)) return + visited.add(txid) + for (const input of tx.inputs) { + const sourceTxid = inputSourceTxid(input) + if (sourceTxid == null || input.sourceTransaction != null) continue + const sourceRaw = beef.findTxid(sourceTxid)?.rawTx + if (sourceRaw == null) continue + const source = BsvTransaction.fromBinary(sourceRaw) + input.sourceTransaction = source + this.hydrateInputSourcesFromBeef(source, beef, visited) + } + } + private async validateTxInput( input: BsvTransaction['inputs'][number], index: number, @@ -172,6 +189,7 @@ export class MockServices implements WalletServices { for (const input of tx.inputs) { const sourceTransaction = input.sourceTransaction if (sourceTransaction == null) continue + await this.populateMerklePaths(sourceTransaction) if (sourceTransaction.merklePath != null) continue const stxid = sourceTransaction.id('hex') const stx = await this.storage.getTransaction(stxid) diff --git a/packages/wallet/wallet-toolbox/src/monitor/Monitor.ts b/packages/wallet/wallet-toolbox/src/monitor/Monitor.ts index aa0270ad5..45e70860c 100644 --- a/packages/wallet/wallet-toolbox/src/monitor/Monitor.ts +++ b/packages/wallet/wallet-toolbox/src/monitor/Monitor.ts @@ -16,6 +16,7 @@ import { TaskMineBlock } from './tasks/TaskMineBlock' import { TaskSendWaiting } from './tasks/TaskSendWaiting' import { TaskCheckNoSends } from './tasks/TaskCheckNoSends' +import { TaskNoSendExpiry } from './tasks/TaskNoSendExpiry' import { TaskUnFail } from './tasks/TaskUnFail' import { TaskReviewUtxos } from './tasks/TaskReviewUtxos' import { TaskReviewDoubleSpends } from './tasks/TaskReviewDoubleSpends' @@ -225,6 +226,7 @@ export class Monitor { new TaskClock(this), new TaskNewHeader(this), new TaskMonitorCallHistory(this), + new TaskNoSendExpiry(this), new TaskSendWaiting(this), new TaskCheckForProofs(this), new TaskCheckNoSends(this), @@ -252,6 +254,7 @@ export class Monitor { new TaskClock(this), new TaskNewHeader(this), new TaskMonitorCallHistory(this), + new TaskNoSendExpiry(this), new TaskSendWaiting(this, 8 * Monitor.oneSecond, 7 * Monitor.oneSecond), // Check every 8 seconds but must be 7 seconds old new TaskCheckForProofs(this, 2 * Monitor.oneHour), // Every two hours if no block found new TaskCheckNoSends(this), @@ -279,6 +282,7 @@ export class Monitor { new TaskClock(this), new TaskNewHeader(this), new TaskMonitorCallHistory(this), + new TaskNoSendExpiry(this), new TaskSendWaiting(this, 8 * Monitor.oneSecond, 7 * Monitor.oneSecond), // Check every 8 seconds but must be 7 seconds old new TaskCheckForProofs(this, 2 * Monitor.oneHour), // Every two hours if no block found new TaskCheckNoSends(this), @@ -453,6 +457,7 @@ export class Monitor { // TaskCheckNoSends.checkNow flag was designed for this signal // (see TaskCheckNoSends.ts:22-25) but was never wired. TaskCheckNoSends.checkNow = true + TaskNoSendExpiry.checkNow = true } /** diff --git a/packages/wallet/wallet-toolbox/src/monitor/tasks/TaskNoSendExpiry.ts b/packages/wallet/wallet-toolbox/src/monitor/tasks/TaskNoSendExpiry.ts new file mode 100644 index 000000000..db86f9f76 --- /dev/null +++ b/packages/wallet/wallet-toolbox/src/monitor/tasks/TaskNoSendExpiry.ts @@ -0,0 +1,44 @@ +import { processNoSendExpiryLifecycle } from '../../storage/methods/noSendExpiryLifecycle' +import { Monitor } from '../Monitor' +import { WalletMonitorTask } from './WalletMonitorTask' + +/** + * Enforces BRC-177 noSend expiries from the active authoritative storage. + * Reclaim transactions are signed before release, so this task never needs + * access to wallet keys and is safe to run in a remote storage monitor. + */ +export class TaskNoSendExpiry extends WalletMonitorTask { + static readonly taskName = 'NoSendExpiry' + static checkNow = false + + constructor( + monitor: Monitor, + public triggerMsecs = 5 * Monitor.oneSecond + ) { + super(monitor, TaskNoSendExpiry.taskName) + } + + trigger(nowMsecsSinceEpoch: number): { run: boolean } { + return { + run: + TaskNoSendExpiry.checkNow || + (this.triggerMsecs > 0 && nowMsecsSinceEpoch - this.lastRunMsecsSinceEpoch > this.triggerMsecs) + } + } + + async runTask(): Promise { + TaskNoSendExpiry.checkNow = false + // A client whose active store is remote does not own lifecycle execution; + // the remote provider's keyless multi-user monitor does. + if (!this.storage.isActiveStorageProvider()) return '' + const result = await this.storage.runAsStorageProvider(async storage => { + return await processNoSendExpiryLifecycle(storage) + }) + if (result.inspected === 0) return '' + return ( + `BRC-177 inspected=${result.inspected} cancelled=${result.cancelled} observed=${result.observed} ` + + `activated=${result.reclaimActivated} reclaimed=${result.reclaimed} targetWon=${result.targetWon} ` + + `deferred=${result.deferred} errors=${result.errors}\n` + ) + } +} diff --git a/packages/wallet/wallet-toolbox/src/monitor/tasks/index.all.ts b/packages/wallet/wallet-toolbox/src/monitor/tasks/index.all.ts index 4b265ff85..2d5a1a605 100644 --- a/packages/wallet/wallet-toolbox/src/monitor/tasks/index.all.ts +++ b/packages/wallet/wallet-toolbox/src/monitor/tasks/index.all.ts @@ -2,6 +2,7 @@ export * from './WalletMonitorTask' export * from './TaskArcSSE' export * from './TaskCheckForProofs' export * from './TaskCheckNoSends' +export * from './TaskNoSendExpiry' export * from './TaskCleanupActionBatches' export * from './TaskClock' export * from './TaskFailAbandoned' diff --git a/packages/wallet/wallet-toolbox/src/sdk/ActionBatch.interfaces.ts b/packages/wallet/wallet-toolbox/src/sdk/ActionBatch.interfaces.ts index cb2ec5f52..650e6f474 100644 --- a/packages/wallet/wallet-toolbox/src/sdk/ActionBatch.interfaces.ts +++ b/packages/wallet/wallet-toolbox/src/sdk/ActionBatch.interfaces.ts @@ -11,6 +11,10 @@ export type ActionBatchPackEncoding = 'identity' | 'gzip' | 'brotli' /** Internal Wallet Toolbox capabilities. These do not extend the BRC-100 wallet interface. */ export interface StorageCapabilities { + /** Built-in BRC-177 pre-funding, durable expiry, and reclaim support. */ + brc177NoSendExpiry?: { + version: 1 + } actionBatch?: { version: 1 maxInlineBytes: number diff --git a/packages/wallet/wallet-toolbox/src/sdk/WalletStorage.interfaces.ts b/packages/wallet/wallet-toolbox/src/sdk/WalletStorage.interfaces.ts index 1b085e578..30f292326 100644 --- a/packages/wallet/wallet-toolbox/src/sdk/WalletStorage.interfaces.ts +++ b/packages/wallet/wallet-toolbox/src/sdk/WalletStorage.interfaces.ts @@ -55,6 +55,7 @@ import { RenewActionBatchResult, StorageCapabilities } from './ActionBatch.interfaces' +import type { Brc177NoSendExpiry } from '../utility/brc177NoSendExpiry' /** * This is the `WalletStorage` interface implemented by a class such as `WalletStorageManager`, @@ -85,6 +86,9 @@ export interface WalletStorage { abortAction: (args: AbortActionArgs) => Promise createAction: (args: Validation.ValidCreateActionArgs) => Promise processAction: (args: StorageProcessActionArgs) => Promise + prepareNoSendExpiry?: (args: Validation.ValidCreateActionArgs) => Promise + activateNoSendExpiry?: (args: StorageActivateNoSendExpiryArgs) => Promise + armNoSendExpiry?: (args: StorageArmNoSendExpiryArgs) => Promise getCapabilities: () => Promise beginActionBatch: (args: BeginActionBatchArgs) => Promise extendActionBatch: (args: ExtendActionBatchArgs) => Promise @@ -178,6 +182,15 @@ export interface WalletStorageWriter extends WalletStorageReader { abortAction: (auth: AuthId, args: AbortActionArgs) => Promise createAction: (auth: AuthId, args: Validation.ValidCreateActionArgs) => Promise processAction: (auth: AuthId, args: StorageProcessActionArgs) => Promise + prepareNoSendExpiry?: ( + auth: AuthId, + args: Validation.ValidCreateActionArgs + ) => Promise + activateNoSendExpiry?: ( + auth: AuthId, + args: StorageActivateNoSendExpiryArgs + ) => Promise + armNoSendExpiry?: (auth: AuthId, args: StorageArmNoSendExpiryArgs) => Promise getCapabilities: () => Promise beginActionBatch: (auth: AuthId, args: BeginActionBatchArgs) => Promise extendActionBatch: (auth: AuthId, args: ExtendActionBatchArgs) => Promise @@ -297,6 +310,38 @@ export interface StorageCreateActionResult { reference: string } +export interface StoragePrepareNoSendExpiryResult { + funding: StorageCreateActionResult + anchorSatoshis: number + anchorVout: number + reclaimFee: number + reclaimSatoshis: number +} + +export interface StorageActivateNoSendExpiryArgs { + target: Validation.ValidCreateActionArgs + fundingReference: string + fundingTxid: string + anchorVout: number +} + +export interface StorageActivateNoSendExpiryResult { + action: StorageCreateActionResult + expiry: Brc177NoSendExpiry + deadline: number + anchorTxid: string + anchorVout: number +} + +export interface StorageArmNoSendExpiryArgs { + reference: string + reclaimTxid: string + reclaimRawTx: number[] | Uint8Array + reclaimDerivationPrefix: string + reclaimDerivationSuffix: string + reclaimSatoshis: number +} + export interface StorageProcessActionArgs { isNewTx: boolean isSendWith: boolean diff --git a/packages/wallet/wallet-toolbox/src/signer/methods/__tests__/createNoSendExpiryAction.test.ts b/packages/wallet/wallet-toolbox/src/signer/methods/__tests__/createNoSendExpiryAction.test.ts new file mode 100644 index 000000000..3656cad10 --- /dev/null +++ b/packages/wallet/wallet-toolbox/src/signer/methods/__tests__/createNoSendExpiryAction.test.ts @@ -0,0 +1,55 @@ +import { Validation } from '@bsv/sdk' +import { targetForStorage } from '../createNoSendExpiryAction' +import { makeNoSendExpiryFundingArgs } from '../../../storage/methods/noSendExpiry' + +describe('createNoSendExpiryAction storage boundary', () => { + test('keeps unlocking scripts and logger objects on the signer side', () => { + const logger = {} as any + const args = Validation.validateCreateActionArgs( + { + description: 'protected explicit input', + inputBEEF: [], + inputs: [ + { + outpoint: `${'01'.repeat(32)}.0`, + inputDescription: 'explicit protected input', + unlockingScript: 'aabb' + } + ], + outputs: [ + { + lockingScript: '51', + satoshis: 1, + outputDescription: 'protected output' + } + ], + labels: ['p nosend expiry seconds 30'], + options: { noSend: true } + }, + logger + ) + + const stored = targetForStorage(args) + + expect(stored.logger).toBeUndefined() + expect(stored.inputs[0].unlockingScript).toBeUndefined() + expect(stored.inputs[0].unlockingScriptLength).toBe(2) + expect(args.logger).toBe(logger) + expect(args.inputs[0].unlockingScript).toBe('aabb') + + args.options.noSendChange.push({ txid: '02'.repeat(32), vout: 1 }) + expect(stored.options.noSendChange).toEqual([]) + }) + + test('attributes the funding fee without copying application or protected labels', () => { + const funding = makeNoSendExpiryFundingArgs(5001, [ + 'p nosend expiry seconds 30', + 'offer 42', + 'admin originator app.example', + 'admin month 2026-08', + 'admin originator app.example' + ]) + + expect(funding.labels).toEqual(['admin brc177 funding', 'admin originator app.example', 'admin month 2026-08']) + }) +}) diff --git a/packages/wallet/wallet-toolbox/src/signer/methods/createNoSendExpiryAction.ts b/packages/wallet/wallet-toolbox/src/signer/methods/createNoSendExpiryAction.ts new file mode 100644 index 000000000..35d5fc877 --- /dev/null +++ b/packages/wallet/wallet-toolbox/src/signer/methods/createNoSendExpiryAction.ts @@ -0,0 +1,307 @@ +import { Beef, PublicKey, Random, Script, Transaction, Utils, Validation } from '@bsv/sdk' +import { Wallet, PendingSignAction, PendingStorageInput } from '../../Wallet' +import { AuthId, StorageCreateActionResult } from '../../sdk/WalletStorage.interfaces' +import { WERR_INTERNAL, WERR_INVALID_OPERATION, WERR_REVIEW_ACTIONS } from '../../sdk/WERR_errors' +import { ScriptTemplateBRC29, brc29ProtocolID } from '../../utility/ScriptTemplateBRC29' +import { Brc177ValidCreateActionArgs } from '../../utility/brc177NoSendExpiry' +import { buildSignableTransaction } from './buildSignableTransaction' +import { completeSignedTransaction, verifyUnlockScripts } from './completeSignedTransaction' +import { CreateActionResultX, processAction } from './createAction' +import { setResultBeef } from './resultBeef' +import { makeNoSendExpiryFundingArgs } from '../../storage/methods/noSendExpiry' + +function pendingFromPlan( + wallet: Wallet, + args: Validation.ValidCreateActionArgs, + dcr: StorageCreateActionResult +): PendingSignAction { + const { tx, amount, pdi } = buildSignableTransaction(dcr, args, wallet) + return { reference: dcr.reference, dcr, args, tx, amount, pdi } +} + +function beefForPending(prior: PendingSignAction): Beef { + if (prior.dcr.inputBeef == null) throw new WERR_INTERNAL('planned action must include input BEEF') + const beef = + prior.dcr.inputBeef instanceof Uint8Array + ? Beef.fromBinaryView(prior.dcr.inputBeef) + : Beef.fromBinary(prior.dcr.inputBeef) + beef.mergeTransaction(prior.tx) + return beef +} + +async function completeFunding( + wallet: Wallet, + auth: AuthId, + prior: PendingSignAction, + args: Validation.ValidCreateActionArgs +): Promise<{ txid: string; beef: Beef }> { + prior.tx = await completeSignedTransaction(prior, {}, wallet) + const txid = prior.tx.id('hex') + const beef = beefForPending(prior) + await verifyUnlockScripts(txid, beef, wallet.scriptVerifier) + const processed = await processAction(prior, wallet, auth, args) + const failed = processed.sendWithResults?.some(result => result.status !== 'unproven') ?? false + if (failed || processed.notDelayedResults == null) { + throw new WERR_REVIEW_ACTIONS( + processed.notDelayedResults ?? [], + processed.sendWithResults ?? [], + txid, + beef.toBinaryAtomic(txid) + ) + } + return { txid, beef } +} + +function findAnchorInput(prior: PendingSignAction, anchorTxid: string, anchorVout: number): PendingStorageInput { + const input = prior.pdi.find(candidate => { + const txInput = prior.tx.inputs[candidate.vin] + return txInput?.sourceTXID === anchorTxid && txInput.sourceOutputIndex === anchorVout + }) + if (input == null || prior.pdi.length !== 1) { + throw new WERR_INVALID_OPERATION('BRC-177 protected action does not have exactly one managed anchor input') + } + return input +} + +function randomDerivation(): string { + return Utils.toBase64(Random(16)) +} + +export function targetForStorage(args: Validation.ValidCreateActionArgs): Validation.ValidCreateActionArgs { + const target = { + ...args, + inputs: args.inputs.map(input => { + const sanitized = { ...input } + delete sanitized.unlockingScript + return sanitized + }), + outputs: args.outputs.map(output => ({ ...output, tags: [...output.tags] })), + labels: [...args.labels], + options: { + ...args.options, + knownTxids: [...args.options.knownTxids], + noSendChange: args.options.noSendChange.map(outpoint => ({ ...outpoint })), + sendWith: [...args.options.sendWith] + }, + randomVals: args.randomVals == null ? undefined : [...args.randomVals] + } + // Logger instances and externally supplied unlocking scripts are local + // signer concerns. In particular, a nested logger cannot be reconstructed by + // StorageClient's top-level RPC logger handshake. + delete target.logger + return target +} + +async function makeReclaim( + wallet: Wallet, + fundingTx: Transaction, + target: PendingSignAction, + anchorTxid: string, + anchorVout: number, + reclaimSatoshis: number +): Promise<{ + tx: Transaction + derivationPrefix: string + derivationSuffix: string +}> { + const anchor = findAnchorInput(target, anchorTxid, anchorVout) + const keys = wallet.getClientChangeKeyPair() + const derivationPrefix = randomDerivation() + const derivationSuffix = randomDerivation() + const outputTemplate = new ScriptTemplateBRC29({ + derivationPrefix, + derivationSuffix, + keyDeriver: wallet.keyDeriver + }) + const reclaim = new Transaction(1, [], [], 0) + reclaim.addInput({ + sourceTransaction: fundingTx, + sourceOutputIndex: anchorVout, + unlockingScript: new Script(), + sequence: 0xffffffff + }) + reclaim.addOutput({ + satoshis: reclaimSatoshis, + lockingScript: outputTemplate.lock(keys.privateKey, keys.publicKey), + change: true + }) + + const inputTemplate = new ScriptTemplateBRC29({ + derivationPrefix: anchor.derivationPrefix, + derivationSuffix: anchor.derivationSuffix, + keyDeriver: wallet.keyDeriver + }) + const counterparty = PublicKey.fromString(anchor.unlockerPubKey || keys.publicKey) + const privateKey = wallet.keyDeriver.derivePrivateKey(brc29ProtocolID, inputTemplate.getKeyID(), counterparty) + reclaim.inputs[0].unlockingScriptTemplate = inputTemplate.unlockWithDerivedPrivateKey( + privateKey, + anchor.sourceSatoshis, + Script.fromHex(anchor.lockingScript) + ) + await reclaim.sign() + return { tx: reclaim, derivationPrefix, derivationSuffix } +} + +function makeSignableBeef(tx: Transaction): number[] { + const beef = new Beef() + for (const input of tx.inputs) { + if (input.sourceTransaction == null) { + throw new WERR_INTERNAL('Every BRC-177 signable input must have a source transaction') + } + beef.mergeRawTx(input.sourceTransaction.toUint8Array()) + } + beef.mergeRawTx(tx.toUint8Array()) + return beef.toBinaryAtomic(tx.id('hex')) +} + +async function armReclaim( + wallet: Wallet, + target: PendingSignAction, + fundingBeef: Beef, + reclaim: Awaited>, + reclaimSatoshis: number +): Promise { + const reclaimTxid = reclaim.tx.id('hex') + const keys = wallet.getClientChangeKeyPair() + const expectedLock = new ScriptTemplateBRC29({ + derivationPrefix: reclaim.derivationPrefix, + derivationSuffix: reclaim.derivationSuffix, + keyDeriver: wallet.keyDeriver + }).lock(keys.privateKey, keys.publicKey) + if ( + reclaim.tx.inputs.length !== 1 || + reclaim.tx.outputs.length !== 1 || + reclaim.tx.outputs[0].satoshis !== reclaimSatoshis || + reclaim.tx.outputs[0].lockingScript.toHex() !== expectedLock.toHex() + ) { + throw new WERR_INTERNAL('BRC-177 reclaim does not return the anchor to wallet-controlled value') + } + const verificationBeef = fundingBeef.clone() + verificationBeef.mergeTransaction(reclaim.tx) + await verifyUnlockScripts(reclaimTxid, verificationBeef, wallet.scriptVerifier) + await wallet.storage.armNoSendExpiry({ + reference: target.reference, + reclaimTxid, + reclaimRawTx: reclaim.tx.toUint8Array(), + reclaimDerivationPrefix: reclaim.derivationPrefix, + reclaimDerivationSuffix: reclaim.derivationSuffix, + reclaimSatoshis + }) +} + +export async function createNoSendExpiryAction( + wallet: Wallet, + auth: AuthId, + vargs: Validation.ValidCreateActionArgs +): Promise { + const capabilities = await wallet.storage.getCapabilities() + if (capabilities.brc177NoSendExpiry?.version !== 1) { + throw new WERR_INVALID_OPERATION('Active storage does not support BRC-177 noSend expiry') + } + + // The flow crosses an on-chain network round trip. Snapshot every mutable + // request collection before it so caller mutation cannot change what the + // signer later builds relative to the plan already committed by storage. + const targetSnapshot = { + ...vargs, + inputBEEF: vargs.inputBEEF == null ? undefined : Array.from(vargs.inputBEEF), + inputs: vargs.inputs.map(input => ({ ...input })), + outputs: vargs.outputs.map(output => ({ ...output, tags: [...output.tags] })), + labels: [...vargs.labels], + options: { + ...vargs.options, + knownTxids: [...vargs.options.knownTxids], + noSendChange: vargs.options.noSendChange.map(outpoint => ({ ...outpoint })), + sendWith: [...vargs.options.sendWith] + }, + randomVals: vargs.randomVals == null ? undefined : [...vargs.randomVals] + } + const storageTarget = targetForStorage(targetSnapshot) + const prepared = await wallet.storage.prepareNoSendExpiry(storageTarget) + const fundingArgs = makeNoSendExpiryFundingArgs(prepared.anchorSatoshis, targetSnapshot.labels) + fundingArgs.includeAllSourceTransactions = targetSnapshot.includeAllSourceTransactions + let funding: PendingSignAction + let completedFunding: Awaited> + try { + funding = pendingFromPlan(wallet, fundingArgs, prepared.funding) + completedFunding = await completeFunding(wallet, auth, funding, fundingArgs) + } catch (error) { + await wallet.storage.abortAction({ reference: prepared.funding.reference }).catch(() => undefined) + throw error + } + + const activated = await wallet.storage.activateNoSendExpiry({ + target: storageTarget, + fundingReference: funding.reference, + fundingTxid: completedFunding.txid, + anchorVout: prepared.anchorVout + }) + const targetArgs: Brc177ValidCreateActionArgs = { + ...targetSnapshot, + options: { + ...targetSnapshot.options, + noSendChange: [{ txid: activated.anchorTxid, vout: activated.anchorVout }], + sendWith: [...targetSnapshot.options.sendWith] + }, + brc177: { + kind: 'protected', + expiry: activated.expiry, + deadline: activated.deadline, + anchorTxid: activated.anchorTxid, + anchorVout: activated.anchorVout + } + } + let target: PendingSignAction + + try { + target = pendingFromPlan(wallet, targetArgs, activated.action) + const reclaim = await makeReclaim( + wallet, + funding.tx, + target, + activated.anchorTxid, + activated.anchorVout, + prepared.reclaimSatoshis + ) + await armReclaim(wallet, target, completedFunding.beef, reclaim, prepared.reclaimSatoshis) + } catch (error) { + await wallet.storage.abortAction({ reference: activated.action.reference }).catch(() => undefined) + throw error + } + + if (targetArgs.isSignAction) { + try { + const tx = makeSignableBeef(target.tx) + wallet.pendingSignActions[target.reference] = target + return { + signableTransaction: { + reference: target.reference, + tx + } + } + } catch (error) { + await wallet.storage.abortAction({ reference: target.reference }).catch(() => undefined) + throw error + } + } + + try { + target.tx = await completeSignedTransaction(target, {}, wallet) + const txid = target.tx.id('hex') + const beef = beefForPending(target) + await verifyUnlockScripts(txid, beef, wallet.scriptVerifier) + const processed = await processAction(target, wallet, auth, targetArgs) + beef.atomicTxid = txid + const result: CreateActionResultX = { + txid, + tx: beef.toBinaryAtomic(txid), + sendWithResults: processed.sendWithResults, + notDelayedResults: processed.notDelayedResults + } + setResultBeef(result, beef) + return result + } catch (error) { + await wallet.storage.abortAction({ reference: target.reference }).catch(() => undefined) + throw error + } +} diff --git a/packages/wallet/wallet-toolbox/src/signer/methods/signAction.ts b/packages/wallet/wallet-toolbox/src/signer/methods/signAction.ts index 593871a6c..a761def75 100644 --- a/packages/wallet/wallet-toolbox/src/signer/methods/signAction.ts +++ b/packages/wallet/wallet-toolbox/src/signer/methods/signAction.ts @@ -12,8 +12,9 @@ import { processAction } from './createAction' import { AuthId, ReviewActionResult } from '../../sdk/WalletStorage.interfaces' import { completeSignedTransaction, verifyUnlockScripts } from './completeSignedTransaction' import { Wallet } from '../../Wallet' -import { WERR_INTERNAL, WERR_NOT_IMPLEMENTED } from '../../sdk/WERR_errors' +import { WERR_INTERNAL, WERR_INVALID_PARAMETER, WERR_NOT_IMPLEMENTED } from '../../sdk/WERR_errors' import { setResultBeef } from './resultBeef' +import type { Brc177ValidCreateActionArgs } from '../../utility/brc177NoSendExpiry' export interface SignActionResultX extends SignActionResult { txid?: TXIDHexString @@ -124,5 +125,14 @@ function mergePriorOptions( saOptions.returnTXIDOnly ??= caVargs.options.returnTXIDOnly saOptions.noSend ??= caVargs.options.noSend saOptions.sendWith ??= caVargs.options.sendWith + if ((caVargs as Brc177ValidCreateActionArgs).brc177?.kind === 'protected') { + if (saOptions.sendWith.length > 0) { + throw new WERR_INVALID_PARAMETER('options.sendWith', 'empty for a BRC-177 protected action') + } + if (saOptions.returnTXIDOnly) { + throw new WERR_INVALID_PARAMETER('options.returnTXIDOnly', 'false for a BRC-177 protected action') + } + saOptions.noSend = true + } return Validation.validateSignActionArgs(saArgs) } diff --git a/packages/wallet/wallet-toolbox/src/storage/StorageIdb.ts b/packages/wallet/wallet-toolbox/src/storage/StorageIdb.ts index 189c2ae40..284ca0efd 100644 --- a/packages/wallet/wallet-toolbox/src/storage/StorageIdb.ts +++ b/packages/wallet/wallet-toolbox/src/storage/StorageIdb.ts @@ -78,6 +78,7 @@ import { DEFAULT_MANAGED_CHANGE_MINIMUM_SATOSHIS, isLegacyManagedChangeBasketDefault } from './methods/managedChangePolicy' +import type { Brc177NoSendExpiryState } from '../utility/brc177NoSendExpiry' export interface StorageIdbOptions extends StorageProviderOptions {} @@ -136,6 +137,10 @@ export class StorageIdb extends StorageProvider implements WalletStorageProvider return true } + protected override supportsNoSendExpiryPersistence(): boolean { + return true + } + protected override requiresActionBatchCleanupBeforeCreateAction(): boolean { return false } @@ -213,10 +218,17 @@ export class StorageIdb extends StorageProvider implements WalletStorageProvider async initDB(storageName?: string, storageIdentityKey?: string): Promise> { const chain = this.chain const maxOutputScript = 1024 - const db = await openDB(this.dbName, 4, { + const db = await openDB(this.dbName, 5, { upgrade(db, _oldVersion, _newVersion, transaction) { upgradeAllStoresV1(db) upgradeActionBatchStoresV2(db) + const transactions = transaction.objectStore('transactions') + if (!transactions.indexNames.contains('noSendExpiryState')) { + transactions.createIndex('noSendExpiryState', 'noSendExpiryState') + } + if (!transactions.indexNames.contains('noSendExpiryReclaimTxid')) { + transactions.createIndex('noSendExpiryReclaimTxid', 'noSendExpiryReclaimTxid') + } const outputs = transaction.objectStore('outputs') if (!outputs.indexNames.contains('userId_basketId')) { outputs.createIndex('userId_basketId', ['userId', 'basketId']) @@ -1208,6 +1220,28 @@ export class StorageIdb extends StorageProvider implements WalletStorageProvider return await this.updateIdb(id, update, 'transactionId', 'transactions', trx) } + override async compareAndSetNoSendExpiryState( + transactionId: number, + expected: Brc177NoSendExpiryState, + next: Brc177NoSendExpiryState, + trx?: TrxToken + ): Promise { + const dbTrx = this.toDbTrx(['transactions'], 'readwrite', trx) + const store = dbTrx.objectStore('transactions') + try { + const transaction = await store.get(transactionId) + if (transaction == null || transaction.noSendExpiryState !== expected) return false + await (store.put as (value: TableTransaction) => Promise)({ + ...transaction, + noSendExpiryState: next, + updated_at: new Date() + }) + return true + } finally { + if (trx == null) await dbTrx.done + } + } + async updateTxLabel(id: number, update: Partial, trx?: TrxToken): Promise { return await this.updateIdb(id, update, 'txLabelId', 'tx_labels', trx) } @@ -1907,6 +1941,12 @@ export class StorageIdb extends StorageProvider implements WalletStorageProvider if (partial?.status !== undefined) return store.index('status').openCursor(partial.status, direction) if (partial?.provenTxId !== undefined) return store.index('provenTxId').openCursor(partial.provenTxId, direction) if (partial?.reference !== undefined) return store.index('reference').openCursor(partial.reference, direction) + if (partial?.noSendExpiryReclaimTxid !== undefined) { + return store.index('noSendExpiryReclaimTxid').openCursor(partial.noSendExpiryReclaimTxid, direction) + } + if (partial?.noSendExpiryState !== undefined) { + return store.index('noSendExpiryState').openCursor(partial.noSendExpiryState, direction) + } return store.openCursor(null, direction) } @@ -1994,6 +2034,7 @@ export class StorageIdb extends StorageProvider implements WalletStorageProvider if (args.noRawTx === true) { t.rawTx = undefined t.inputBEEF = undefined + t.noSendExpiryReclaimRawTx = undefined } else { await this.validateRawTransaction(t, args.trx) } diff --git a/packages/wallet/wallet-toolbox/src/storage/StorageKnex.ts b/packages/wallet/wallet-toolbox/src/storage/StorageKnex.ts index cab11752d..4ea7d406f 100644 --- a/packages/wallet/wallet-toolbox/src/storage/StorageKnex.ts +++ b/packages/wallet/wallet-toolbox/src/storage/StorageKnex.ts @@ -62,6 +62,7 @@ import { verifyId, verifyOne, verifyOneOrNone } from '../utility/utilityHelpers' import { EntityTimeStamp, TransactionStatus } from '../sdk/types' import { managedChangeOutputFields } from './methods/managedChange' import type { ManagedChangeInputCandidate } from './methods/availableManagedChange' +import type { Brc177NoSendExpiryState } from '../utility/brc177NoSendExpiry' export interface StorageKnexOptions extends StorageProviderOptions { /** @@ -132,6 +133,7 @@ export class StorageKnex extends StorageProvider implements WalletStorageProvide } protected override supportsActionBatchPersistence (): boolean { return true } + protected override supportsNoSendExpiryPersistence (): boolean { return true } protected override requiresActionBatchCleanupBeforeCreateAction (): boolean { return false } async readSettings (trx?: TrxToken): Promise { @@ -750,6 +752,19 @@ export class StorageKnex extends StorageProvider implements WalletStorageProvide return r } + override async compareAndSetNoSendExpiryState ( + transactionId: number, + expected: Brc177NoSendExpiryState, + next: Brc177NoSendExpiryState, + trx?: TrxToken + ): Promise { + await this.verifyReadyForDatabaseAccess(trx) + const updated = await this.toDb(trx)('transactions') + .where({ transactionId, noSendExpiryState: expected }) + .update(this.validatePartialForUpdate({ noSendExpiryState: next } as Partial)) + return updated === 1 + } + override async updateTxLabelMap ( transactionId: number, txLabelId: number, diff --git a/packages/wallet/wallet-toolbox/src/storage/StorageProvider.ts b/packages/wallet/wallet-toolbox/src/storage/StorageProvider.ts index 7be9cd45c..58a2992ae 100644 --- a/packages/wallet/wallet-toolbox/src/storage/StorageProvider.ts +++ b/packages/wallet/wallet-toolbox/src/storage/StorageProvider.ts @@ -48,6 +48,10 @@ import { StorageInternalizeActionResult, StorageProcessActionArgs, StorageProcessActionResults, + StoragePrepareNoSendExpiryResult, + StorageActivateNoSendExpiryArgs, + StorageActivateNoSendExpiryResult, + StorageArmNoSendExpiryArgs, StorageProvenOrReq, SyncChunk, TrxToken, @@ -71,6 +75,7 @@ import { WERR_INVALID_OPERATION, WERR_INVALID_PARAMETER, WERR_MISSING_PARAMETER, + WERR_NOT_ACTIVE, WERR_NOT_IMPLEMENTED, WERR_UNAUTHORIZED } from '../sdk/WERR_errors' @@ -79,6 +84,7 @@ import { WalletError } from '../sdk/WalletError' import { asArray } from '../utility/utilityHelpers.noBuffer' import { TableActionBatch, TableActionBatchBlob, TableActionBatchOutput } from './schema/tables/TableActionBatch' import { classifyOutputUtxo, requireConclusiveUtxo } from '../services/classifyOutputUtxo' +import { processNoSendExpiryLifecycle } from './methods/noSendExpiryLifecycle' import { AbortActionBatchResult, ActionBatchManifest, @@ -119,6 +125,11 @@ import { defaultManagedChangePolicy, validateManagedChangePolicy } from './methods/managedChangePolicy' +import { + activateNoSendExpiry, + armNoSendExpiry, + prepareNoSendExpiry +} from './methods/noSendExpiry' export abstract class StorageProvider extends StorageReaderWriter implements WalletStorageProvider { isDirty = false @@ -387,9 +398,48 @@ export abstract class StorageProvider extends StorageReaderWriter implements Wal } async getCapabilities(): Promise { - return this.supportsActionBatchPersistence() - ? getActionBatchCapabilities(this.actionBatchMaxReservedOutputs, true) - : {} + return { + ...(this.supportsNoSendExpiryPersistence() + ? { brc177NoSendExpiry: { version: 1 as const } } + : {}), + ...(this.supportsActionBatchPersistence() + ? getActionBatchCapabilities(this.actionBatchMaxReservedOutputs, true) + : {}) + } + } + + async prepareNoSendExpiry( + auth: AuthId, + args: Validation.ValidCreateActionArgs + ): Promise { + if (auth.isActive !== true) throw new WERR_NOT_ACTIVE('BRC-177 requires the active storage provider') + if (!this.supportsNoSendExpiryPersistence()) { + throw new WERR_NOT_IMPLEMENTED('BRC-177 atomic lifecycle persistence') + } + return await prepareNoSendExpiry(this, auth, args) + } + + async activateNoSendExpiry( + auth: AuthId, + args: StorageActivateNoSendExpiryArgs + ): Promise { + if (auth.isActive !== true) throw new WERR_NOT_ACTIVE('BRC-177 requires the active storage provider') + if (!this.supportsNoSendExpiryPersistence()) { + throw new WERR_NOT_IMPLEMENTED('BRC-177 atomic lifecycle persistence') + } + return await activateNoSendExpiry(this, auth, args) + } + + async armNoSendExpiry(auth: AuthId, args: StorageArmNoSendExpiryArgs): Promise { + if (auth.isActive !== true) throw new WERR_NOT_ACTIVE('BRC-177 requires the active storage provider') + if (!this.supportsNoSendExpiryPersistence()) { + throw new WERR_NOT_IMPLEMENTED('BRC-177 atomic lifecycle persistence') + } + await armNoSendExpiry(this, auth, args) + } + + protected supportsNoSendExpiryPersistence(): boolean { + return false } protected supportsActionBatchPersistence(): boolean { @@ -603,7 +653,15 @@ export abstract class StorageProvider extends StorageReaderWriter implements Wal ) } const unAbortableStatus: TransactionStatus[] = ['completed', 'failed', 'sending', 'unproven'] - if (tx == null || !tx.isOutgoing || unAbortableStatus.includes(tx.status)) { + const brc177TerminalOrRacing = tx?.noSendExpiryState != null && [ + 'broadcast', + 'reclaiming', + 'reclaimed', + 'target-won', + 'conflicted', + 'cancelled' + ].includes(tx.noSendExpiryState) + if (tx == null || !tx.isOutgoing || (unAbortableStatus.includes(tx.status) && !brc177TerminalOrRacing)) { throw new WERR_INVALID_PARAMETER( 'reference', 'an inprocess, outgoing action that has not been signed and shared to the network.' @@ -657,6 +715,16 @@ export abstract class StorageProvider extends StorageReaderWriter implements Wal serviceUnreachable: boolean, trx: TrxToken ): Promise { + if (tx.noSendExpiryState === 'preparing' || tx.noSendExpiryState === 'unsigned') { + if (!await this.compareAndSetNoSendExpiryState( + tx.transactionId, + tx.noSendExpiryState, + 'cancelled', + trx + )) { + throw new WERR_INVALID_OPERATION('BRC-177 action changed while it was being aborted') + } + } await this.updateTransactionStatus('failed', tx.transactionId, userId, reference, trx) if (tx.txid != null && tx.txid !== '') { const req = await EntityProvenTxReq.fromStorageTxid(this, tx.txid, trx) @@ -679,6 +747,47 @@ export abstract class StorageProvider extends StorageReaderWriter implements Wal const userId = auth.userId const r = await this.transaction(async trx => { const { tx, reference } = await this.findAbortableTransaction(userId, args, trx) + if (tx.noSendExpiryState != null && auth.isActive !== true) { + throw new WERR_NOT_ACTIVE('BRC-177 requires the active storage provider') + } + if (tx.noSendExpiryState === 'signed') { + // A released BRC-177 transaction must never be invalidated locally: the + // recipient may be broadcasting it at this instant. Make it due and let + // the lifecycle's positive chain/UTXO checks arbitrate the race. + if (!await this.compareAndSetNoSendExpiryState( + tx.transactionId, + 'signed', + 'revocation-requested', + trx + )) { + throw new WERR_INVALID_OPERATION('BRC-177 action changed while early revocation was requested') + } + const req = tx.txid == null ? undefined : await EntityProvenTxReq.fromStorageTxid(this, tx.txid, trx) + if (req != null) { + req.addHistoryNote({ what: 'brc177-early-abort-reclaim-requested', reference: args.reference }) + await req.updateStorageDynamicProperties(this, trx) + } + return { + __abortAction: 'brc177-reclaim-requested' as const, + transactionId: tx.transactionId + } + } + if (tx.noSendExpiryState === 'revocation-requested') { + return { + __abortAction: 'brc177-reclaim-requested' as const, + transactionId: tx.transactionId + } + } + if (tx.noSendExpiryState === 'reclaiming' || tx.noSendExpiryState === 'reclaimed') { + return { __abortAction: 'brc177-reclaim-in-progress' as const } + } + if (tx.noSendExpiryState === 'cancelled') { + return { __abortAction: 'brc177-cancelled' as const } + } + if (tx.noSendExpiryState === 'broadcast' || tx.noSendExpiryState === 'target-won' || + tx.noSendExpiryState === 'conflicted') { + return { __abortAction: 'brc177-target-protected' as const } + } // Chain-status protection for signed nosend txs. // // Background: a nosend tx (created via createAction({noSend:true})) @@ -725,6 +834,21 @@ export abstract class StorageProvider extends StorageReaderWriter implements Wal ) }) if ('__abortAction' in r) { + if (r.__abortAction === 'brc177-reclaim-requested') { + await processNoSendExpiryLifecycle(this).catch(() => undefined) + const current = verifyOne(await this.findTransactions({ + partial: { transactionId: r.transactionId }, + noRawTx: true + })) + if (current.noSendExpiryState === 'broadcast' || current.noSendExpiryState === 'target-won' || + current.noSendExpiryState === 'conflicted') { + return { aborted: false } + } + return { aborted: true } + } + if (r.__abortAction === 'brc177-reclaim-in-progress') return { aborted: true } + if (r.__abortAction === 'brc177-cancelled') return { aborted: true } + if (r.__abortAction === 'brc177-target-protected') return { aborted: false } // Tone Engel review feedback (PR #122 comment 4444566147 item 3): // do not throw on chain-confirmed refusal — surface it via the // return value so callers can branch on it. Refusal is positive @@ -898,6 +1022,28 @@ export abstract class StorageProvider extends StorageReaderWriter implements Wal } } + private async protectNoSendExpiryReclaimInputOnFailure( + tx: TableTransaction, + trx?: TrxToken + ): Promise { + if (tx.txid == null) return false + const target = verifyOneOrNone( + await this.findTransactions({ + partial: { userId: tx.userId, noSendExpiryReclaimTxid: tx.txid }, + noRawTx: true, + trx + }) + ) + if (target == null) return false + + // Generic failed-transaction cleanup releases inputs for reuse. A reclaim + // is different: the signed target may still be outside the wallet, so its + // anchor remains quarantined even when a processor rejects this reclaim. + // Keep the race lifecycle intact because a prior submission can still be + // proven later; a rejection response is not chain finality. + return true + } + /** * For all `status` values besides 'failed', just updates the transaction records status property. * @@ -944,7 +1090,9 @@ export abstract class StorageProvider extends StorageReaderWriter implements Wal switch (status) { case 'failed': - await this.releaseInputsAllocatedToFailedTransaction(tx, trx) + if (!(await this.protectNoSendExpiryReclaimInputOnFailure(tx, trx))) { + await this.releaseInputsAllocatedToFailedTransaction(tx, trx) + } await this.markFailedTransactionOutputsNotSpendable(tx, trx) break case 'nosend': diff --git a/packages/wallet/wallet-toolbox/src/storage/StorageReaderWriter.ts b/packages/wallet/wallet-toolbox/src/storage/StorageReaderWriter.ts index f780351a1..2cb9858e5 100644 --- a/packages/wallet/wallet-toolbox/src/storage/StorageReaderWriter.ts +++ b/packages/wallet/wallet-toolbox/src/storage/StorageReaderWriter.ts @@ -32,6 +32,8 @@ import { DEFAULT_MANAGED_CHANGE_MINIMUM_SATOSHIS, DEFAULT_MANAGED_CHANGE_TARGET_UTXOS } from './methods/managedChangePolicy' +import type { Brc177NoSendExpiryState } from '../utility/brc177NoSendExpiry' +import { WERR_NOT_IMPLEMENTED } from '../sdk/WERR_errors' export abstract class StorageReaderWriter extends StorageReader { abstract dropAllData (): Promise @@ -87,6 +89,14 @@ export abstract class StorageReaderWriter extends StorageReader { abstract updateProvenTxReq (id: number | number[], update: Partial, trx?: TrxToken): Promise abstract updateSyncState (id: number, update: Partial, trx?: TrxToken): Promise abstract updateTransaction (id: number | number[], update: Partial, trx?: TrxToken): Promise + async compareAndSetNoSendExpiryState ( + _transactionId: number, + _expected: Brc177NoSendExpiryState, + _next: Brc177NoSendExpiryState, + _trx?: TrxToken + ): Promise { + throw new WERR_NOT_IMPLEMENTED('BRC-177 atomic lifecycle persistence') + } abstract updateTxLabel (id: number, update: Partial, trx?: TrxToken): Promise abstract updateTxLabelMap ( transactionId: number, diff --git a/packages/wallet/wallet-toolbox/src/storage/WalletStorageManager.ts b/packages/wallet/wallet-toolbox/src/storage/WalletStorageManager.ts index 08faf0179..f8fa76403 100644 --- a/packages/wallet/wallet-toolbox/src/storage/WalletStorageManager.ts +++ b/packages/wallet/wallet-toolbox/src/storage/WalletStorageManager.ts @@ -484,6 +484,37 @@ export class WalletStorageManager implements sdk.WalletStorage { }) } + async prepareNoSendExpiry( + args: Validation.ValidCreateActionArgs + ): Promise { + return await this.runAsWriter(async writer => { + if (writer.prepareNoSendExpiry == null) { + throw new sdk.WERR_INVALID_OPERATION('Active storage does not support BRC-177 noSend expiry') + } + return await writer.prepareNoSendExpiry(await this.getAuth(true), args) + }) + } + + async activateNoSendExpiry( + args: sdk.StorageActivateNoSendExpiryArgs + ): Promise { + return await this.runAsWriter(async writer => { + if (writer.activateNoSendExpiry == null) { + throw new sdk.WERR_INVALID_OPERATION('Active storage does not support BRC-177 noSend expiry') + } + return await writer.activateNoSendExpiry(await this.getAuth(true), args) + }) + } + + async armNoSendExpiry(args: sdk.StorageArmNoSendExpiryArgs): Promise { + await this.runAsWriter(async writer => { + if (writer.armNoSendExpiry == null) { + throw new sdk.WERR_INVALID_OPERATION('Active storage does not support BRC-177 noSend expiry') + } + await writer.armNoSendExpiry(await this.getAuth(true), args) + }) + } + async getCapabilities(): Promise { return await this.runAsReader(async () => await this.getActive().getCapabilities()) } diff --git a/packages/wallet/wallet-toolbox/src/storage/__test/StorageIdb.test.ts b/packages/wallet/wallet-toolbox/src/storage/__test/StorageIdb.test.ts index b48a7adff..8743f2eee 100644 --- a/packages/wallet/wallet-toolbox/src/storage/__test/StorageIdb.test.ts +++ b/packages/wallet/wallet-toolbox/src/storage/__test/StorageIdb.test.ts @@ -29,10 +29,14 @@ describe('StorageIdb tests', () => { try { const r = await storage.migrate(`storageIdbTest-${Date.now()}`, '42'.repeat(32)) const db = storage.db - expect(r).toBe('4') + expect(r).toBe('5') expect(db).toBeTruthy() expect(db?.transaction('outputs').objectStore('outputs').indexNames.contains('userId_basketId')).toBe(true) expect(db?.transaction('outputs').objectStore('outputs').indexNames.contains('txid_vout_userId')).toBe(true) + expect(db?.transaction('transactions').objectStore('transactions') + .indexNames.contains('noSendExpiryState')).toBe(true) + expect(db?.transaction('transactions').objectStore('transactions') + .indexNames.contains('noSendExpiryReclaimTxid')).toBe(true) expect(db?.transaction('certificates').objectStore('certificates') .indexNames.contains('userId_basketId')).toBe(false) } finally { @@ -58,11 +62,15 @@ describe('StorageIdb tests', () => { try { const upgraded = await storage.initDB('version 2 upgrade test', '42'.repeat(32)) - expect(upgraded.version).toBe(4) + expect(upgraded.version).toBe(5) expect(upgraded.transaction('outputs').objectStore('outputs') .indexNames.contains('userId_basketId')).toBe(true) expect(upgraded.transaction('outputs').objectStore('outputs') .indexNames.contains('txid_vout_userId')).toBe(true) + expect(upgraded.transaction('transactions').objectStore('transactions') + .indexNames.contains('noSendExpiryState')).toBe(true) + expect(upgraded.transaction('transactions').objectStore('transactions') + .indexNames.contains('noSendExpiryReclaimTxid')).toBe(true) upgraded.close() } finally { await resetStorage(storage) @@ -184,6 +192,29 @@ describe('StorageIdb tests', () => { } }) + test('atomically compares and sets BRC-177 expiry state', async () => { + const storage = await makeStorage() + try { + const userId = await insertUser(storage) + const transactionId = await insertTransaction(storage, userId, { + status: 'nosend', + txid: '16'.repeat(32) + }) + await storage.updateTransaction(transactionId, { noSendExpiryState: 'signed' }) + + const contenders = await Promise.all([ + storage.compareAndSetNoSendExpiryState(transactionId, 'signed', 'reclaiming'), + storage.compareAndSetNoSendExpiryState(transactionId, 'signed', 'reclaiming') + ]) + expect(contenders.filter(Boolean)).toHaveLength(1) + await expect(storage.compareAndSetNoSendExpiryState(transactionId, 'signed', 'conflicted')).resolves.toBe(false) + const [transaction] = await storage.findTransactions({ partial: { transactionId } }) + expect(transaction.noSendExpiryState).toBe('reclaiming') + } finally { + await resetStorage(storage) + } + }) + test('processes an entire sync page in one IndexedDB transaction', async () => { const storage = await makeStorage() try { diff --git a/packages/wallet/wallet-toolbox/src/storage/idbHelpers.ts b/packages/wallet/wallet-toolbox/src/storage/idbHelpers.ts index c75d8820a..9ae89d44e 100644 --- a/packages/wallet/wallet-toolbox/src/storage/idbHelpers.ts +++ b/packages/wallet/wallet-toolbox/src/storage/idbHelpers.ts @@ -296,7 +296,13 @@ function matchesTransactionPartialScalars (r: TableTransaction, partial: Partial eq(partial.isOutgoing, r.isOutgoing) && eq(partial.satoshis, r.satoshis) && eq(partial.version, r.version) && - eq(partial.lockTime, r.lockTime) + eq(partial.lockTime, r.lockTime) && + eq(partial.noSendExpiryValue, r.noSendExpiryValue) && + eq(partial.noSendExpiryDeadline, r.noSendExpiryDeadline) && + eq(partial.noSendExpiryAnchorVout, r.noSendExpiryAnchorVout) && + eq(partial.noSendExpiryReleasedAt, r.noSendExpiryReleasedAt) && + eq(partial.noSendExpiryObservedAt, r.noSendExpiryObservedAt) && + eq(partial.noSendExpiryReclaimSatoshis, r.noSendExpiryReclaimSatoshis) ) } @@ -305,7 +311,13 @@ function matchesTransactionPartialStrings (r: TableTransaction, partial: Partial eqNullable(partial.status, r.status) && eqNullable(partial.reference, r.reference) && eqNullable(partial.description, r.description) && - eqNullable(partial.txid, r.txid) + eqNullable(partial.txid, r.txid) && + eqNullable(partial.noSendExpiryMode, r.noSendExpiryMode) && + eqNullable(partial.noSendExpiryState, r.noSendExpiryState) && + eqNullable(partial.noSendExpiryAnchorTxid, r.noSendExpiryAnchorTxid) && + eqNullable(partial.noSendExpiryReclaimTxid, r.noSendExpiryReclaimTxid) && + eqNullable(partial.noSendExpiryReclaimDerivationPrefix, r.noSendExpiryReclaimDerivationPrefix) && + eqNullable(partial.noSendExpiryReclaimDerivationSuffix, r.noSendExpiryReclaimDerivationSuffix) ) } diff --git a/packages/wallet/wallet-toolbox/src/storage/methods/createAction.ts b/packages/wallet/wallet-toolbox/src/storage/methods/createAction.ts index 965bc9064..373ef5add 100644 --- a/packages/wallet/wallet-toolbox/src/storage/methods/createAction.ts +++ b/packages/wallet/wallet-toolbox/src/storage/methods/createAction.ts @@ -30,6 +30,7 @@ import { WERR_INSUFFICIENT_FUNDS, WERR_INVALID_OPERATION, WERR_INVALID_PARAMETER, + WERR_NOT_ACTIVE, WERR_REVIEW_ACTIONS } from '../../sdk/WERR_errors' import { @@ -55,6 +56,7 @@ import type { ManagedChangeInputCandidate } from './availableManagedChange' import { CanonicalChangeSelector, randomizeOutputVouts as randomizePlannedOutputVouts } from './actionPlanning' import { TransactionStatus } from '../../sdk/types' import { beefForTxids } from '../../utility/beefForTxids' +import type { Brc177ValidCreateActionArgs } from '../../utility/brc177NoSendExpiry' let disableDoubleSpendCheckForTest = true export function setDisableDoubleSpendCheckForTest(v: boolean) { @@ -107,6 +109,10 @@ async function createActionCore( if (vargs.isTestWerrReviewActions) throwDummyReviewActions() + if ((vargs as Brc177ValidCreateActionArgs).brc177 != null && auth.isActive !== true) { + throw new WERR_NOT_ACTIVE('BRC-177 requires the active storage provider') + } + if (!vargs.isNewTx) // The purpose of this function is to create the initial storage records associated // with a new transaction. It's an error if we have no new inputs or outputs... @@ -176,6 +182,7 @@ async function createActionCore( [userId, vargs, xinputs, xoutputs, changeBasket, noSendChangeIn, feeModel], parent ) + validateBrc177FundingPlan(vargs as Brc177ValidCreateActionArgs, initialFundingPlan) logger?.log(`planned funding from ${initialFundingPlan.availableChangeCount} change inputs`) // The selected source txids are known before the write transaction begins. @@ -231,7 +238,11 @@ async function createActionCore( logger?.log('adjusted change outputs to max possible') } + const fixedManagedChangeSatoshis = ctx.xoutputs + .filter(output => output.purpose === 'change') + .reduce((sum, output) => sum + output.satoshis, 0) const satoshis = + fixedManagedChangeSatoshis + funded.changeOutputs.reduce((sum, output) => sum + output.satoshis, 0) - funded.allocatedChange.reduce((sum, output) => sum + output.satoshis, 0) if (satoshis !== initialSatoshis) { @@ -329,9 +340,10 @@ interface CreateTransactionSdkContext { noSendChangeIn: TableOutput[] feeModel: StorageFeeModel transactionId: number + derivationPrefix?: string } -interface XValidCreateActionInput extends Validation.ValidCreateActionInput { +export interface XValidCreateActionInput extends Validation.ValidCreateActionInput { vin: number lockingScript: Script satoshis: number @@ -590,14 +602,14 @@ async function createNewOutputs( const newOutputs: Array<{ o: TableOutput; tags: string[] }> = [] for (const xo of ctx.xoutputs) { - const lockingScript = asArray(xo.lockingScript) + const lockingScript = xo.purpose === 'change' ? undefined : asArray(xo.lockingScript) if (xo.purpose === 'service-charge') { const now = new Date() await storage.insertCommission( { userId, transactionId: ctx.transactionId, - lockingScript, + lockingScript: verifyTruthy(lockingScript), satoshis: xo.satoshis, isRedeemed: false, keyOffset: verifyTruthy(xo.keyOffset), @@ -608,15 +620,26 @@ async function createNewOutputs( trx ) const o = makeDefaultOutput(userId, ctx.transactionId, xo.satoshis, xo.vout) - o.lockingScript = lockingScript + o.lockingScript = verifyTruthy(lockingScript) o.providedBy = 'storage' o.purpose = 'storage-commission' o.type = 'custom' o.spendable = false newOutputs.push({ o, tags: [] }) + } else if (xo.purpose === 'change') { + const o = makeDefaultOutput(userId, ctx.transactionId, xo.satoshis, xo.vout) + o.basketId = ctx.changeBasket.basketId + o.change = true + o.derivationPrefix = verifyTruthy(ctx.derivationPrefix) + o.derivationSuffix = verifyTruthy(xo.derivationSuffix) + o.providedBy = 'storage' + o.purpose = 'change' + o.type = 'P2PKH' + o.spendable = true + newOutputs.push({ o, tags: [] }) } else { const o = makeDefaultOutput(userId, ctx.transactionId, xo.satoshis, xo.vout) - o.lockingScript = lockingScript + o.lockingScript = verifyTruthy(lockingScript) o.basketId = xo.basket ? txBaskets[xo.basket].basketId : undefined o.customInstructions = xo.customInstructions o.outputDescription = xo.outputDescription @@ -688,6 +711,15 @@ async function createNewTxRecord( txid: undefined, rawTx: undefined } + const brc177 = (vargs as Brc177ValidCreateActionArgs).brc177 + if (brc177?.kind === 'protected') { + newTx.noSendExpiryMode = brc177.expiry.mode + newTx.noSendExpiryValue = brc177.expiry.value + newTx.noSendExpiryDeadline = brc177.deadline + newTx.noSendExpiryState = 'preparing' + newTx.noSendExpiryAnchorTxid = brc177.anchorTxid + newTx.noSendExpiryAnchorVout = brc177.anchorVout + } newTx.transactionId = await storage.insertTransaction(newTx, trx) const labelNames = [...new Set(vargs.labels)] @@ -722,7 +754,7 @@ async function createNewTxRecord( * @param vargs * @returns xoutputs */ -function validateRequiredOutputs( +export function validateRequiredOutputs( storage: StorageProvider, userId: number, vargs: Validation.ValidCreateActionArgs @@ -759,6 +791,23 @@ function validateRequiredOutputs( }) } + const brc177 = (vargs as Brc177ValidCreateActionArgs).brc177 + if (brc177?.kind === 'funding') { + vout++ + xoutputs.push({ + lockingScript: '00'.repeat(25), + satoshis: brc177.anchorSatoshis, + outputDescription: '', + basket: undefined, + tags: [], + vout, + providedBy: 'storage', + purpose: 'change', + derivationSuffix: undefined, + keyOffset: undefined + }) + } + return xoutputs } @@ -783,7 +832,7 @@ function validateRequiredOutputs( * @returns {beef} containing verified validity proof data for all required inputs. * @returns {xinputs} extended validated required inputs. */ -async function validateRequiredInputs( +export async function validateRequiredInputs( storage: StorageProvider, userId: number, vargs: Validation.ValidCreateActionArgs @@ -1094,6 +1143,7 @@ interface MakeFundingParamsArgs { function makeFundingParams(args: MakeFundingParamsArgs): GenerateChangeSdkParams { const { storage, vargs, xinputs, xoutputs, changeBasket, feeModel, healthyChangeCount, compatibilityFallback } = args const preferredSatoshis = Math.max(1, changeBasket.minimumDesiredUTXOValue) + const brc177 = (vargs as Brc177ValidCreateActionArgs).brc177 return { fixedInputs: xinputs.map(input => ({ satoshis: input.satoshis, @@ -1112,7 +1162,9 @@ function makeFundingParams(args: MakeFundingParamsArgs): GenerateChangeSdkParams changeLockingScriptLength: 25, changeUnlockingScriptLength: 107, targetNetCount: changeBasket.numberOfDesiredUTXOs - healthyChangeCount, - maxChangeOutputs: storage.managedChangePolicy.maxOutputsPerAction, + // The planner requires a positive cap. The exact anchor leaves no surplus, + // and validateBrc177FundingPlan below independently rejects any change. + maxChangeOutputs: brc177?.kind === 'protected' ? 1 : storage.managedChangePolicy.maxOutputsPerAction, surplusPoolShaping: !compatibilityFallback, maxMigrationInputs: compatibilityFallback ? 0 : storage.managedChangePolicy.migrationInputsPerAction, randomVals: vargs.randomVals @@ -1130,9 +1182,11 @@ async function buildFundingPlan( ): Promise { const [userId, vargs, xinputs, xoutputs, changeBasket, noSendChangeIn, feeModel] = context const noSendIds = new Set(noSendChangeIn.map(output => output.outputId)) + const brc177 = (vargs as Brc177ValidCreateActionArgs).brc177 const available = candidates.filter( output => !noSendIds.has(output.outputId) && eligibleStatuses.includes(output.transactionStatus) ) + if (brc177?.kind === 'protected') available.length = 0 const preferredSatoshis = Math.max(1, changeBasket.minimumDesiredUTXOValue) const healthyChangeCount = compatibilityFallback ? // Preserve the legacy target-count input exactly, including noSendChange @@ -1579,6 +1633,12 @@ async function fundNewTransactionSdk( // Generate a derivation prefix for the payment const derivationPrefix = randomDerivation(16) + ctx.derivationPrefix = derivationPrefix + for (const output of ctx.xoutputs) { + if (output.purpose === 'change' && output.derivationSuffix == null) { + output.derivationSuffix = randomDerivation(16) + } + } const r: { allocatedChange: TableOutput[] @@ -1626,6 +1686,24 @@ async function fundNewTransactionSdk( return r } +function validateBrc177FundingPlan( + vargs: Brc177ValidCreateActionArgs, + plan: PreparedFundingPlan +): void { + if (vargs.brc177?.kind !== 'protected') return + if (plan.selected.length !== 1 || vargs.options.noSendChange.length !== 1) { + throw new WERR_INVALID_OPERATION('BRC-177 protected action must use exactly one revocation anchor') + } + const selected = plan.selected[0] + const anchor = vargs.options.noSendChange[0] + if (selected.txid !== anchor.txid || selected.vout !== anchor.vout) { + throw new WERR_INVALID_OPERATION('BRC-177 protected action selected a non-anchor wallet input') + } + if (plan.result.changeOutputs.length !== 0) { + throw new WERR_INVALID_OPERATION('BRC-177 protected action must not create wallet change') + } +} + /** * Avoid returning any known raw transaction data by converting any known transaction * in the `beef` to txidOnly. diff --git a/packages/wallet/wallet-toolbox/src/storage/methods/noSendExpiry.ts b/packages/wallet/wallet-toolbox/src/storage/methods/noSendExpiry.ts new file mode 100644 index 000000000..7399bd08e --- /dev/null +++ b/packages/wallet/wallet-toolbox/src/storage/methods/noSendExpiry.ts @@ -0,0 +1,428 @@ +import { Beef, Transaction, TransactionSignature, Utils, Validation } from '@bsv/sdk' +import { + AuthId, + StorageActivateNoSendExpiryArgs, + StorageActivateNoSendExpiryResult, + StorageArmNoSendExpiryArgs, + StoragePrepareNoSendExpiryResult, + TrxToken +} from '../../sdk/WalletStorage.interfaces' +import { WERR_INVALID_OPERATION, WERR_INVALID_PARAMETER } from '../../sdk/WERR_errors' +import { Brc177ValidCreateActionArgs, parseBrc177NoSendExpiryLabels } from '../../utility/brc177NoSendExpiry' +import { verifyId, verifyOne } from '../../utility/utilityHelpers' +import { asArray } from '../../utility/utilityHelpers.noBuffer' +import { StorageProvider, validateStorageFeeModel } from '../StorageProvider' +import { TableTransaction } from '../schema/tables/TableTransaction' +import { createAction, validateRequiredInputs, validateRequiredOutputs } from './createAction' +import { transactionSize } from './utils' +import { verifyUnlockScripts } from '../../signer/methods/completeSignedTransaction' + +const MANAGED_INPUT_UNLOCKING_SCRIPT_LENGTH = 107 +const MANAGED_OUTPUT_LOCKING_SCRIPT_LENGTH = 25 +const CONSERVATIVE_RECLAIM_SATS_PER_KB = 1000 +const MAX_RECLAIM_RAW_TX_BYTES = 1000 + +export function validateNoSendExpiryRequest( + args: Validation.ValidCreateActionArgs +): ReturnType { + const expiry = parseBrc177NoSendExpiryLabels(args.labels) + if (expiry == null) return undefined + if (!args.isNewTx || args.outputs.length === 0) { + throw new WERR_INVALID_PARAMETER('outputs', 'at least one output for a BRC-177 protected action') + } + if (!args.options.noSend) { + throw new WERR_INVALID_PARAMETER('options.noSend', 'true for a BRC-177 protected action') + } + if (args.options.sendWith.length > 0) { + throw new WERR_INVALID_PARAMETER('options.sendWith', 'empty for a BRC-177 protected action') + } + if (args.options.noSendChange.length > 0) { + throw new WERR_INVALID_PARAMETER('options.noSendChange', 'empty for a BRC-177 protected action') + } + if (args.options.returnTXIDOnly) { + throw new WERR_INVALID_PARAMETER('options.returnTXIDOnly', 'false for a BRC-177 protected action') + } + return expiry +} + +export function makeNoSendExpiryFundingArgs( + anchorSatoshis: number, + protectedLabels: string[] = [] +): Brc177ValidCreateActionArgs { + const attributionLabels = protectedLabels.filter( + label => label.startsWith('admin originator ') || label.startsWith('admin month ') + ) + const args = Validation.validateCreateActionArgs({ + description: 'BRC-177 expiry anchor funding', + labels: [...new Set(['admin brc177 funding', ...attributionLabels])], + options: { + acceptDelayedBroadcast: false, + noSend: false, + randomizeOutputs: false, + returnTXIDOnly: false, + signAndProcess: true + } + }) as Brc177ValidCreateActionArgs + args.brc177 = { kind: 'funding', anchorSatoshis } + return args +} + +function feeForSize(storage: StorageProvider, size: number, minimumSatsPerKb = 0): number { + const feeModel = validateStorageFeeModel(storage.feeModel) + const satsPerKb = Math.max(feeModel.value || 0, minimumSatsPerKb) + return Math.ceil((size / 1000) * satsPerKb) +} + +function reclaimValues( + storage: StorageProvider, + anchorSatoshis: number +): { reclaimFee: number; reclaimSatoshis: number } { + const reclaimSize = transactionSize([MANAGED_INPUT_UNLOCKING_SCRIPT_LENGTH], [MANAGED_OUTPUT_LOCKING_SCRIPT_LENGTH]) + const reclaimFee = feeForSize(storage, reclaimSize, CONSERVATIVE_RECLAIM_SATS_PER_KB) + const currentFee = feeForSize(storage, reclaimSize) + const minimumOutput = Math.max(1, currentFee * 2) + const reclaimSatoshis = anchorSatoshis - reclaimFee + if (reclaimSatoshis < minimumOutput) { + throw new WERR_INVALID_PARAMETER( + 'outputs', + `a no-change BRC-177 action whose anchor leaves at least ${minimumOutput} satoshis after its ${reclaimFee}-satoshi reclaim fee` + ) + } + return { reclaimFee, reclaimSatoshis } +} + +async function estimateAnchorSatoshis( + storage: StorageProvider, + userId: number, + target: Validation.ValidCreateActionArgs +): Promise { + const { xinputs } = await validateRequiredInputs(storage, userId, target) + const xoutputs = validateRequiredOutputs(storage, userId, target) + const size = transactionSize( + [...xinputs.map(input => input.unlockingScriptLength), MANAGED_INPUT_UNLOCKING_SCRIPT_LENGTH], + xoutputs.map(output => output.lockingScript.length / 2) + ) + const inputSatoshis = xinputs.reduce((sum, input) => sum + input.satoshis, 0) + const outputSatoshis = xoutputs.reduce((sum, output) => sum + output.satoshis, 0) + const anchorSatoshis = outputSatoshis + feeForSize(storage, size) - inputSatoshis + if (!Number.isSafeInteger(anchorSatoshis) || anchorSatoshis <= 0) { + throw new WERR_INVALID_PARAMETER('inputs', 'a BRC-177 action requiring a positive, exactly sized revocation anchor') + } + return anchorSatoshis +} + +export async function prepareNoSendExpiry( + storage: StorageProvider, + auth: AuthId, + target: Validation.ValidCreateActionArgs +): Promise { + const expiry = validateNoSendExpiryRequest(target) + if (expiry == null) { + throw new WERR_INVALID_PARAMETER('labels', 'a BRC-177 noSend expiry label') + } + if (expiry.mode === 'timestamp' && expiry.value <= Math.floor(Date.now() / 1000)) { + throw new WERR_INVALID_PARAMETER('labels', 'a BRC-177 timestamp later than the current time') + } + if (expiry.mode === 'seconds' && !Number.isSafeInteger(Math.floor(Date.now() / 1000) + expiry.value)) { + throw new WERR_INVALID_PARAMETER('labels', 'a safely schedulable BRC-177 seconds duration') + } + if (expiry.mode === 'blockheight' && expiry.value <= (await storage.getServices().getHeight())) { + throw new WERR_INVALID_PARAMETER('labels', 'a BRC-177 blockheight later than the current best-chain height') + } + const userId = verifyId(auth.userId) + const anchorSatoshis = await estimateAnchorSatoshis(storage, userId, target) + const { reclaimFee, reclaimSatoshis } = reclaimValues(storage, anchorSatoshis) + const fundingArgs = makeNoSendExpiryFundingArgs(anchorSatoshis, target.labels) + fundingArgs.includeAllSourceTransactions = target.includeAllSourceTransactions + const funding = await createAction(storage, auth, fundingArgs) + const anchor = funding.outputs.find(output => output.providedBy === 'storage' && output.purpose === 'change') + if (anchor == null || anchor.satoshis !== anchorSatoshis) { + throw new WERR_INVALID_OPERATION('BRC-177 funding plan did not contain its exact revocation anchor') + } + return { + funding, + anchorSatoshis, + anchorVout: anchor.vout, + reclaimFee, + reclaimSatoshis + } +} + +async function resolveDeadline( + storage: StorageProvider, + expiry: NonNullable> +): Promise { + if (expiry.mode === 'blockheight') { + const height = await storage.getServices().getHeight() + if (expiry.value <= height) { + throw new WERR_INVALID_PARAMETER('labels', 'a BRC-177 blockheight later than the current best-chain height') + } + return expiry.value + } + const now = Math.floor(Date.now() / 1000) + if (expiry.mode === 'timestamp') { + if (expiry.value <= now) { + throw new WERR_INVALID_PARAMETER('labels', 'a BRC-177 timestamp later than the current time') + } + return expiry.value + } + const deadline = now + expiry.value + if (!Number.isSafeInteger(deadline)) { + throw new WERR_INVALID_PARAMETER('labels', 'a safely schedulable BRC-177 seconds duration') + } + return deadline +} + +export async function activateNoSendExpiry( + storage: StorageProvider, + auth: AuthId, + args: StorageActivateNoSendExpiryArgs +): Promise { + const expiry = validateNoSendExpiryRequest(args.target) + if (expiry == null) throw new WERR_INVALID_PARAMETER('labels', 'a BRC-177 noSend expiry label') + const userId = verifyId(auth.userId) + const funding = verifyOne( + await storage.findTransactions({ + partial: { userId, reference: args.fundingReference, txid: args.fundingTxid } + }) + ) + if (funding.status !== 'unproven' && funding.status !== 'completed') { + throw new WERR_INVALID_OPERATION('BRC-177 funding transaction was not accepted by a processor') + } + const anchor = verifyOne( + await storage.findOutputs({ + partial: { userId, txid: args.fundingTxid, vout: args.anchorVout } + }) + ) + if (!anchor.change || anchor.type !== 'P2PKH' || anchor.purpose !== 'change' || !anchor.spendable) { + throw new WERR_INVALID_OPERATION('BRC-177 revocation anchor is not available managed change') + } + + const expectedAnchor = await estimateAnchorSatoshis(storage, userId, args.target) + if (Number(anchor.satoshis) !== expectedAnchor) { + throw new WERR_INVALID_OPERATION('BRC-177 revocation anchor no longer exactly funds the protected action') + } + reclaimValues(storage, expectedAnchor) + const deadline = await resolveDeadline(storage, expiry) + const target = { + ...args.target, + inputs: [...args.target.inputs], + outputs: [...args.target.outputs], + labels: [...args.target.labels], + options: { + ...args.target.options, + noSend: true, + noSendChange: [{ txid: args.fundingTxid, vout: args.anchorVout }], + sendWith: [], + returnTXIDOnly: false + } + } as Brc177ValidCreateActionArgs + target.brc177 = { + kind: 'protected', + expiry, + deadline, + anchorTxid: args.fundingTxid, + anchorVout: args.anchorVout + } + const action = await createAction(storage, auth, target) + return { + action, + expiry, + deadline, + anchorTxid: args.fundingTxid, + anchorVout: args.anchorVout + } +} + +function validateDerivation(value: string, name: string): void { + let bytes: number[] + try { + bytes = Utils.toArray(value, 'base64') + } catch { + throw new WERR_INVALID_PARAMETER(name, 'a 16-byte base64 derivation') + } + if (bytes.length !== 16 || Utils.toBase64(bytes) !== value) { + throw new WERR_INVALID_PARAMETER(name, 'a canonical 16-byte base64 derivation') + } +} + +function hasCanonicalAllP2pkhUnlock(reclaim: Transaction): boolean { + const chunks = reclaim.inputs[0]?.unlockingScript?.chunks + const checksig = chunks?.[0]?.data + const publicKey = chunks?.[1]?.data + if (chunks?.length !== 2 || checksig == null || publicKey?.length !== 33) return false + try { + const signature = TransactionSignature.fromChecksigFormat(checksig) + return ( + signature.scope === (TransactionSignature.SIGHASH_ALL | TransactionSignature.SIGHASH_FORKID) && + signature.hasLowS() && + Utils.toHex(signature.toChecksigFormat()) === Utils.toHex(checksig) + ) + } catch { + return false + } +} + +function parseCanonicalReclaim(args: StorageArmNoSendExpiryArgs): { reclaim: Transaction; rawTx: number[] } { + const rawTx = asArray(args.reclaimRawTx) + if (rawTx.length > MAX_RECLAIM_RAW_TX_BYTES) { + throw new WERR_INVALID_PARAMETER('reclaimRawTx', `at most ${MAX_RECLAIM_RAW_TX_BYTES} bytes`) + } + let reclaim: Transaction + try { + reclaim = Transaction.fromBinary(rawTx) + } catch { + throw new WERR_INVALID_PARAMETER('reclaimRawTx', 'a valid serialized reclaim transaction') + } + if (Utils.toHex(reclaim.toUint8Array()) !== Utils.toHex(rawTx)) { + throw new WERR_INVALID_PARAMETER('reclaimRawTx', 'a canonical serialized reclaim transaction') + } + if (reclaim.id('hex') !== args.reclaimTxid) { + throw new WERR_INVALID_PARAMETER('reclaimTxid', 'the hash of reclaimRawTx') + } + return { reclaim, rawTx } +} + +function validateReclaimOutput( + storage: StorageProvider, + anchorSatoshis: number, + args: StorageArmNoSendExpiryArgs, + reclaim: Transaction +): void { + const expected = reclaimValues(storage, anchorSatoshis).reclaimSatoshis + if (args.reclaimSatoshis !== expected || reclaim.outputs.length !== 1 || reclaim.outputs[0].satoshis !== expected) { + throw new WERR_INVALID_PARAMETER('reclaimSatoshis', 'the exact BRC-177 reclaim amount') + } + if (!/^76a914[0-9a-f]{40}88ac$/.test(reclaim.outputs[0].lockingScript.toHex())) { + throw new WERR_INVALID_PARAMETER('reclaimRawTx', 'one canonical P2PKH managed-change output') + } +} + +function validateReclaimSpend(target: TableTransaction, reclaim: Transaction): void { + const input = reclaim.inputs[0] + if ( + reclaim.inputs.length !== 1 || + input.sourceTXID !== target.noSendExpiryAnchorTxid || + input.sourceOutputIndex !== target.noSendExpiryAnchorVout || + input.sequence !== 0xffffffff || + reclaim.lockTime !== 0 || + input.unlockingScript == null || + input.unlockingScript.toHex().length === 0 || + !hasCanonicalAllP2pkhUnlock(reclaim) + ) { + throw new WERR_INVALID_PARAMETER( + 'reclaimRawTx', + 'an immediately valid SIGHASH_ALL transaction spending only the revocation anchor' + ) + } +} + +async function verifyReclaimSignature( + storage: StorageProvider, + target: TableTransaction, + rawTx: number[], + reclaimTxid: string, + trx?: TrxToken +): Promise { + let sourceRawTx: number[] + try { + const source = await storage.getRawTxOfKnownValidTransaction( + target.noSendExpiryAnchorTxid!, + undefined, + undefined, + trx + ) + if (source == null) throw new Error('missing source transaction') + sourceRawTx = asArray(source) + } catch { + throw new WERR_INVALID_OPERATION('BRC-177 revocation anchor source transaction is unavailable') + } + const verificationBeef = new Beef() + verificationBeef.mergeRawTx(sourceRawTx) + verificationBeef.mergeRawTx(rawTx) + try { + await verifyUnlockScripts(reclaimTxid, verificationBeef, storage.scriptVerifier) + } catch { + throw new WERR_INVALID_PARAMETER('reclaimRawTx', 'a valid signature for the revocation anchor') + } +} + +async function validateArmSnapshot( + storage: StorageProvider, + userId: number, + reference: string, + args: StorageArmNoSendExpiryArgs, + reclaim: Transaction, + trx?: TrxToken +): Promise { + const target = verifyOne( + await storage.findTransactions({ + partial: { userId, reference }, + trx + }) + ) + if (target.status !== 'unsigned' || target.noSendExpiryState !== 'preparing') { + throw new WERR_INVALID_OPERATION('BRC-177 action is not waiting to be armed') + } + if ( + target.noSendExpiryMode == null || + target.noSendExpiryDeadline == null || + target.noSendExpiryAnchorTxid == null || + target.noSendExpiryAnchorVout == null + ) { + throw new WERR_INVALID_OPERATION('BRC-177 action metadata is incomplete') + } + const now = Math.floor(Date.now() / 1000) + if (target.noSendExpiryMode !== 'blockheight' && target.noSendExpiryDeadline <= now) { + throw new WERR_INVALID_OPERATION('BRC-177 action expired before it could be armed') + } + const anchor = verifyOne( + await storage.findOutputs({ + partial: { + userId, + txid: target.noSendExpiryAnchorTxid, + vout: target.noSendExpiryAnchorVout + }, + trx + }) + ) + validateReclaimOutput(storage, Number(anchor.satoshis), args, reclaim) + validateReclaimSpend(target, reclaim) + return target +} + +export async function armNoSendExpiry( + storage: StorageProvider, + auth: AuthId, + args: StorageArmNoSendExpiryArgs +): Promise { + const userId = verifyId(auth.userId) + validateDerivation(args.reclaimDerivationPrefix, 'reclaimDerivationPrefix') + validateDerivation(args.reclaimDerivationSuffix, 'reclaimDerivationSuffix') + const { reclaim, rawTx } = parseCanonicalReclaim(args) + + // Signature verification can invoke an asynchronous verifier. Keep it out + // of the IndexedDB write transaction, which browsers may auto-commit while + // no database request is pending, then revalidate the complete snapshot in + // the atomic section before publishing the armed state. + const snapshot = await validateArmSnapshot(storage, userId, args.reference, args, reclaim) + await verifyReclaimSignature(storage, snapshot, rawTx, args.reclaimTxid, undefined) + + await storage.transaction(async trx => { + const target = await validateArmSnapshot(storage, userId, args.reference, args, reclaim, trx) + if (!(await storage.compareAndSetNoSendExpiryState(target.transactionId, 'preparing', 'unsigned', trx))) { + throw new WERR_INVALID_OPERATION('BRC-177 action changed before it could be armed') + } + await storage.updateTransaction( + target.transactionId, + { + noSendExpiryReclaimTxid: args.reclaimTxid, + noSendExpiryReclaimRawTx: rawTx, + noSendExpiryReclaimDerivationPrefix: args.reclaimDerivationPrefix, + noSendExpiryReclaimDerivationSuffix: args.reclaimDerivationSuffix, + noSendExpiryReclaimSatoshis: args.reclaimSatoshis + }, + trx + ) + }) +} diff --git a/packages/wallet/wallet-toolbox/src/storage/methods/noSendExpiryLifecycle.ts b/packages/wallet/wallet-toolbox/src/storage/methods/noSendExpiryLifecycle.ts new file mode 100644 index 000000000..569c8bbfa --- /dev/null +++ b/packages/wallet/wallet-toolbox/src/storage/methods/noSendExpiryLifecycle.ts @@ -0,0 +1,744 @@ +import { Transaction } from '@bsv/sdk' +import { ProvenTxReqStatus, TrxToken } from '../../sdk' +import { WERR_INTERNAL, WERR_INVALID_OPERATION } from '../../sdk/WERR_errors' +import { StatusForTxidResult } from '../../sdk/WalletServices.interfaces' +import { parseTxScriptOffsets } from '../../utility/parseTxScriptOffsets' +import { randomBytesBase64, verifyId, verifyOne, verifyOneOrNone } from '../../utility/utilityHelpers' +import { asArray } from '../../utility/utilityHelpers.noBuffer' +import type { Brc177NoSendExpiryState } from '../../utility/brc177NoSendExpiry' +import type { StorageProvider } from '../StorageProvider' +import { EntityProvenTxReq } from '../schema/entities/EntityProvenTxReq' +import { TableOutput } from '../schema/tables/TableOutput' +import { TableTransaction } from '../schema/tables/TableTransaction' + +// Descending lifecycle order ensures a row advanced by this pass moves only +// into a state whose page has already run, so it is never counted or acted on +// twice without retaining an unbounded set of transaction IDs. +const ACTIVE_STATES: Brc177NoSendExpiryState[] = [ + 'reclaiming', + 'broadcast', + 'conflicted', + 'revocation-requested', + 'signed', + 'unsigned', + 'preparing' +] +const QUERY_PAGE_SIZE = 100 +const STATUS_BATCH_SIZE = 100 + +export interface NoSendExpiryLifecycleResult { + inspected: number + cancelled: number + observed: number + reclaimActivated: number + reclaimed: number + targetWon: number + deferred: number + errors: number +} + +function emptyResult(): NoSendExpiryLifecycleResult { + return { + inspected: 0, + cancelled: 0, + observed: 0, + reclaimActivated: 0, + reclaimed: 0, + targetWon: 0, + deferred: 0, + errors: 0 + } +} + +async function activeTransactionsOnPage( + storage: StorageProvider, + page: TableTransaction[] +): Promise { + // A remote database may be retained as a synchronized backup while another + // provider is authoritative for a user. Check only the bounded set of users + // represented by this page rather than materializing every active user or + // expiry in a multi-user service. + const activeStorage = storage.getSettings().storageIdentityKey + const activeUsers = new Set() + for (const userId of new Set(page.map(transaction => transaction.userId))) { + const user = verifyOneOrNone( + await storage.findUsers({ + partial: { userId, activeStorage }, + paged: { limit: 1 } + }) + ) + if (user != null) activeUsers.add(userId) + } + return page.filter(transaction => activeUsers.has(transaction.userId)) +} + +function isDue(transaction: TableTransaction, nowSeconds: number, height: number | undefined): boolean { + if (transaction.noSendExpiryState === 'revocation-requested') return true + if (transaction.noSendExpiryDeadline == null) return false + return transaction.noSendExpiryMode === 'blockheight' + ? height != null && height >= transaction.noSendExpiryDeadline + : nowSeconds >= transaction.noSendExpiryDeadline +} + +async function getStatuses( + storage: StorageProvider, + transactions: TableTransaction[] +): Promise> { + const txids = [ + ...new Set( + transactions.flatMap(transaction => { + const txids: string[] = [] + if (transaction.txid) txids.push(transaction.txid) + if (transaction.noSendExpiryState === 'reclaiming' && transaction.noSendExpiryReclaimTxid) { + txids.push(transaction.noSendExpiryReclaimTxid) + } + return txids + }) + ) + ] + if (txids.length === 0) return new Map() + const statuses = new Map() + for (let offset = 0; offset < txids.length; offset += STATUS_BATCH_SIZE) { + try { + const result = await storage.getServices().getStatusForTxids(txids.slice(offset, offset + STATUS_BATCH_SIZE)) + if (result.status !== 'success') continue + for (const item of result.results) statuses.set(item.txid, item.status) + } catch {} + } + return statuses +} + +async function updateReqStatus( + storage: StorageProvider, + txid: string | undefined, + status: ProvenTxReqStatus, + note: string, + trx: TrxToken +): Promise { + if (!txid) return + const req = await EntityProvenTxReq.fromStorageTxid(storage, txid, trx) + if (req == null || req.status === 'completed') return + req.status = status + req.addHistoryNote({ what: note }) + await req.updateStorageDynamicProperties(storage, trx) +} + +async function quarantineTransactionOutputs( + storage: StorageProvider, + transactionId: number, + trx: TrxToken +): Promise { + const outputs = await storage.findOutputs({ partial: { transactionId }, trx }) + for (const output of outputs) { + if (output.spendable) await storage.updateOutput(output.outputId, { spendable: false }, trx) + } +} + +async function markObservedTarget( + storage: StorageProvider, + transaction: TableTransaction, + final: boolean +): Promise { + return await storage.transaction(async trx => { + const current = verifyOne( + await storage.findTransactions({ + partial: { transactionId: verifyId(transaction.transactionId) }, + trx + }) + ) + if (current.noSendExpiryState === 'reclaimed' || current.noSendExpiryState === 'cancelled') return false + + if (final) { + return await finishTargetWinner(storage, current, trx) + } + if ( + current.noSendExpiryState !== 'signed' && + current.noSendExpiryState !== 'revocation-requested' && + current.noSendExpiryState !== 'broadcast' && + current.noSendExpiryState !== 'conflicted' + ) { + return false + } + // Claim even an existing broadcast state. Besides making conflicted -> + // broadcast monotonic, the conditional write holds this row for the rest + // of the transaction so a concurrent reclaim/proof winner cannot be + // overwritten by the observation metadata update below. + if ( + !(await storage.compareAndSetNoSendExpiryState( + current.transactionId, + current.noSendExpiryState, + 'broadcast', + trx + )) + ) { + return false + } + await storage.updateTransaction( + current.transactionId, + { + noSendExpiryObservedAt: Date.now(), + ...(current.status === 'nosend' ? { status: 'unproven' as const } : {}) + }, + trx + ) + await updateReqStatus(storage, current.txid, 'unmined', 'brc177-target-observed', trx) + return true + }) +} + +async function noteTargetObservedDuringRace(storage: StorageProvider, transaction: TableTransaction): Promise { + return await storage.transaction(async trx => { + const current = verifyOne( + await storage.findTransactions({ + partial: { transactionId: verifyId(transaction.transactionId) }, + trx + }) + ) + if (current.noSendExpiryState !== 'reclaiming') return false + // Lock the lifecycle row before changing transaction/request metadata. If + // a proof winner committed after our read, this self-transition fails and + // prevents stale observation data from reviving the losing transaction. + if (!(await storage.compareAndSetNoSendExpiryState(current.transactionId, 'reclaiming', 'reclaiming', trx))) { + return false + } + let changed = false + if (current.status === 'nosend') { + await storage.updateTransaction( + current.transactionId, + { + status: 'unproven', + noSendExpiryObservedAt: current.noSendExpiryObservedAt ?? Date.now() + }, + trx + ) + changed = true + } + if (current.txid != null) { + const req = await EntityProvenTxReq.fromStorageTxid(storage, current.txid, trx) + if (req != null && req.status === 'nosend') { + req.status = 'unmined' + req.addHistoryNote({ what: 'brc177-target-observed-during-reclaim-race' }) + await req.updateStorageDynamicProperties(storage, trx) + changed = true + } + } + if (current.noSendExpiryReclaimTxid != null) { + const reclaim = verifyOneOrNone( + await storage.findTransactions({ + partial: { userId: current.userId, txid: current.noSendExpiryReclaimTxid }, + trx + }) + ) + const reclaimReq = await EntityProvenTxReq.fromStorageTxid(storage, current.noSendExpiryReclaimTxid, trx) + // Once the target is positively observed, BRC-177 stops future reclaim + // submissions. Keep the request in proof tracking because a prior send + // may still have reached a processor and either side can win the race. + if (reclaimReq != null && (reclaimReq.status === 'unsent' || reclaimReq.status === 'sending')) { + reclaimReq.status = 'unmined' + reclaimReq.addHistoryNote({ what: 'brc177-reclaim-retry-suppressed-target-observed' }) + await reclaimReq.updateStorageDynamicProperties(storage, trx) + if (reclaim != null && (reclaim.status === 'unprocessed' || reclaim.status === 'sending')) { + await storage.updateTransaction(reclaim.transactionId, { status: 'unproven' }, trx) + } + changed = true + } + } + return changed + }) +} + +async function cancelUnsigned(storage: StorageProvider, transaction: TableTransaction): Promise { + return await storage.transaction(async trx => { + const expected = transaction.noSendExpiryState + if (expected !== 'preparing' && expected !== 'unsigned') return false + if (!(await storage.compareAndSetNoSendExpiryState(transaction.transactionId, expected, 'cancelled', trx))) { + return false + } + await storage.updateTransactionStatus('failed', transaction.transactionId, undefined, undefined, trx) + return true + }) +} + +function validateReclaim(transaction: TableTransaction): Transaction { + if ( + transaction.noSendExpiryReclaimRawTx == null || + !transaction.noSendExpiryReclaimTxid || + !transaction.noSendExpiryReclaimDerivationPrefix || + !transaction.noSendExpiryReclaimDerivationSuffix || + transaction.noSendExpiryReclaimSatoshis == null || + !transaction.noSendExpiryAnchorTxid || + transaction.noSendExpiryAnchorVout == null + ) { + throw new WERR_INVALID_OPERATION('BRC-177 reclaim metadata is incomplete') + } + const reclaim = Transaction.fromBinary(asArray(transaction.noSendExpiryReclaimRawTx)) + if ( + reclaim.id('hex') !== transaction.noSendExpiryReclaimTxid || + reclaim.inputs.length !== 1 || + reclaim.outputs.length !== 1 || + reclaim.inputs[0].sourceTXID !== transaction.noSendExpiryAnchorTxid || + reclaim.inputs[0].sourceOutputIndex !== transaction.noSendExpiryAnchorVout || + reclaim.outputs[0].satoshis !== transaction.noSendExpiryReclaimSatoshis + ) { + throw new WERR_INVALID_OPERATION('BRC-177 reclaim metadata does not describe the signed reclaim transaction') + } + return reclaim +} + +async function insertReclaim( + storage: StorageProvider, + target: TableTransaction, + anchor: TableOutput, + trx: TrxToken +): Promise { + const reclaim = validateReclaim(target) + const rawTx = asArray(target.noSendExpiryReclaimRawTx!) + const reclaimTxid = target.noSendExpiryReclaimTxid! + const existingTransaction = verifyOneOrNone( + await storage.findTransactions({ + partial: { userId: target.userId, txid: reclaimTxid }, + trx + }) + ) + let reclaimTransaction = existingTransaction + if (reclaimTransaction == null) { + const now = new Date() + reclaimTransaction = { + created_at: now, + updated_at: now, + transactionId: 0, + userId: target.userId, + status: 'unprocessed', + reference: randomBytesBase64(12), + isOutgoing: true, + satoshis: target.noSendExpiryReclaimSatoshis! - anchor.satoshis, + description: 'BRC-177 expiry reclaim', + version: reclaim.version, + lockTime: reclaim.lockTime, + txid: reclaimTxid + } + reclaimTransaction.transactionId = await storage.insertTransaction(reclaimTransaction, trx) + + const basket = verifyOne( + await storage.findOutputBaskets({ + partial: { userId: target.userId, name: 'default' }, + trx + }) + ) + const offsets = parseTxScriptOffsets(rawTx) + const scriptOffset = offsets.outputs[0] + const lockingScript = rawTx.slice(scriptOffset.offset, scriptOffset.offset + scriptOffset.length) + await storage.insertOutput( + { + created_at: now, + updated_at: now, + outputId: 0, + userId: target.userId, + transactionId: reclaimTransaction.transactionId, + basketId: basket.basketId, + // The reclaim is deliberately racing the released target. Do not expose + // its output as wallet liquidity until a locally validated proof wins. + spendable: false, + change: true, + outputDescription: '', + vout: 0, + satoshis: target.noSendExpiryReclaimSatoshis!, + providedBy: 'storage', + purpose: 'change', + type: 'P2PKH', + txid: reclaimTxid, + derivationPrefix: target.noSendExpiryReclaimDerivationPrefix, + derivationSuffix: target.noSendExpiryReclaimDerivationSuffix, + scriptLength: scriptOffset.length, + scriptOffset: scriptOffset.offset, + lockingScript: scriptOffset.length > storage.getSettings().maxOutputScript ? undefined : lockingScript + }, + trx + ) + } + + await storage.updateOutput( + anchor.outputId, + { + spendable: false, + spentBy: reclaimTransaction.transactionId + }, + trx + ) + + const targetReq = target.txid == null ? undefined : await EntityProvenTxReq.fromStorageTxid(storage, target.txid, trx) + if (targetReq == null) throw new WERR_INTERNAL('BRC-177 protected transaction request is missing') + const req = EntityProvenTxReq.fromTxid(reclaimTxid, rawTx, targetReq.api.inputBEEF) + req.status = 'unsent' + req.addNotifyTransactionId(reclaimTransaction.transactionId) + req.addHistoryNote({ what: 'brc177-reclaim-activated', targetTxid: target.txid }) + return await req.insertOrMerge(storage, trx) +} + +async function activateReclaim( + storage: StorageProvider, + transaction: TableTransaction, + anchor: TableOutput, + expected: 'signed' | 'revocation-requested' | 'conflicted' +): Promise { + return await storage.transaction(async trx => { + if (!(await storage.compareAndSetNoSendExpiryState(transaction.transactionId, expected, 'reclaiming', trx))) + return undefined + return await insertReclaim(storage, transaction, anchor, trx) + }) +} + +async function finishReclaimWinner( + storage: StorageProvider, + target: TableTransaction, + reclaimTransaction: TableTransaction, + trx: TrxToken +): Promise { + const currentTarget = verifyOne( + await storage.findTransactions({ + partial: { transactionId: target.transactionId, userId: target.userId }, + trx + }) + ) + const currentReclaim = verifyOne( + await storage.findTransactions({ + partial: { transactionId: reclaimTransaction.transactionId, userId: target.userId }, + trx + }) + ) + if ( + currentTarget.noSendExpiryState !== 'reclaiming' || + currentTarget.status === 'completed' || + currentTarget.provenTxId != null || + currentReclaim.provenTxId == null + ) { + return false + } + if (!(await storage.compareAndSetNoSendExpiryState(currentTarget.transactionId, 'reclaiming', 'reclaimed', trx))) { + return false + } + if (currentTarget.status !== 'failed') { + await storage.updateTransactionStatus('failed', currentTarget.transactionId, undefined, undefined, trx) + } + await updateReqStatus(storage, currentTarget.txid, 'invalid', 'brc177-reclaim-won', trx) + const anchor = verifyOne( + await storage.findOutputs({ + partial: { + userId: currentTarget.userId, + txid: currentTarget.noSendExpiryAnchorTxid, + vout: currentTarget.noSendExpiryAnchorVout + }, + trx + }) + ) + await storage.updateOutput( + anchor.outputId, + { + spendable: false, + spentBy: currentReclaim.transactionId + }, + trx + ) + const reclaimOutput = verifyOne( + await storage.findOutputs({ + partial: { transactionId: currentReclaim.transactionId, vout: 0 }, + trx + }) + ) + await storage.updateOutput(reclaimOutput.outputId, { spendable: true }, trx) + return true +} + +async function finishTargetWinner(storage: StorageProvider, target: TableTransaction, trx: TrxToken): Promise { + const current = verifyOne( + await storage.findTransactions({ + partial: { transactionId: target.transactionId, userId: target.userId }, + trx + }) + ) + if ( + current.noSendExpiryState == null || + current.noSendExpiryState === 'reclaimed' || + current.noSendExpiryState === 'cancelled' || + current.noSendExpiryState === 'target-won' + ) { + return false + } + const reclaimTxid = current.noSendExpiryReclaimTxid + const reclaimTransaction = + reclaimTxid == null + ? undefined + : verifyOneOrNone( + await storage.findTransactions({ + partial: { userId: current.userId, txid: reclaimTxid }, + trx + }) + ) + // Contradictory completion/proof signals for competing spends cannot both + // describe the same best chain. Keep every output quarantined until the + // ordinary proof reconciliation machinery resolves that inconsistency. + if (reclaimTransaction?.status === 'completed' || reclaimTransaction?.provenTxId != null) { + await quarantineTransactionOutputs(storage, current.transactionId, trx) + await quarantineTransactionOutputs(storage, reclaimTransaction.transactionId, trx) + return false + } + // A status flag alone suppresses reclaim but never releases value or + // finalizes a winner; only the linked, locally validated proof may do that. + if (current.provenTxId == null) return false + if ( + !(await storage.compareAndSetNoSendExpiryState(current.transactionId, current.noSendExpiryState, 'target-won', trx)) + ) { + return false + } + if (reclaimTransaction != null && reclaimTransaction.status !== 'failed') { + await storage.updateTransactionStatus('failed', reclaimTransaction.transactionId, undefined, undefined, trx) + await updateReqStatus(storage, reclaimTxid, 'doubleSpend', 'brc177-target-won', trx) + } + const anchor = verifyOne( + await storage.findOutputs({ + partial: { + userId: current.userId, + txid: current.noSendExpiryAnchorTxid, + vout: current.noSendExpiryAnchorVout + }, + trx + }) + ) + await storage.updateOutput( + anchor.outputId, + { + spendable: false, + spentBy: current.transactionId + }, + trx + ) + await storage.updateTransaction( + current.transactionId, + { + noSendExpiryObservedAt: current.noSendExpiryObservedAt ?? Date.now(), + ...(current.status === 'nosend' ? { status: 'unproven' as const } : {}) + }, + trx + ) + return true +} + +async function reconcileRace( + storage: StorageProvider, + target: TableTransaction +): Promise<'reclaimed' | 'target' | 'deferred'> { + const reclaim = + target.noSendExpiryReclaimTxid == null + ? undefined + : verifyOneOrNone( + await storage.findTransactions({ + partial: { userId: target.userId, txid: target.noSendExpiryReclaimTxid } + }) + ) + if (target.status === 'completed' || target.provenTxId != null) { + const won = await storage.transaction(async trx => await finishTargetWinner(storage, target, trx)) + return won ? 'target' : 'deferred' + } + if (reclaim?.status === 'completed' || reclaim?.provenTxId != null) { + const won = await storage.transaction(async trx => await finishReclaimWinner(storage, target, reclaim, trx)) + return won ? 'reclaimed' : 'deferred' + } + return 'deferred' +} + +function isKnownOrMined(status: StatusForTxidResult['status'] | undefined): boolean { + return status === 'known' || status === 'mined' +} + +async function processObservationOrRace( + storage: StorageProvider, + transaction: TableTransaction, + targetStatus: StatusForTxidResult['status'] | undefined, + result: NoSendExpiryLifecycleResult +): Promise { + // A service's `mined` verdict is useful evidence that reclaim must stop, + // but it is not a locally verified Merkle proof. Only storage's proven + // state may finalize the target as the winner. + if (transaction.status === 'completed' || transaction.provenTxId != null) { + if (await markObservedTarget(storage, transaction, true)) result.targetWon++ + return true + } + if (transaction.noSendExpiryState === 'reclaiming') { + if (isKnownOrMined(targetStatus)) { + if (await noteTargetObservedDuringRace(storage, transaction)) result.observed++ + } + const race = await reconcileRace(storage, transaction) + if (race === 'reclaimed') result.reclaimed++ + else if (race === 'target') result.targetWon++ + else result.deferred++ + return true + } + const stateCanObserve = + transaction.noSendExpiryState === 'signed' || + transaction.noSendExpiryState === 'revocation-requested' || + transaction.noSendExpiryState === 'broadcast' || + transaction.noSendExpiryState === 'conflicted' + if (stateCanObserve && isKnownOrMined(targetStatus)) { + if (await markObservedTarget(storage, transaction, false)) result.observed++ + return true + } + return false +} + +async function requestRevocation( + storage: StorageProvider, + transaction: TableTransaction +): Promise<'revocation-requested' | undefined> { + if (transaction.noSendExpiryState === 'revocation-requested') return 'revocation-requested' + if (transaction.noSendExpiryState !== 'signed') return undefined + // Persist that the deadline has triggered before consulting fallible + // services. A clock correction or block-height reorganization must not + // reactivate a transaction after its expiry was already observed. + const changed = await storage.compareAndSetNoSendExpiryState( + transaction.transactionId, + 'signed', + 'revocation-requested' + ) + return changed ? 'revocation-requested' : undefined +} + +async function attemptReclaim( + storage: StorageProvider, + transaction: TableTransaction, + result: NoSendExpiryLifecycleResult, + expected: 'revocation-requested' | 'conflicted' = 'revocation-requested' +): Promise { + const anchor = verifyOne( + await storage.findOutputs({ + partial: { + userId: transaction.userId, + txid: transaction.noSendExpiryAnchorTxid, + vout: transaction.noSendExpiryAnchorVout + } + }) + ) + let anchorIsUtxo: boolean + try { + anchorIsUtxo = await storage.getServices().isUtxo(anchor) + } catch { + result.deferred++ + return + } + if (!anchorIsUtxo) { + if (expected === 'revocation-requested') { + await storage.compareAndSetNoSendExpiryState(transaction.transactionId, expected, 'conflicted') + } + result.deferred++ + return + } + + const reclaim = await activateReclaim(storage, transaction, anchor, expected) + if (reclaim == null) return + result.reclaimActivated++ + await storage.attemptToPostReqsToNetwork([reclaim]).catch(() => undefined) +} + +async function processTransaction( + storage: StorageProvider, + transaction: TableTransaction, + targetStatus: StatusForTxidResult['status'] | undefined, + nowSeconds: number, + height: number | undefined, + result: NoSendExpiryLifecycleResult +): Promise { + if (await processObservationOrRace(storage, transaction, targetStatus, result)) return + if (!isDue(transaction, nowSeconds, height)) return + if (transaction.noSendExpiryState === 'preparing' || transaction.noSendExpiryState === 'unsigned') { + if (await cancelUnsigned(storage, transaction)) result.cancelled++ + return + } + if (transaction.noSendExpiryState === 'conflicted') { + // A competing mempool spend can disappear. Resume only after the target + // is still explicitly unknown and the anchor service again gives a + // conclusive unspent verdict; `attemptReclaim` advances directly to the + // higher-ranked reclaiming state without reviving the released target. + if (targetStatus !== 'unknown') { + result.deferred++ + return + } + await attemptReclaim(storage, transaction, result, 'conflicted') + return + } + if ((await requestRevocation(storage, transaction)) == null) return + // Absence of a successful, explicit "unknown" verdict is not evidence + // that the protected transaction is absent. A service outage must never + // turn into authorization to double spend the anchor. + if (targetStatus !== 'unknown') { + result.deferred++ + return + } + await attemptReclaim(storage, transaction, result) +} + +interface LifecycleContext { + nowSeconds: number + height?: number + heightAttempted: boolean +} + +async function processPage( + storage: StorageProvider, + page: TableTransaction[], + context: LifecycleContext, + result: NoSendExpiryLifecycleResult +): Promise { + const transactions = await activeTransactionsOnPage(storage, page) + result.inspected += transactions.length + + const needsHeight = transactions.some(transaction => transaction.noSendExpiryMode === 'blockheight') + if (!context.heightAttempted && needsHeight) { + context.heightAttempted = true + try { + context.height = await storage.getServices().getHeight() + } catch {} + } + const statuses = await getStatuses(storage, transactions) + for (const transaction of transactions) { + try { + const targetStatus = transaction.txid ? statuses.get(transaction.txid) : undefined + await processTransaction(storage, transaction, targetStatus, context.nowSeconds, context.height, result) + } catch { + // A damaged row needs operator repair, but it must not block unrelated + // users or later deadlines in this multi-user monitor pass. + result.deferred++ + result.errors++ + } + } +} + +async function processState( + storage: StorageProvider, + state: Brc177NoSendExpiryState, + context: LifecycleContext, + result: NoSendExpiryLifecycleResult +): Promise { + for (let offset = 0; ; offset += QUERY_PAGE_SIZE) { + const page = await storage.findTransactions({ + partial: { noSendExpiryState: state }, + paged: { limit: QUERY_PAGE_SIZE, offset } + }) + await processPage(storage, page, context, result) + // State transitions can shift later offsets and defer some rows until + // the next five-second pass, but each pass remains strictly bounded in + // memory and every pass restarts from the beginning. + if (page.length < QUERY_PAGE_SIZE) return + } +} + +export async function processNoSendExpiryLifecycle(storage: StorageProvider): Promise { + const result = emptyResult() + const context: LifecycleContext = { + nowSeconds: Math.floor(Date.now() / 1000), + heightAttempted: false + } + + for (const state of ACTIVE_STATES) { + await processState(storage, state, context, result) + } + return result +} diff --git a/packages/wallet/wallet-toolbox/src/storage/methods/processAction.ts b/packages/wallet/wallet-toolbox/src/storage/methods/processAction.ts index 0607410ff..e97ad6dc8 100644 --- a/packages/wallet/wallet-toolbox/src/storage/methods/processAction.ts +++ b/packages/wallet/wallet-toolbox/src/storage/methods/processAction.ts @@ -25,7 +25,7 @@ import { verifyTruthy } from '../../utility/utilityHelpers' import { EntityProvenTxReq } from '../schema/entities/EntityProvenTxReq' -import { WERR_INTERNAL, WERR_INVALID_OPERATION } from '../../sdk/WERR_errors' +import { WERR_INTERNAL, WERR_INVALID_OPERATION, WERR_NOT_ACTIVE } from '../../sdk/WERR_errors' import { TableProvenTxReq } from '../schema/tables/TableProvenTxReq' import { TableProvenTx } from '../schema/tables/TableProvenTx' import { ProvenTxReqStatus, TransactionStatus } from '../../sdk/types' @@ -84,7 +84,7 @@ async function processActionCore( storage, 'wallet.storage.process_action.validate', parent, - async () => await validateCommitNewTxToStorageArgs(storage, userId, args) + async () => await validateCommitNewTxToStorageArgs(storage, auth, args) ) logger?.log('validated new tx updates to storage') ;({ req } = await traceProcessStep( @@ -381,9 +381,10 @@ interface ValidCommitNewTxToStorageArgs { async function validateCommitNewTxToStorageArgs( storage: StorageProvider, - userId: number, + auth: AuthId, params: StorageProcessActionArgs ): Promise { + const userId = verifyId(auth.userId) if (!params.reference || !params.txid || params.rawTx == null) { throw new WERR_INVALID_OPERATION('One or more expected params are undefined.') } @@ -409,6 +410,22 @@ async function validateCommitNewTxToStorageArgs( partial: { userId, reference: params.reference } }) ) + if (transaction.noSendExpiryState != null) { + if (auth.isActive !== true) throw new WERR_NOT_ACTIVE('BRC-177 requires the active storage provider') + if (!params.isNoSend || params.isSendWith) { + throw new WERR_INVALID_OPERATION('BRC-177 protected actions must remain noSend and cannot use sendWith') + } + if (transaction.noSendExpiryState !== 'unsigned' || transaction.noSendExpiryReclaimRawTx == null) { + throw new WERR_INVALID_OPERATION('BRC-177 protected action is not armed for signature release') + } + const deadline = verifyInteger(transaction.noSendExpiryDeadline) + const expired = transaction.noSendExpiryMode === 'blockheight' + ? (await storage.getServices().getHeight()) >= deadline + : Math.floor(Date.now() / 1000) >= deadline + if (expired) { + throw new WERR_INVALID_OPERATION('BRC-177 protected action has expired') + } + } if (!transaction.isOutgoing) throw new WERR_INVALID_OPERATION('isOutgoing is not true') if (transaction.inputBEEF == null) throw new WERR_INVALID_OPERATION() // Transaction must have unsigned or unprocessed status @@ -480,6 +497,10 @@ async function validateCommitNewTxToStorageArgs( }, postStatus } + if (transaction.noSendExpiryState != null) { + vargs.transactionUpdate.noSendExpiryState = 'signed' + vargs.transactionUpdate.noSendExpiryReleasedAt = Date.now() + } // update outputs with txid, script offsets and lengths, drop long output scripts from outputs table // outputs spendable will be updated for change to true and all others to !!o.tracked when tx has been broadcast @@ -501,6 +522,13 @@ async function commitNewTxToStorage( ): Promise { let log = vargs.log + const blockheightExpired = vargs.transaction.noSendExpiryState != null && + vargs.transaction.noSendExpiryMode === 'blockheight' && + (await storage.getServices().getHeight()) >= verifyInteger(vargs.transaction.noSendExpiryDeadline) + if (blockheightExpired) { + throw new WERR_INVALID_OPERATION('BRC-177 protected action expired before signature release') + } + log = stampLog(log, 'start storage commitNewTxToStorage') let req: EntityProvenTxReq | undefined @@ -508,6 +536,23 @@ async function commitNewTxToStorage( await storage.transaction(async trx => { log = stampLog(log, '... storage commitNewTxToStorage storage transaction start') + if (vargs.transaction.noSendExpiryState != null) { + const current = verifyOne(await storage.findTransactions({ + partial: { transactionId: vargs.transactionId, userId }, + trx + })) + if (current.noSendExpiryState !== 'unsigned') { + throw new WERR_INVALID_OPERATION('BRC-177 protected action changed before signature release') + } + if (current.noSendExpiryMode !== 'blockheight' && + Math.floor(Date.now() / 1000) >= verifyInteger(current.noSendExpiryDeadline)) { + throw new WERR_INVALID_OPERATION('BRC-177 protected action expired before signature release') + } + if (!await storage.compareAndSetNoSendExpiryState(current.transactionId, 'unsigned', 'signed', trx)) { + throw new WERR_INVALID_OPERATION('BRC-177 protected action changed before signature release') + } + } + // Create initial 'nosend' proven_tx_req record to store signed, valid rawTx and input beef req = await vargs.req.insertOrMerge(storage, trx) @@ -519,7 +564,9 @@ async function commitNewTxToStorage( log = stampLog(log, '... storage commitNewTxToStorage outputs updated') - await storage.updateTransaction(vargs.transactionId, vargs.transactionUpdate, trx) + const transactionUpdate = { ...vargs.transactionUpdate } + delete transactionUpdate.noSendExpiryState + await storage.updateTransaction(vargs.transactionId, transactionUpdate, trx) log = stampLog(log, '... storage commitNewTxToStorage storage transaction end') }) diff --git a/packages/wallet/wallet-toolbox/src/storage/remoting/StorageClientBase.ts b/packages/wallet/wallet-toolbox/src/storage/remoting/StorageClientBase.ts index f80d2eb33..b0dde0919 100644 --- a/packages/wallet/wallet-toolbox/src/storage/remoting/StorageClientBase.ts +++ b/packages/wallet/wallet-toolbox/src/storage/remoting/StorageClientBase.ts @@ -27,6 +27,10 @@ import { StorageInternalizeActionResult, StorageProcessActionArgs, StorageProcessActionResults, + StoragePrepareNoSendExpiryResult, + StorageActivateNoSendExpiryArgs, + StorageActivateNoSendExpiryResult, + StorageArmNoSendExpiryArgs, SyncChunk, UpdateProvenTxReqWithNewProvenTxArgs, UpdateProvenTxReqWithNewProvenTxResult, @@ -310,6 +314,24 @@ export abstract class StorageClientBase implements WalletStorageProvider { return await this.rpcCall('processAction', [auth, args]) } + async prepareNoSendExpiry( + auth: AuthId, + args: Validation.ValidCreateActionArgs + ): Promise { + return await this.rpcCall('prepareNoSendExpiry', [auth, args]) + } + + async activateNoSendExpiry( + auth: AuthId, + args: StorageActivateNoSendExpiryArgs + ): Promise { + return await this.rpcCall('activateNoSendExpiry', [auth, args]) + } + + async armNoSendExpiry(auth: AuthId, args: StorageArmNoSendExpiryArgs): Promise { + await this.rpcCall('armNoSendExpiry', [auth, args]) + } + async getCapabilities(): Promise { return await this.rpcCall('getCapabilities', []) } diff --git a/packages/wallet/wallet-toolbox/src/storage/remoting/StorageServer.ts b/packages/wallet/wallet-toolbox/src/storage/remoting/StorageServer.ts index 1dab14a14..543706216 100644 --- a/packages/wallet/wallet-toolbox/src/storage/remoting/StorageServer.ts +++ b/packages/wallet/wallet-toolbox/src/storage/remoting/StorageServer.ts @@ -60,6 +60,8 @@ import { ACTION_BATCH_MAX_PACK_BYTES, ACTION_BATCH_MAX_PACK_ITEMS } from '../met const storageRpcMethods = new Set([ 'abortAction', 'abortActionBatch', + 'activateNoSendExpiry', + 'armNoSendExpiry', 'adminStats', 'beginActionBatch', 'commitActionBatch', @@ -85,6 +87,7 @@ const storageRpcMethods = new Set([ 'makeAvailable', 'migrate', 'prepareActionBatchCommit', + 'prepareNoSendExpiry', 'processAction', 'processSyncChunk', 'relinquishCertificate', @@ -98,6 +101,8 @@ const storageRpcMethods = new Set([ const authIdRpcMethods = new Set([ 'abortAction', 'abortActionBatch', + 'activateNoSendExpiry', + 'armNoSendExpiry', 'beginActionBatch', 'commitActionBatch', 'commitActionBatchByDigest', @@ -114,6 +119,7 @@ const authIdRpcMethods = new Set([ 'listCertificates', 'listOutputs', 'prepareActionBatchCommit', + 'prepareNoSendExpiry', 'processAction', 'relinquishCertificate', 'relinquishOutput', @@ -122,13 +128,16 @@ const authIdRpcMethods = new Set([ 'setActive' ]) -const actionBatchRpcMethods = new Set([ +const activeStorageRpcMethods = new Set([ 'abortActionBatch', + 'activateNoSendExpiry', + 'armNoSendExpiry', 'beginActionBatch', 'commitActionBatch', 'commitActionBatchByDigest', 'extendActionBatch', 'prepareActionBatchCommit', + 'prepareNoSendExpiry', 'renewActionBatch', 'resumeActionBatch' ]) @@ -836,7 +845,7 @@ export class StorageServer { private async authorizeStandardRpcCall(method: string, params: any[], req: Request): Promise { if (authIdRpcMethods.has(method)) { - await this.bindAuthenticatedAuth(params, req, actionBatchRpcMethods.has(method)) + await this.bindAuthenticatedAuth(params, req, activeStorageRpcMethods.has(method)) return } await this.validateParam0(params, req) @@ -871,7 +880,7 @@ export class StorageServer { const { user } = await this.storage.findOrInsertUser(identityKey) const isActive = user.activeStorage === this.storage.getSettings().storageIdentityKey if (requireActive && !isActive) { - throw new WERR_NOT_ACTIVE("action batch methods require the authenticated user's active storage provider") + throw new WERR_NOT_ACTIVE("this method requires the authenticated user's active storage provider") } return { identityKey, diff --git a/packages/wallet/wallet-toolbox/src/storage/remoting/__test/StorageClient.test.ts b/packages/wallet/wallet-toolbox/src/storage/remoting/__test/StorageClient.test.ts index 6408cb8f3..00314489c 100644 --- a/packages/wallet/wallet-toolbox/src/storage/remoting/__test/StorageClient.test.ts +++ b/packages/wallet/wallet-toolbox/src/storage/remoting/__test/StorageClient.test.ts @@ -116,6 +116,33 @@ describe('StorageClient tests', () => { expect(sent.sendWithResults).toHaveLength(1) }) + test('1a creates and arms BRC-177 entirely through authenticated remote storage', async () => { + await expect(client.storage.getCapabilities()).resolves.toMatchObject({ + brc177NoSendExpiry: { version: 1 } + }) + const created = await client.wallet.createAction({ + description: 'remote BRC-177 protected action', + labels: ['p nosend expiry seconds 3600'], + outputs: [ + { + satoshis: 500, + lockingScript: '51', + outputDescription: 'remote protected output' + } + ], + options: { noSend: true, randomizeOutputs: false } + }) + + const target = verifyOne( + await server.setup.activeStorage.findTransactions({ + partial: { userId: server.setup.userId, txid: created.txid } + }) + ) + expect(target.noSendExpiryState).toBe('signed') + expect(target.noSendExpiryReclaimTxid).toMatch(/^[0-9a-f]{64}$/) + expect(target.noSendExpiryReclaimRawTx?.length).toBeGreaterThan(0) + }) + test('1b authenticated binary action batch blob upload', async () => { const firstAction = Validation.validateCreateActionArgs({ description: 'stage binary action batch blob', diff --git a/packages/wallet/wallet-toolbox/src/storage/remoting/__test/StorageServerRpc.test.ts b/packages/wallet/wallet-toolbox/src/storage/remoting/__test/StorageServerRpc.test.ts index 7100d43c4..a0acb9a33 100644 --- a/packages/wallet/wallet-toolbox/src/storage/remoting/__test/StorageServerRpc.test.ts +++ b/packages/wallet/wallet-toolbox/src/storage/remoting/__test/StorageServerRpc.test.ts @@ -139,6 +139,50 @@ describe('StorageServer JSON-RPC boundary', () => { expect(consoleLog).toHaveBeenCalledWith(expect.stringContaining('trace-id')) }) + test('authenticates and dispatches the BRC-177 storage lifecycle RPCs', async () => { + const prepareNoSendExpiry = jest.fn(async () => ({ anchorSatoshis: 10 })) + const activateNoSendExpiry = jest.fn(async () => ({ deadline: 20 })) + const armNoSendExpiry = jest.fn(async () => undefined) + const server = makeServer({ prepareNoSendExpiry, activateNoSendExpiry, armNoSendExpiry }) + + for (const [id, method] of ['prepareNoSendExpiry', 'activateNoSendExpiry', 'armNoSendExpiry'].entries()) { + const captured = makeResponse() + await invoke(server, 'handleRpcRequest', makeRequest({ + jsonrpc: '2.0', + method, + params: [{}, { caller: method }], + id + }), captured.response) + expect(captured.statusCode).toBe(200) + expect(captured.body).toMatchObject({ jsonrpc: '2.0', id }) + } + + for (const handler of [prepareNoSendExpiry, activateNoSendExpiry, armNoSendExpiry]) { + expect(handler).toHaveBeenCalledWith( + expect.objectContaining({ identityKey: 'alice', userId: 7 }), + expect.any(Object) + ) + } + }) + + test('rejects BRC-177 lifecycle mutations on an inactive remote store', async () => { + const server = makeServer({ + findOrInsertUser: jest.fn(async (identityKey: string) => ({ + user: { + activeStorage: 'different-storage-key', + identityKey, + userId: 7 + } + })) + }) + const request = makeRequest({}, {}, 'alice') + for (const method of ['prepareNoSendExpiry', 'activateNoSendExpiry', 'armNoSendExpiry']) { + await expect( + invoke(server, 'authorizeStandardRpcCall', method, [{}, {}], request) + ).rejects.toThrow("this method requires the authenticated user's active storage provider") + } + }) + test('correlates the HTTP, authorization, handler, and RPC spans', async () => { const events: TelemetryEvent[] = [] let nextSpanId = 1 diff --git a/packages/wallet/wallet-toolbox/src/storage/schema/KnexMigrations.ts b/packages/wallet/wallet-toolbox/src/storage/schema/KnexMigrations.ts index 5a4ef9e79..2cb8856d9 100644 --- a/packages/wallet/wallet-toolbox/src/storage/schema/KnexMigrations.ts +++ b/packages/wallet/wallet-toolbox/src/storage/schema/KnexMigrations.ts @@ -16,6 +16,7 @@ export const MONITOR_CREATED_AT_INDEX_MIGRATION = '2026-07-14-002 add monitor cr export const CREATE_ACTION_FUNDING_INDEX_MIGRATION = '2026-08-02-001 add createAction funding selection index' export const PAYMENT_REPLAY_MIGRATION = '2026-08-04-001 add payment replay claims' export const MANAGED_CHANGE_POLICY_MIGRATION = '2026-08-10-001 upgrade managed change liquidity defaults' +export const BRC177_NO_SEND_EXPIRY_MIGRATION = '2026-08-30-001 add brc177 nosend expiry state' interface Migration { up: (knex: Knex) => Promise @@ -177,6 +178,52 @@ export class KnexMigrations implements MigrationSource { } } + migrations[BRC177_NO_SEND_EXPIRY_MIGRATION] = { + async up(knex) { + await knex.schema.alterTable('transactions', table => { + table.string('noSendExpiryMode', 16).nullable() + table.bigInteger('noSendExpiryValue').unsigned().nullable() + table.bigInteger('noSendExpiryDeadline').unsigned().nullable() + table.string('noSendExpiryState', 24).nullable() + table.string('noSendExpiryAnchorTxid', 64).nullable() + table.integer('noSendExpiryAnchorVout').unsigned().nullable() + table.bigInteger('noSendExpiryReleasedAt').unsigned().nullable() + table.bigInteger('noSendExpiryObservedAt').unsigned().nullable() + table.string('noSendExpiryReclaimTxid', 64).nullable() + table.binary('noSendExpiryReclaimRawTx').nullable() + table.string('noSendExpiryReclaimDerivationPrefix', 32).nullable() + table.string('noSendExpiryReclaimDerivationSuffix', 32).nullable() + table.bigInteger('noSendExpiryReclaimSatoshis').unsigned().nullable() + table.index(['noSendExpiryState', 'noSendExpiryDeadline'], 'idx_transactions_nosend_expiry') + table.index(['userId', 'noSendExpiryReclaimTxid'], 'idx_transactions_nosend_reclaim') + }) + if ((await determineDBType(knex)) === 'MySQL') { + await knex.raw('ALTER TABLE transactions MODIFY COLUMN noSendExpiryReclaimRawTx LONGBLOB') + } + }, + async down(knex) { + await knex.schema.alterTable('transactions', table => { + table.dropIndex(['noSendExpiryState', 'noSendExpiryDeadline'], 'idx_transactions_nosend_expiry') + table.dropIndex(['userId', 'noSendExpiryReclaimTxid'], 'idx_transactions_nosend_reclaim') + table.dropColumns( + 'noSendExpiryMode', + 'noSendExpiryValue', + 'noSendExpiryDeadline', + 'noSendExpiryState', + 'noSendExpiryAnchorTxid', + 'noSendExpiryAnchorVout', + 'noSendExpiryReleasedAt', + 'noSendExpiryObservedAt', + 'noSendExpiryReclaimTxid', + 'noSendExpiryReclaimRawTx', + 'noSendExpiryReclaimDerivationPrefix', + 'noSendExpiryReclaimDerivationSuffix', + 'noSendExpiryReclaimSatoshis' + ) + }) + } + } + migrations['2026-07-15-001 add action batch reservations and blobs'] = { async up(knex) { const dbtype = await determineDBType(knex) diff --git a/packages/wallet/wallet-toolbox/src/storage/schema/StorageIdbSchema.ts b/packages/wallet/wallet-toolbox/src/storage/schema/StorageIdbSchema.ts index 01bfbc6a4..653d95c3c 100644 --- a/packages/wallet/wallet-toolbox/src/storage/schema/StorageIdbSchema.ts +++ b/packages/wallet/wallet-toolbox/src/storage/schema/StorageIdbSchema.ts @@ -148,6 +148,8 @@ export interface StorageIdbSchema { provenTxId: number reference: string status: TransactionStatus + noSendExpiryState: string + noSendExpiryReclaimTxid: string } } txLabels: { diff --git a/packages/wallet/wallet-toolbox/src/storage/schema/entities/EntityOutput.ts b/packages/wallet/wallet-toolbox/src/storage/schema/entities/EntityOutput.ts index 6615d4a73..372e6fc70 100644 --- a/packages/wallet/wallet-toolbox/src/storage/schema/entities/EntityOutput.ts +++ b/packages/wallet/wallet-toolbox/src/storage/schema/entities/EntityOutput.ts @@ -3,6 +3,23 @@ import { optionalArraysEqual, verifyId, verifyOneOrNone } from '../../../utility import { TableOutput } from '../tables/TableOutput' import { EntityBase, EntityStorage, SyncMap } from './EntityBase' +async function quarantineUnresolvedBrc177Reclaim( + output: EntityOutput, + storage: EntityStorage, + trx?: TrxToken +): Promise { + if (!output.spendable || output.txid == null) return + const target = verifyOneOrNone( + await storage.findTransactions({ + partial: { userId: output.userId, noSendExpiryReclaimTxid: output.txid }, + trx + }) + ) + if (target != null && target.noSendExpiryState !== 'reclaimed') { + output.spendable = false + } +} + export class EntityOutput extends EntityBase { constructor (api?: TableOutput) { const now = new Date() @@ -300,6 +317,7 @@ export class EntityOutput extends EntityBase { this.basketId = this.basketId ? syncMap.outputBasket.idMap[this.basketId] : undefined this.transactionId = syncMap.transaction.idMap[this.transactionId] this.spentBy = this.spentBy ? syncMap.transaction.idMap[this.spentBy] : undefined + await quarantineUnresolvedBrc177Reclaim(this, storage, trx) this.outputId = 0 this.outputId = await storage.insertOutput(this.toApi(), trx) } @@ -326,6 +344,7 @@ export class EntityOutput extends EntityBase { this.scriptLength = ei.scriptLength this.scriptOffset = ei.scriptOffset this.lockingScript = ei.lockingScript + await quarantineUnresolvedBrc177Reclaim(this, storage, trx) this.updated_at = new Date(Math.max(ei.updated_at.getTime(), this.updated_at.getTime())) await storage.updateOutput(this.id, this.toApi(), trx) wasMerged = true diff --git a/packages/wallet/wallet-toolbox/src/storage/schema/entities/EntityTransaction.ts b/packages/wallet/wallet-toolbox/src/storage/schema/entities/EntityTransaction.ts index c8d6b6dcc..a497c55d8 100644 --- a/packages/wallet/wallet-toolbox/src/storage/schema/entities/EntityTransaction.ts +++ b/packages/wallet/wallet-toolbox/src/storage/schema/entities/EntityTransaction.ts @@ -1,12 +1,145 @@ import { TransactionStatus } from '../../../sdk/types' import { TrxToken } from '../../../sdk/WalletStorage.interfaces' import { optionalArraysEqual, verifyId, verifyOneOrNone } from '../../../utility/utilityHelpers' +import { brc177NoSendExpiryStateRank } from '../../../utility/brc177NoSendExpiry' import { TableOutput } from '../tables/TableOutput' import { TableTransaction } from '../tables/TableTransaction' import { EntityBase, EntityStorage, SyncMap } from './EntityBase' import { EntityProvenTx } from './EntityProvenTx' import { Transaction as BsvTransaction, TransactionInput } from '@bsv/sdk' +function earliestDefined (a: number | undefined, b: number | undefined): number | undefined { + if (a == null) return b + if (b == null) return a + return Math.min(a, b) +} + +type NoSendExpirySnapshot = Pick< + TableTransaction, + | 'noSendExpiryMode' + | 'noSendExpiryValue' + | 'noSendExpiryDeadline' + | 'noSendExpiryState' + | 'noSendExpiryAnchorTxid' + | 'noSendExpiryAnchorVout' + | 'noSendExpiryReleasedAt' + | 'noSendExpiryObservedAt' + | 'noSendExpiryReclaimTxid' + | 'noSendExpiryReclaimRawTx' + | 'noSendExpiryReclaimDerivationPrefix' + | 'noSendExpiryReclaimDerivationSuffix' + | 'noSendExpiryReclaimSatoshis' +> + +function noSendExpirySnapshot (transaction: TableTransaction): NoSendExpirySnapshot { + return { + noSendExpiryMode: transaction.noSendExpiryMode, + noSendExpiryValue: transaction.noSendExpiryValue, + noSendExpiryDeadline: transaction.noSendExpiryDeadline, + noSendExpiryState: transaction.noSendExpiryState, + noSendExpiryAnchorTxid: transaction.noSendExpiryAnchorTxid, + noSendExpiryAnchorVout: transaction.noSendExpiryAnchorVout, + noSendExpiryReleasedAt: transaction.noSendExpiryReleasedAt, + noSendExpiryObservedAt: transaction.noSendExpiryObservedAt, + noSendExpiryReclaimTxid: transaction.noSendExpiryReclaimTxid, + noSendExpiryReclaimRawTx: transaction.noSendExpiryReclaimRawTx, + noSendExpiryReclaimDerivationPrefix: transaction.noSendExpiryReclaimDerivationPrefix, + noSendExpiryReclaimDerivationSuffix: transaction.noSendExpiryReclaimDerivationSuffix, + noSendExpiryReclaimSatoshis: transaction.noSendExpiryReclaimSatoshis + } +} + +function mergeNoSendExpiryMetadata ( + target: TableTransaction, + current: NoSendExpirySnapshot, + incoming: TableTransaction, + lifecycleAdvanced: boolean, + targetWinnerSupersedesReclaim: boolean +): void { + target.noSendExpiryMode = current.noSendExpiryMode ?? incoming.noSendExpiryMode + target.noSendExpiryValue = current.noSendExpiryValue ?? incoming.noSendExpiryValue + target.noSendExpiryDeadline = earliestDefined(current.noSendExpiryDeadline, incoming.noSendExpiryDeadline) + + target.noSendExpiryState = current.noSendExpiryState + if (lifecycleAdvanced) target.noSendExpiryState = incoming.noSendExpiryState + if (targetWinnerSupersedesReclaim) target.noSendExpiryState = 'target-won' + + target.noSendExpiryAnchorTxid = current.noSendExpiryAnchorTxid ?? incoming.noSendExpiryAnchorTxid + target.noSendExpiryAnchorVout = current.noSendExpiryAnchorVout ?? incoming.noSendExpiryAnchorVout + target.noSendExpiryReleasedAt = earliestDefined( + current.noSendExpiryReleasedAt, + incoming.noSendExpiryReleasedAt + ) + target.noSendExpiryObservedAt = earliestDefined( + current.noSendExpiryObservedAt, + incoming.noSendExpiryObservedAt + ) + target.noSendExpiryReclaimTxid = current.noSendExpiryReclaimTxid ?? incoming.noSendExpiryReclaimTxid + target.noSendExpiryReclaimRawTx = current.noSendExpiryReclaimRawTx ?? incoming.noSendExpiryReclaimRawTx + target.noSendExpiryReclaimDerivationPrefix = + current.noSendExpiryReclaimDerivationPrefix ?? incoming.noSendExpiryReclaimDerivationPrefix + target.noSendExpiryReclaimDerivationSuffix = + current.noSendExpiryReclaimDerivationSuffix ?? incoming.noSendExpiryReclaimDerivationSuffix + target.noSendExpiryReclaimSatoshis = + current.noSendExpiryReclaimSatoshis ?? incoming.noSendExpiryReclaimSatoshis +} + +function carriesProofEvidence (transaction: TableTransaction): boolean { + // `completed` is a useful conservative signal, but only a mapped provenTx + // record carries the proof material that may advance BRC-177 finality. + return transaction.provenTxId != null +} + +function mappedProvenTxId (incoming: TableTransaction, syncMap: SyncMap): number | undefined { + return incoming.provenTxId == null ? undefined : syncMap.provenTx.idMap[incoming.provenTxId] +} + +function mergeOrdinaryTransactionProperties ( + target: TableTransaction, + incoming: TableTransaction, + syncMap: SyncMap +): void { + target.version = incoming.version + target.lockTime = incoming.lockTime + target.isOutgoing = incoming.isOutgoing + target.status = incoming.status + target.provenTxId = mappedProvenTxId(incoming, syncMap) + target.satoshis = incoming.satoshis + target.txid = incoming.txid + target.description = incoming.description + target.rawTx = incoming.rawTx + target.inputBEEF = incoming.inputBEEF +} + +function mergeProofEvidence ( + target: TableTransaction, + incoming: TableTransaction, + syncMap: SyncMap +): void { + target.status = incoming.status + target.provenTxId = mappedProvenTxId(incoming, syncMap) + target.rawTx ??= incoming.rawTx + target.inputBEEF ??= incoming.inputBEEF +} + +async function quarantineReclaimOutput ( + storage: EntityStorage, + target: TableTransaction, + reclaimTxid: string | undefined, + trx?: TrxToken +): Promise { + if (reclaimTxid == null) return + const reclaimOutput = verifyOneOrNone( + await storage.findOutputs({ + partial: { userId: target.userId, txid: reclaimTxid, vout: 0 }, + trx + }) + ) + if (reclaimOutput?.spendable) { + await storage.updateOutput(reclaimOutput.outputId, { spendable: false }, trx) + } +} + export class EntityTransaction extends EntityBase { /** * @returns @bsv/sdk Transaction object from parsed rawTx. @@ -199,6 +332,85 @@ export class EntityTransaction extends EntityBase { this.api.rawTx = v } + get noSendExpiryMode () { + return this.api.noSendExpiryMode + } + set noSendExpiryMode (v) { + this.api.noSendExpiryMode = v + } + get noSendExpiryValue () { + return this.api.noSendExpiryValue + } + set noSendExpiryValue (v) { + this.api.noSendExpiryValue = v + } + get noSendExpiryDeadline () { + return this.api.noSendExpiryDeadline + } + set noSendExpiryDeadline (v) { + this.api.noSendExpiryDeadline = v + } + get noSendExpiryState () { + return this.api.noSendExpiryState + } + set noSendExpiryState (v) { + this.api.noSendExpiryState = v + } + get noSendExpiryAnchorTxid () { + return this.api.noSendExpiryAnchorTxid + } + set noSendExpiryAnchorTxid (v) { + this.api.noSendExpiryAnchorTxid = v + } + get noSendExpiryAnchorVout () { + return this.api.noSendExpiryAnchorVout + } + set noSendExpiryAnchorVout (v) { + this.api.noSendExpiryAnchorVout = v + } + get noSendExpiryReleasedAt () { + return this.api.noSendExpiryReleasedAt + } + set noSendExpiryReleasedAt (v) { + this.api.noSendExpiryReleasedAt = v + } + get noSendExpiryObservedAt () { + return this.api.noSendExpiryObservedAt + } + set noSendExpiryObservedAt (v) { + this.api.noSendExpiryObservedAt = v + } + get noSendExpiryReclaimTxid () { + return this.api.noSendExpiryReclaimTxid + } + set noSendExpiryReclaimTxid (v) { + this.api.noSendExpiryReclaimTxid = v + } + get noSendExpiryReclaimRawTx () { + return this.api.noSendExpiryReclaimRawTx + } + set noSendExpiryReclaimRawTx (v) { + this.api.noSendExpiryReclaimRawTx = v + } + get noSendExpiryReclaimDerivationPrefix () { + return this.api.noSendExpiryReclaimDerivationPrefix + } + set noSendExpiryReclaimDerivationPrefix (v) { + this.api.noSendExpiryReclaimDerivationPrefix = v + } + get noSendExpiryReclaimDerivationSuffix () { + return this.api.noSendExpiryReclaimDerivationSuffix + } + set noSendExpiryReclaimDerivationSuffix (v) { + this.api.noSendExpiryReclaimDerivationSuffix = v + } + get noSendExpiryReclaimSatoshis () { + return this.api.noSendExpiryReclaimSatoshis + } + set noSendExpiryReclaimSatoshis (v) { + this.api.noSendExpiryReclaimSatoshis = v + } + // Extended (computed / dependent entity) Properties // get labels() { return this.api.labels } // set labels(v: string[] | undefined) { this.api.labels = v } @@ -235,6 +447,19 @@ export class EntityTransaction extends EntityBase { eo.description === ei.description && optionalArraysEqual(eo.rawTx, ei.rawTx) && optionalArraysEqual(eo.inputBEEF, ei.inputBEEF) && + optionalArraysEqual(eo.noSendExpiryReclaimRawTx, ei.noSendExpiryReclaimRawTx) && + eo.noSendExpiryMode === ei.noSendExpiryMode && + eo.noSendExpiryValue === ei.noSendExpiryValue && + eo.noSendExpiryDeadline === ei.noSendExpiryDeadline && + eo.noSendExpiryState === ei.noSendExpiryState && + eo.noSendExpiryAnchorTxid === ei.noSendExpiryAnchorTxid && + eo.noSendExpiryAnchorVout === ei.noSendExpiryAnchorVout && + eo.noSendExpiryReleasedAt === ei.noSendExpiryReleasedAt && + eo.noSendExpiryObservedAt === ei.noSendExpiryObservedAt && + eo.noSendExpiryReclaimTxid === ei.noSendExpiryReclaimTxid && + eo.noSendExpiryReclaimDerivationPrefix === ei.noSendExpiryReclaimDerivationPrefix && + eo.noSendExpiryReclaimDerivationSuffix === ei.noSendExpiryReclaimDerivationSuffix && + eo.noSendExpiryReclaimSatoshis === ei.noSendExpiryReclaimSatoshis && (eo.provenTxId == null) === (ei.provenTxId == null) && !(ei.provenTxId && eo.provenTxId !== ((syncMap != null) ? syncMap.provenTx.idMap[verifyId(ei.provenTxId)] : ei.provenTxId)) ) { return true } @@ -285,29 +510,56 @@ export class EntityTransaction extends EntityBase { syncMap: SyncMap, trx?: TrxToken ): Promise { - let wasMerged = false - if (ei.updated_at > this.updated_at) { - // Properties that are never updated: - // transactionId - // userId - // reference - - // Merged properties - this.version = ei.version - this.lockTime = ei.lockTime - this.isOutgoing = ei.isOutgoing - this.status = ei.status - this.provenTxId = ei.provenTxId ? syncMap.provenTx.idMap[ei.provenTxId] : undefined - this.satoshis = ei.satoshis - this.txid = ei.txid - this.description = ei.description - this.rawTx = ei.rawTx - this.inputBEEF = ei.inputBEEF - this.updated_at = new Date(Math.max(ei.updated_at.getTime(), this.updated_at.getTime())) - await storage.updateTransaction(this.id, this.toApi(), trx) - wasMerged = true + const currentExpiryRank = brc177NoSendExpiryStateRank(this.noSendExpiryState) + const incomingExpiryRank = brc177NoSendExpiryStateRank(ei.noSendExpiryState) + const lifecycleAdvanced = incomingExpiryRank > currentExpiryRank + const incomingCarriesProof = carriesProofEvidence(ei) + const addsProofEvidence = incomingCarriesProof && !carriesProofEvidence(this.api) + const targetWinnerSupersedesReclaim = + this.noSendExpiryState === 'reclaimed' && + (ei.noSendExpiryState === 'target-won' || incomingCarriesProof) + const incomingIsNewer = ei.updated_at > this.updated_at + const shouldMerge = + lifecycleAdvanced || + addsProofEvidence || + (incomingIsNewer && (incomingExpiryRank >= currentExpiryRank || incomingCarriesProof)) + if (!shouldMerge) return false + + const currentExpiry = noSendExpirySnapshot(this.api) + // Properties that are never updated: + // transactionId + // userId + // reference + + // A stale storage can carry a more advanced BRC-177 state because wall + // clocks differ. In that case merge only lifecycle data; overwriting the + // rest of the row would regress newer proof and transaction state. The + // one exception is positive local proof evidence, which must reach a + // reclaiming storage so the race can be resolved safely. + const mergeOrdinaryProperties = + incomingIsNewer && (incomingExpiryRank >= currentExpiryRank || incomingCarriesProof) + if (mergeOrdinaryProperties) { + mergeOrdinaryTransactionProperties(this.api, ei, syncMap) + } else if (incomingCarriesProof) { + // Proof evidence is independently monotonic and cannot be discarded + // merely because the proving store's wall clock is behind. Avoid + // copying unrelated stale row fields while retaining the proof link. + mergeProofEvidence(this.api, ei, syncMap) + } + mergeNoSendExpiryMetadata(this.api, currentExpiry, ei, lifecycleAdvanced, targetWinnerSupersedesReclaim) + this.updated_at = new Date(Math.max(ei.updated_at.getTime(), this.updated_at.getTime())) + // Knex serialization mutates its update object. Keep this entity's Date + // and byte-array representation intact for any subsequent sync merge in + // the same cycle. + await storage.updateTransaction(this.id, { ...this.toApi() }, trx) + if (targetWinnerSupersedesReclaim) { + // Lifecycle rank deliberately gives a contradictory target proof the + // conservative precedence. Output rows still use timestamp merging, + // so quarantine reclaimed liquidity here even when clock skew would + // otherwise leave that output spendable. + await quarantineReclaimOutput(storage, this.api, this.noSendExpiryReclaimTxid, trx) } - return wasMerged + return true } async getProvenTx (storage: EntityStorage, trx?: TrxToken): Promise { diff --git a/packages/wallet/wallet-toolbox/src/storage/schema/entities/__tests/OutputTests.test.ts b/packages/wallet/wallet-toolbox/src/storage/schema/entities/__tests/OutputTests.test.ts index a4dbe4ff0..0b503e061 100644 --- a/packages/wallet/wallet-toolbox/src/storage/schema/entities/__tests/OutputTests.test.ts +++ b/packages/wallet/wallet-toolbox/src/storage/schema/entities/__tests/OutputTests.test.ts @@ -1,4 +1,4 @@ -import { createSyncMap, TableOutput } from '../../../../../src' +import { createSyncMap, TableOutput, TableTransaction } from '../../../../../src' import { TestUtilsWalletStorage as _tu, TestWalletNoSetup } from '../../../../../test/utils/TestUtilsWalletStorage' import { EntityOutput } from '../EntityOutput' @@ -340,6 +340,108 @@ describe('Output class method tests', () => { expect(unchangedRecord[0].spendable).toBe(true) }) + test('BRC-177 reclaim outputs stay quarantined when synchronized after a target winner', async () => { + const ctx = ctxs[0] + const now = new Date() + const reclaimTxid = 'fa'.repeat(32) + const target: TableTransaction = { + created_at: now, + updated_at: now, + transactionId: 0, + userId: 1, + status: 'completed', + reference: 'YnJjMTc3LXRhcmdldA==', + isOutgoing: true, + satoshis: -1, + description: 'BRC-177 target winner', + txid: 'fb'.repeat(32), + noSendExpiryState: 'target-won', + noSendExpiryReclaimTxid: reclaimTxid + } + target.transactionId = await ctx.activeStorage.insertTransaction(target) + const reclaim: TableTransaction = { + created_at: now, + updated_at: now, + transactionId: 0, + userId: 1, + status: 'failed', + reference: 'YnJjMTc3LXJlY2xhaW0=', + isOutgoing: true, + satoshis: -1, + description: 'BRC-177 expiry reclaim', + txid: reclaimTxid + } + reclaim.transactionId = await ctx.activeStorage.insertTransaction(reclaim) + + const existing: TableOutput = { + created_at: new Date(now.getTime() - 2_000), + updated_at: new Date(now.getTime() - 1_000), + outputId: 0, + userId: 1, + transactionId: reclaim.transactionId, + spendable: false, + change: true, + satoshis: 1_000, + outputDescription: '', + vout: 0, + type: 'P2PKH', + providedBy: 'storage', + purpose: 'change', + txid: reclaimTxid + } + existing.outputId = await ctx.activeStorage.insertOutput(existing) + const entity = new EntityOutput(existing) + const syncMap = createSyncMap() + syncMap.transaction.idMap[reclaim.transactionId] = reclaim.transactionId + + await expect( + entity.mergeExisting( + ctx.activeStorage, + undefined, + { ...existing, updated_at: now, spendable: true }, + syncMap + ) + ).resolves.toBe(true) + expect(entity.spendable).toBe(false) + expect( + (await ctx.activeStorage.findOutputs({ partial: { outputId: existing.outputId } }))[0].spendable + ).toBe(false) + + const secondReclaimTxid = 'fc'.repeat(32) + const secondTarget = { + ...target, + transactionId: 0, + reference: 'YnJjMTc3LXRhcmdldC0y', + txid: 'fd'.repeat(32), + noSendExpiryReclaimTxid: secondReclaimTxid + } + secondTarget.transactionId = await ctx.activeStorage.insertTransaction(secondTarget) + const secondReclaim = { + ...reclaim, + transactionId: 0, + reference: 'YnJjMTc3LXJlY2xhaW0tMg==', + txid: secondReclaimTxid + } + secondReclaim.transactionId = await ctx.activeStorage.insertTransaction(secondReclaim) + const incomingTransactionId = 9_001 + const incoming = new EntityOutput({ + ...existing, + outputId: 9_002, + transactionId: incomingTransactionId, + txid: secondReclaimTxid, + spendable: true + }) + syncMap.transaction.idMap[incomingTransactionId] = secondReclaim.transactionId + + await incoming.mergeNew(ctx.activeStorage, 1, syncMap) + expect(incoming.spendable).toBe(false) + expect( + (await ctx.activeStorage.findOutputs({ + partial: { transactionId: secondReclaim.transactionId, vout: 0 } + }))[0].spendable + ).toBe(false) + }) + // Test: Output entity getters and setters test('Output entity getters and setters', async () => { const now = new Date() diff --git a/packages/wallet/wallet-toolbox/src/storage/schema/entities/__tests/TransactionTests.test.ts b/packages/wallet/wallet-toolbox/src/storage/schema/entities/__tests/TransactionTests.test.ts index 3252b7802..44caf3619 100644 --- a/packages/wallet/wallet-toolbox/src/storage/schema/entities/__tests/TransactionTests.test.ts +++ b/packages/wallet/wallet-toolbox/src/storage/schema/entities/__tests/TransactionTests.test.ts @@ -242,6 +242,142 @@ describe('Transaction class method tests', () => { } }) + test('BRC-177 sync advances lifecycle state but rejects clock-skew regression', async () => { + for (const { activeStorage } of ctxs) { + const txData = await _tu.insertTestTransaction(activeStorage, undefined, true) + const local: TableTransaction = { + ...txData.tx, + updated_at: new Date('2026-08-30T12:00:00.000Z'), + noSendExpiryMode: 'timestamp', + noSendExpiryValue: 2_000_000_000, + noSendExpiryDeadline: 0, + noSendExpiryState: 'signed', + noSendExpiryAnchorTxid: '17'.repeat(32), + noSendExpiryAnchorVout: 0, + noSendExpiryReclaimRawTx: [1, 2, 3], + description: 'current transaction data' + } + await activeStorage.updateTransaction(local.transactionId, { ...local }) + const entity = new EntityTransaction({ + ...local, + created_at: new Date(local.created_at), + updated_at: new Date(local.updated_at) + }) + const syncMap = createSyncMap() + const proven = await _tu.insertTestProvenTx(activeStorage, local.txid) + const incomingProvenTxId = 9_999 + syncMap.provenTx.idMap[incomingProvenTxId] = proven.provenTxId + + const staleButNewer: TableTransaction = { + ...local, + updated_at: new Date('2026-08-31T12:00:00.000Z'), + noSendExpiryDeadline: 2_000_000_000, + noSendExpiryState: 'unsigned' + } + await expect(entity.mergeExisting(activeStorage, undefined, staleButNewer, syncMap)).resolves.toBe(false) + + const advancedButOlder: TableTransaction = { + ...local, + updated_at: new Date('2026-08-29T12:00:00.000Z'), + noSendExpiryDeadline: 2_000_000_000, + noSendExpiryState: 'reclaiming', + noSendExpiryReclaimRawTx: undefined, + description: 'stale transaction data' + } + await expect(entity.mergeExisting(activeStorage, undefined, advancedButOlder, syncMap)).resolves.toBe(true) + const [stored] = await activeStorage.findTransactions({ + partial: { transactionId: local.transactionId } + }) + expect(stored.noSendExpiryState).toBe('reclaiming') + expect(stored.noSendExpiryDeadline).toBe(0) + expect(stored.noSendExpiryReclaimRawTx).toBeDefined() + expect(stored.description).toBe('current transaction data') + + const proofFromLowerState: TableTransaction = { + ...local, + updated_at: new Date('2026-08-30T13:00:00.000Z'), + noSendExpiryState: 'broadcast', + status: 'completed', + provenTxId: incomingProvenTxId, + description: 'proof-bearing transaction data' + } + await expect(entity.mergeExisting(activeStorage, undefined, proofFromLowerState, syncMap)).resolves.toBe(true) + const [proofMerged] = await activeStorage.findTransactions({ + partial: { transactionId: local.transactionId } + }) + expect(proofMerged.noSendExpiryState).toBe('reclaiming') + expect(proofMerged.status).toBe('completed') + expect(proofMerged.description).toBe('proof-bearing transaction data') + + const reclaimTxid = '18'.repeat(32) + const { tx: reclaim } = await _tu.insertTestTransaction(activeStorage, txData.user, true, { + txid: reclaimTxid, + status: 'completed' + }) + const reclaimOutput = await _tu.insertTestOutput(activeStorage, reclaim, 0, 100, undefined, true, { + txid: reclaimTxid, + spendable: true + }) + await activeStorage.updateTransaction(local.transactionId, { + noSendExpiryState: 'reclaimed', + noSendExpiryReclaimTxid: reclaimTxid, + updated_at: new Date('2026-08-31T12:00:00.000Z') + }) + const [reclaimedLocal] = await activeStorage.findTransactions({ + partial: { transactionId: local.transactionId } + }) + const terminalEntity = new EntityTransaction(reclaimedLocal) + const targetWinnerWithOlderClock: TableTransaction = { + ...reclaimedLocal, + updated_at: new Date('2026-08-29T12:00:00.000Z'), + noSendExpiryState: 'target-won' + } + + await expect( + terminalEntity.mergeExisting(activeStorage, undefined, targetWinnerWithOlderClock, syncMap) + ).resolves.toBe(true) + const [terminalTarget] = await activeStorage.findTransactions({ + partial: { transactionId: local.transactionId } + }) + const [quarantined] = await activeStorage.findOutputs({ + partial: { outputId: reclaimOutput.outputId } + }) + expect(terminalTarget.noSendExpiryState).toBe('target-won') + expect(quarantined.spendable).toBe(false) + + await activeStorage.updateTransaction(local.transactionId, { + noSendExpiryState: 'reclaimed', + status: 'failed', + provenTxId: undefined, + updated_at: new Date('2026-08-31T13:00:00.000Z') + }) + await activeStorage.updateOutput(reclaimOutput.outputId, { spendable: true }) + const [reclaimedBeforeProof] = await activeStorage.findTransactions({ + partial: { transactionId: local.transactionId } + }) + const proofEntity = new EntityTransaction(reclaimedBeforeProof) + const olderTargetProofBeforeLifecycleUpdate: TableTransaction = { + ...reclaimedBeforeProof, + updated_at: new Date('2026-08-29T13:00:00.000Z'), + noSendExpiryState: 'reclaiming', + status: 'completed', + provenTxId: incomingProvenTxId + } + await expect( + proofEntity.mergeExisting(activeStorage, undefined, olderTargetProofBeforeLifecycleUpdate, syncMap) + ).resolves.toBe(true) + const [proofWinner] = await activeStorage.findTransactions({ + partial: { transactionId: local.transactionId } + }) + const [proofQuarantined] = await activeStorage.findOutputs({ + partial: { outputId: reclaimOutput.outputId } + }) + expect(proofWinner.noSendExpiryState).toBe('target-won') + expect(proofWinner.status).toBe('completed') + expect(proofQuarantined.spendable).toBe(false) + } + }) + // Test: getBsvTx handles undefined rawTx test('10_getBsvTx_handles_undefined_rawTx', () => { const tx = new EntityTransaction() // No rawTx provided diff --git a/packages/wallet/wallet-toolbox/src/storage/schema/tables/TableTransaction.ts b/packages/wallet/wallet-toolbox/src/storage/schema/tables/TableTransaction.ts index 707176c62..6c8379639 100644 --- a/packages/wallet/wallet-toolbox/src/storage/schema/tables/TableTransaction.ts +++ b/packages/wallet/wallet-toolbox/src/storage/schema/tables/TableTransaction.ts @@ -1,5 +1,9 @@ import { Base64String } from '@bsv/sdk' import * as sdk from '../../../sdk' +import type { + Brc177NoSendExpiryMode, + Brc177NoSendExpiryState +} from '../../../utility/brc177NoSendExpiry' export interface TableTransaction extends sdk.EntityTimeStamp { created_at: Date @@ -33,6 +37,21 @@ export interface TableTransaction extends sdk.EntityTimeStamp { txid?: string inputBEEF?: number[] rawTx?: number[] + /** Internal BRC-177 lifecycle metadata. These fields are synchronized with the action. */ + noSendExpiryMode?: Brc177NoSendExpiryMode + noSendExpiryValue?: number + /** Resolved Unix seconds for time modes, or the absolute height for blockheight. */ + noSendExpiryDeadline?: number + noSendExpiryState?: Brc177NoSendExpiryState + noSendExpiryAnchorTxid?: string + noSendExpiryAnchorVout?: number + noSendExpiryReleasedAt?: number + noSendExpiryObservedAt?: number + noSendExpiryReclaimTxid?: string + noSendExpiryReclaimRawTx?: number[] + noSendExpiryReclaimDerivationPrefix?: string + noSendExpiryReclaimDerivationSuffix?: string + noSendExpiryReclaimSatoshis?: number } export const transactionColumnsWithoutRawTx = [ @@ -48,7 +67,20 @@ export const transactionColumnsWithoutRawTx = [ 'version', 'lockTime', 'description', - 'txid' + 'txid', + 'noSendExpiryMode', + 'noSendExpiryValue', + 'noSendExpiryDeadline', + 'noSendExpiryState', + 'noSendExpiryAnchorTxid', + 'noSendExpiryAnchorVout', + 'noSendExpiryReleasedAt', + 'noSendExpiryObservedAt', + 'noSendExpiryReclaimTxid', + 'noSendExpiryReclaimDerivationPrefix', + 'noSendExpiryReclaimDerivationSuffix', + 'noSendExpiryReclaimSatoshis' // 'inputBEEF', // 'rawTx', + // 'noSendExpiryReclaimRawTx', ] diff --git a/packages/wallet/wallet-toolbox/src/utility/__tests/brc177NoSendExpiry.test.ts b/packages/wallet/wallet-toolbox/src/utility/__tests/brc177NoSendExpiry.test.ts new file mode 100644 index 000000000..b6481b619 --- /dev/null +++ b/packages/wallet/wallet-toolbox/src/utility/__tests/brc177NoSendExpiry.test.ts @@ -0,0 +1,50 @@ +import { + brc177NoSendExpiryStateRank, + hasBrc177NoSendExpiryLabel, + parseBrc177NoSendExpiryLabels +} from '../brc177NoSendExpiry' + +describe('BRC-177 noSend expiry labels', () => { + test.each([ + ['p nosend expiry seconds 30', 'seconds', 30], + ['p nosend expiry timestamp 1788134400', 'timestamp', 1788134400], + ['p nosend expiry blockheight 900000', 'blockheight', 900000] + ] as const)('parses %s', (label, mode, value) => { + expect(parseBrc177NoSendExpiryLabels([label, 'application label'])).toEqual({ label, mode, value }) + expect(hasBrc177NoSendExpiryLabel([label])).toBe(true) + }) + + test('ignores labels outside the reserved module', () => { + expect(parseBrc177NoSendExpiryLabels(['application label'])).toBeUndefined() + expect(hasBrc177NoSendExpiryLabel(['application label'])).toBe(false) + }) + + test.each([ + 'p nosend expiry seconds 0', + 'p nosend expiry seconds 01', + 'p nosend expiry seconds -1', + 'p nosend expiry seconds 1.5', + 'p nosend expiry seconds 1', + 'p nosend expiry seconds 1 ', + 'p nosend expiry seconds', + 'p nosend expiry minutes 1', + 'p nosend unsupported payload', + `p nosend expiry timestamp ${Number.MAX_SAFE_INTEGER + 1}` + ])('rejects non-canonical or unsupported label %s', label => { + expect(() => parseBrc177NoSendExpiryLabels([label])).toThrow() + }) + + test('rejects multiple labels in the reserved module', () => { + expect(() => + parseBrc177NoSendExpiryLabels(['p nosend expiry seconds 30', 'p nosend expiry timestamp 1788134400']) + ).toThrow('exactly one') + }) + + test('orders synchronized lifecycle states without allowing unsafe regression', () => { + expect(brc177NoSendExpiryStateRank('cancelled')).toBeGreaterThan(brc177NoSendExpiryStateRank('unsigned')) + expect(brc177NoSendExpiryStateRank('signed')).toBeGreaterThan(brc177NoSendExpiryStateRank('cancelled')) + expect(brc177NoSendExpiryStateRank('revocation-requested')).toBeGreaterThan(brc177NoSendExpiryStateRank('signed')) + expect(brc177NoSendExpiryStateRank('reclaiming')).toBeGreaterThan(brc177NoSendExpiryStateRank('broadcast')) + expect(brc177NoSendExpiryStateRank('target-won')).toBeGreaterThan(brc177NoSendExpiryStateRank('reclaimed')) + }) +}) diff --git a/packages/wallet/wallet-toolbox/src/utility/brc177NoSendExpiry.ts b/packages/wallet/wallet-toolbox/src/utility/brc177NoSendExpiry.ts new file mode 100644 index 000000000..0e8ed17b7 --- /dev/null +++ b/packages/wallet/wallet-toolbox/src/utility/brc177NoSendExpiry.ts @@ -0,0 +1,120 @@ +import { Validation } from '@bsv/sdk' +import { WERR_INVALID_PARAMETER } from '../sdk/WERR_errors' + +export const BRC177_NO_SEND_EXPIRY_PREFIX = 'p nosend expiry ' +export const BRC177_NO_SEND_MODULE_PREFIX = 'p nosend ' + +export type Brc177NoSendExpiryMode = 'seconds' | 'timestamp' | 'blockheight' + +export interface Brc177NoSendExpiry { + mode: Brc177NoSendExpiryMode + value: number + label: string +} + +export type Brc177NoSendExpiryState = + | 'preparing' + | 'unsigned' + | 'signed' + | 'revocation-requested' + | 'broadcast' + | 'reclaiming' + | 'reclaimed' + | 'target-won' + | 'conflicted' + | 'cancelled' + +/** + * Orders durable lifecycle states by safety progress for cross-storage merge. + * A newer wall-clock timestamp must never revive a state from which a signed + * target could escape expiry enforcement. + */ +export function brc177NoSendExpiryStateRank(state: Brc177NoSendExpiryState | undefined): number { + switch (state) { + case 'preparing': + return 0 + case 'unsigned': + return 1 + case 'cancelled': + return 2 + case 'signed': + return 3 + case 'revocation-requested': + return 4 + case 'conflicted': + return 5 + case 'broadcast': + return 6 + case 'reclaiming': + return 7 + case 'reclaimed': + return 8 + // If synchronized stores ever carry contradictory terminal observations, + // preserving the target winner is conservative: it never exposes the + // reclaim output as spendable on possibly stale proof evidence. + case 'target-won': + return 9 + default: + return -1 + } +} + +export interface Brc177FundingCreateActionMetadata { + kind: 'funding' + anchorSatoshis: number +} + +export interface Brc177ProtectedCreateActionMetadata { + kind: 'protected' + expiry: Brc177NoSendExpiry + deadline: number + anchorTxid: string + anchorVout: number +} + +export type Brc177CreateActionMetadata = Brc177FundingCreateActionMetadata | Brc177ProtectedCreateActionMetadata + +export type Brc177ValidCreateActionArgs = Validation.ValidCreateActionArgs & { + brc177?: Brc177CreateActionMetadata +} + +function parseCanonicalUnsignedInteger(value: string): number { + if (!/^(0|[1-9]\d*)$/.test(value)) { + throw new WERR_INVALID_PARAMETER('labels', 'a canonical unsigned BRC-177 expiry value') + } + const parsed = Number(value) + if (!Number.isSafeInteger(parsed)) { + throw new WERR_INVALID_PARAMETER('labels', 'a safely supported BRC-177 expiry value') + } + return parsed +} + +export function parseBrc177NoSendExpiryLabels(labels: string[] | undefined): Brc177NoSendExpiry | undefined { + const moduleLabels = (labels ?? []).filter(label => label.startsWith(BRC177_NO_SEND_MODULE_PREFIX)) + if (moduleLabels.length === 0) return undefined + const matching = (labels ?? []).filter(label => label.startsWith(BRC177_NO_SEND_EXPIRY_PREFIX)) + if (matching.length !== 1 || moduleLabels.length !== 1) { + throw new WERR_INVALID_PARAMETER('labels', 'exactly one BRC-177 noSend expiry label') + } + + const label = matching[0] + const remainder = label.slice(BRC177_NO_SEND_EXPIRY_PREFIX.length) + const separator = remainder.indexOf(' ') + if (separator <= 0 || separator === remainder.length - 1 || remainder.indexOf(' ', separator + 1) !== -1) { + throw new WERR_INVALID_PARAMETER('labels', 'a valid BRC-177 noSend expiry label') + } + + const mode = remainder.slice(0, separator) + if (mode !== 'seconds' && mode !== 'timestamp' && mode !== 'blockheight') { + throw new WERR_INVALID_PARAMETER('labels', 'a supported BRC-177 expiry mode') + } + const value = parseCanonicalUnsignedInteger(remainder.slice(separator + 1)) + if (mode === 'seconds' && value === 0) { + throw new WERR_INVALID_PARAMETER('labels', 'a BRC-177 seconds duration greater than zero') + } + return { mode, value, label } +} + +export function hasBrc177NoSendExpiryLabel(labels: string[] | undefined): boolean { + return (labels ?? []).some(label => label.startsWith(BRC177_NO_SEND_MODULE_PREFIX)) +} diff --git a/packages/wallet/wallet-toolbox/src/utility/index.all.ts b/packages/wallet/wallet-toolbox/src/utility/index.all.ts index fad7ec1a6..40030940c 100644 --- a/packages/wallet/wallet-toolbox/src/utility/index.all.ts +++ b/packages/wallet/wallet-toolbox/src/utility/index.all.ts @@ -10,3 +10,4 @@ export * from './Format' export * from './brc114ActionTimeLabels' export * from './brc153ReferenceLabels' +export * from './brc177NoSendExpiry' diff --git a/packages/wallet/wallet-toolbox/src/utility/index.client.ts b/packages/wallet/wallet-toolbox/src/utility/index.client.ts index 86a1caa34..e4c0f2068 100644 --- a/packages/wallet/wallet-toolbox/src/utility/index.client.ts +++ b/packages/wallet/wallet-toolbox/src/utility/index.client.ts @@ -8,3 +8,4 @@ export * from './utilityHelpers.noBuffer' export * from './brc114ActionTimeLabels' export * from './brc153ReferenceLabels' +export * from './brc177NoSendExpiry' diff --git a/packages/wallet/wallet-toolbox/test/Wallet/action/noSendExpiry.test.ts b/packages/wallet/wallet-toolbox/test/Wallet/action/noSendExpiry.test.ts new file mode 100644 index 000000000..b59c7a420 --- /dev/null +++ b/packages/wallet/wallet-toolbox/test/Wallet/action/noSendExpiry.test.ts @@ -0,0 +1,753 @@ +import { Beef, CachedKeyDeriver, PrivateKey, Script, Transaction, Utils, Validation } from '@bsv/sdk' +import { Knex, knex as makeKnex } from 'knex' +import { Wallet } from '../../../src/Wallet' +import { MockServices } from '../../../src/mockchain/MockServices' +import { Monitor } from '../../../src/monitor/Monitor' +import { TaskCheckForProofs } from '../../../src/monitor/tasks/TaskCheckForProofs' +import { StorageKnex } from '../../../src/storage/StorageKnex' +import { WalletStorageManager } from '../../../src/storage/WalletStorageManager' +import { processNoSendExpiryLifecycle } from '../../../src/storage/methods/noSendExpiryLifecycle' +import { ScriptTemplateBRC29 } from '../../../src/utility/ScriptTemplateBRC29' +import { randomBytesHex, verifyOne } from '../../../src/utility/utilityHelpers' +import { asArray } from '../../../src/utility/utilityHelpers.noBuffer' + +function memoryKnex(_name: string): Knex { + return makeKnex({ + client: 'better-sqlite3', + connection: { filename: ':memory:' }, + useNullAsDefault: true, + pool: { min: 1, max: 1 } + }) +} + +interface WalletHarness { + wallet: Wallet + storage: WalletStorageManager + active: StorageKnex + activeKey: string + backup?: StorageKnex + backupKey?: string + monitor: Monitor + destroy: () => Promise +} + +describe('BRC-177 noSend expiry reference implementation', () => { + jest.setTimeout(120_000) + + let chainDb: Knex + let services: MockServices + + beforeAll(async () => { + chainDb = memoryKnex('brc177_chain') + services = new MockServices(chainDb) + await services.initialize() + // Keep several mature coinbases available so each isolated wallet can be + // funded by a transaction the mock processor validates normally. + for (let i = 0; i < 105; i++) await services.mineBlock() + }) + + afterAll(async () => { + await chainDb.destroy() + }) + + async function createProvider(name: string): Promise<{ provider: StorageKnex; key: string; db: Knex }> { + const db = memoryKnex(name) + const key = randomBytesHex(33) + const provider = new StorageKnex({ + chain: 'mock', + knex: db, + commissionSatoshis: 0, + feeModel: { model: 'sat/kb', value: 1 } + }) + await provider.migrate(name, key) + await provider.makeAvailable() + return { provider, key, db } + } + + async function fundWallet(wallet: Wallet): Promise { + const height = await services.getHeight() + const [utxo] = await services.storage + .knex('mockchain_utxos') + .where({ isCoinbase: true, spentByTxid: null }) + .where('blockHeight', '<=', height - 100) + .orderBy('blockHeight', 'asc') + .limit(1) + expect(utxo).toBeDefined() + + const sourceRow = await services.storage.getTransaction(utxo.txid) + const sourceRaw = asArray(sourceRow!.rawTx) + const source = Transaction.fromBinary(sourceRaw) + const derivationPrefix = Utils.toBase64(Array(16).fill(21)) + const derivationSuffix = Utils.toBase64(Array(16).fill(22)) + const keys = wallet.getClientChangeKeyPair() + const template = new ScriptTemplateBRC29({ + derivationPrefix, + derivationSuffix, + keyDeriver: wallet.keyDeriver + }) + const payment = new Transaction() + payment.addInput({ + sourceTransaction: source, + sourceOutputIndex: 0, + unlockingScript: new Script(), + sequence: 0xffffffff + }) + payment.addOutput({ + satoshis: 1_000_000, + lockingScript: template.lock(keys.privateKey, keys.publicKey) + }) + + const sourceProof = await services.getMerklePath(utxo.txid) + const submitBeef = new Beef() + const sourceBump = submitBeef.mergeBump(sourceProof.merklePath!) + submitBeef.mergeRawTx(sourceRaw, sourceBump) + submitBeef.mergeRawTx(payment.toUint8Array()) + const paymentTxid = payment.id('hex') + const posted = await services.postBeef(submitBeef, [paymentTxid]) + expect(posted[0].status).toBe('success') + + await services.mineBlock() + const paymentProof = await services.getMerklePath(paymentTxid) + const atomic = new Beef() + const paymentBump = atomic.mergeBump(paymentProof.merklePath!) + atomic.mergeRawTx(payment.toUint8Array(), paymentBump) + await expect( + wallet.internalizeAction({ + tx: atomic.toBinaryAtomic(paymentTxid), + outputs: [ + { + outputIndex: 0, + protocol: 'wallet payment', + paymentRemittance: { + derivationPrefix, + derivationSuffix, + senderIdentityKey: wallet.identityKey + } + } + ], + description: 'Fund BRC-177 integration wallet' + }) + ).resolves.toMatchObject({ accepted: true }) + } + + async function createHarness(withBackup = false): Promise { + const rootKey = PrivateKey.fromHex(randomBytesHex(32)) + const keyDeriver = new CachedKeyDeriver(rootKey) + const identityKey = rootKey.toPublicKey().toString() + const activeSetup = await createProvider('brc177_wallet') + const backupSetup = withBackup ? await createProvider('brc177_backup') : undefined + if (backupSetup != null) { + const { user } = await backupSetup.provider.findOrInsertUser(identityKey) + await backupSetup.provider.setActive({ identityKey, userId: user.userId }, activeSetup.key) + } + const storage = new WalletStorageManager( + identityKey, + activeSetup.provider, + backupSetup == null ? undefined : [backupSetup.provider] + ) + await storage.makeAvailable() + storage.setServices(services) + const monitor = new Monitor({ + chain: 'mock', + storage, + services, + chaintracks: services.tracker as any, + msecsWaitPerMerkleProofServiceReq: 0, + taskRunWaitMsecs: 5_000, + abandonedMsecs: 300_000, + unprovenAttemptsLimitTest: 100, + unprovenAttemptsLimitMain: 144, + maxRebroadcastAttempts: 0, + startupTaskMode: 'default' + }) + const wallet = new Wallet({ chain: 'mock', keyDeriver, storage, services, monitor }) + await fundWallet(wallet) + return { + wallet, + storage, + active: activeSetup.provider, + activeKey: activeSetup.key, + backup: backupSetup?.provider, + backupKey: backupSetup?.key, + monitor, + async destroy() { + await wallet.destroy() + await activeSetup.db.destroy() + if (backupSetup != null) await backupSetup.db.destroy() + } + } + } + + function protectedArgs(seconds: number, signAndProcess = true) { + return { + description: 'BRC-177 protected payment', + labels: [`p nosend expiry seconds ${seconds}`], + outputs: [ + { + satoshis: 5_000, + lockingScript: '51', + outputDescription: 'Protected recipient output' + } + ], + options: { + noSend: true, + randomizeOutputs: false, + signAndProcess + } + } as const + } + + test('pre-funds, releases noSend, atomically reclaims, and finalizes after proof', async () => { + const ctx = await createHarness() + try { + const created = await ctx.wallet.createAction(protectedArgs(3600)) + expect(created.txid).toBeDefined() + expect(created.tx).toBeDefined() + expect(await services.storage.getTransaction(created.txid!)).toBeUndefined() + await expect( + ctx.wallet.internalizeAction({ + tx: created.tx!, + description: 'Inbound actions cannot claim BRC-177 protection', + labels: ['p nosend expiry seconds 3600'], + outputs: [ + { + outputIndex: 0, + protocol: 'basket insertion', + insertionRemittance: { + basket: 'inbound', + customInstructions: 'BRC-177 rejection test', + tags: [] + } + } + ] + }) + ).rejects.toThrow('only on outgoing createAction requests') + + const target = verifyOne(await ctx.active.findTransactions({ partial: { txid: created.txid } })) + expect(target.noSendExpiryState).toBe('signed') + expect(target.noSendExpiryReclaimRawTx).toBeDefined() + const funding = verifyOne(await ctx.active.findTransactions({ partial: { txid: target.noSendExpiryAnchorTxid } })) + const anchor = verifyOne( + await ctx.active.findOutputs({ + partial: { + txid: target.noSendExpiryAnchorTxid, + vout: target.noSendExpiryAnchorVout + } + }) + ) + // The anchor is fixed managed change, not an external spend. Only the + // already-paid funding fee is charged to the application's monthly + // authorization; the protected action accounts for the anchor later. + expect(funding.satoshis).toBeLessThan(0) + expect(-funding.satoshis).toBeLessThan(anchor.satoshis) + const targetTx = Transaction.fromAtomicBEEF(created.tx!) + expect(targetTx.inputs).toHaveLength(1) + expect(targetTx.outputs).toHaveLength(1) + + await ctx.active.updateTransaction(target.transactionId, { noSendExpiryDeadline: 0 }) + const runs = await Promise.all([ + processNoSendExpiryLifecycle(ctx.active), + processNoSendExpiryLifecycle(ctx.active) + ]) + expect(runs.reduce((sum, run) => sum + run.reclaimActivated, 0)).toBe(1) + + const reclaimTxid = target.noSendExpiryReclaimTxid! + expect(await services.storage.getTransaction(reclaimTxid)).toBeDefined() + const reclaimRows = await ctx.active.findTransactions({ partial: { txid: reclaimTxid } }) + expect(reclaimRows).toHaveLength(1) + const pendingReclaimOutput = verifyOne( + await ctx.active.findOutputs({ + partial: { transactionId: reclaimRows[0].transactionId, vout: 0 } + }) + ) + expect(pendingReclaimOutput.spendable).toBe(false) + + const header = await services.mineBlock() + ctx.monitor.processNewBlockHeader(header) + await ctx.monitor.runTask(TaskCheckForProofs.taskName) + await processNoSendExpiryLifecycle(ctx.active) + + const finalTarget = verifyOne(await ctx.active.findTransactions({ partial: { txid: created.txid } })) + const finalReclaim = verifyOne(await ctx.active.findTransactions({ partial: { txid: reclaimTxid } })) + expect(finalTarget.noSendExpiryState).toBe('reclaimed') + expect(finalTarget.status).toBe('failed') + expect(finalReclaim.status).toBe('completed') + const reclaimOutput = verifyOne( + await ctx.active.findOutputs({ + partial: { transactionId: finalReclaim.transactionId, vout: 0 } + }) + ) + expect(reclaimOutput.spendable).toBe(true) + expect(reclaimOutput.change).toBe(true) + } finally { + await ctx.destroy() + } + }) + + test('observation cancels reclaim but only a validated proof finalizes the target winner', async () => { + const ctx = await createHarness() + try { + const created = await ctx.wallet.createAction(protectedArgs(3600)) + const beef = Beef.fromBinary(created.tx!) + const posted = await services.postBeef(beef, [created.txid!]) + expect(posted[0].status).toBe('success') + + await processNoSendExpiryLifecycle(ctx.active) + let target = verifyOne(await ctx.active.findTransactions({ partial: { txid: created.txid } })) + expect(target.noSendExpiryState).toBe('broadcast') + expect(target.status).toBe('unproven') + await expect(ctx.wallet.abortAction({ reference: target.reference })).resolves.toEqual({ aborted: false }) + + await ctx.active.updateTransaction(target.transactionId, { noSendExpiryDeadline: 0 }) + await processNoSendExpiryLifecycle(ctx.active) + target = verifyOne(await ctx.active.findTransactions({ partial: { txid: created.txid } })) + expect(target.noSendExpiryState).toBe('broadcast') + expect(await services.storage.getTransaction(target.noSendExpiryReclaimTxid!)).toBeUndefined() + + const header = await services.mineBlock() + await processNoSendExpiryLifecycle(ctx.active) + target = verifyOne(await ctx.active.findTransactions({ partial: { txid: created.txid } })) + expect(target.noSendExpiryState).toBe('broadcast') + + ctx.monitor.processNewBlockHeader(header) + await ctx.monitor.runTask(TaskCheckForProofs.taskName) + await processNoSendExpiryLifecycle(ctx.active) + target = verifyOne(await ctx.active.findTransactions({ partial: { txid: created.txid } })) + expect(target.noSendExpiryState).toBe('target-won') + expect(target.status).toBe('completed') + } finally { + await ctx.destroy() + } + }) + + test('a target observed during the reclaim race still wins only after local proof', async () => { + const ctx = await createHarness() + try { + const created = await ctx.wallet.createAction(protectedArgs(3600)) + let target = verifyOne(await ctx.active.findTransactions({ partial: { txid: created.txid } })) + await ctx.active.updateTransaction(target.transactionId, { noSendExpiryDeadline: 0 }) + + const broadcast = jest + .spyOn(ctx.active, 'attemptToPostReqsToNetwork') + .mockRejectedValueOnce(new Error('processor temporarily unavailable')) + const expired = await processNoSendExpiryLifecycle(ctx.active) + broadcast.mockRestore() + expect(expired.reclaimActivated).toBe(1) + target = verifyOne(await ctx.active.findTransactions({ partial: { txid: created.txid } })) + expect(target.noSendExpiryState).toBe('reclaiming') + expect(await services.storage.getTransaction(target.noSendExpiryReclaimTxid!)).toBeUndefined() + + const posted = await services.postBeef(Beef.fromBinary(created.tx!), [created.txid!]) + expect(posted[0].status).toBe('success') + const header = await services.mineBlock() + + const lostObservationRace = jest.spyOn(ctx.active, 'compareAndSetNoSendExpiryState').mockResolvedValueOnce(false) + const raced = await processNoSendExpiryLifecycle(ctx.active) + lostObservationRace.mockRestore() + expect(raced.observed).toBe(0) + target = verifyOne(await ctx.active.findTransactions({ partial: { txid: created.txid } })) + expect(target.noSendExpiryState).toBe('reclaiming') + expect(target.status).toBe('nosend') + + const observed = await processNoSendExpiryLifecycle(ctx.active) + expect(observed.observed).toBe(1) + target = verifyOne(await ctx.active.findTransactions({ partial: { txid: created.txid } })) + expect(target.noSendExpiryState).toBe('reclaiming') + expect(target.status).toBe('unproven') + const suppressedReclaimReq = verifyOne( + await ctx.active.findProvenTxReqs({ + partial: { txid: target.noSendExpiryReclaimTxid } + }) + ) + expect(suppressedReclaimReq.status).toBe('unmined') + expect(await services.storage.getTransaction(target.noSendExpiryReclaimTxid!)).toBeUndefined() + + ctx.monitor.processNewBlockHeader(header) + await ctx.monitor.runTask(TaskCheckForProofs.taskName) + await processNoSendExpiryLifecycle(ctx.active) + target = verifyOne(await ctx.active.findTransactions({ partial: { txid: created.txid } })) + const reclaim = verifyOne( + await ctx.active.findTransactions({ + partial: { txid: target.noSendExpiryReclaimTxid } + }) + ) + expect(target.noSendExpiryState).toBe('target-won') + expect(target.status).toBe('completed') + expect(reclaim.status).toBe('failed') + } finally { + await ctx.destroy() + } + }) + + test('contradictory proof state keeps both race outputs quarantined', async () => { + const ctx = await createHarness() + try { + const created = await ctx.wallet.createAction(protectedArgs(3600)) + let target = verifyOne(await ctx.active.findTransactions({ partial: { txid: created.txid } })) + await ctx.active.updateTransaction(target.transactionId, { noSendExpiryDeadline: 0 }) + await processNoSendExpiryLifecycle(ctx.active) + + target = verifyOne(await ctx.active.findTransactions({ partial: { txid: created.txid } })) + const reclaim = verifyOne( + await ctx.active.findTransactions({ partial: { txid: target.noSendExpiryReclaimTxid } }) + ) + await ctx.active.updateTransaction(target.transactionId, { status: 'completed' }) + await ctx.active.updateTransaction(reclaim.transactionId, { status: 'completed' }) + + const runs = await Promise.all([ + processNoSendExpiryLifecycle(ctx.active), + processNoSendExpiryLifecycle(ctx.active) + ]) + expect(runs.reduce((sum, run) => sum + run.reclaimed + run.targetWon, 0)).toBe(0) + target = verifyOne(await ctx.active.findTransactions({ partial: { txid: created.txid } })) + expect(target.noSendExpiryState).toBe('reclaiming') + const targetOutput = verifyOne( + await ctx.active.findOutputs({ partial: { transactionId: target.transactionId, vout: 0 } }) + ) + const reclaimOutput = verifyOne( + await ctx.active.findOutputs({ partial: { transactionId: reclaim.transactionId, vout: 0 } }) + ) + expect(targetOutput.spendable).toBe(false) + expect(reclaimOutput.spendable).toBe(false) + } finally { + await ctx.destroy() + } + }) + + test('a rejected reclaim never releases the revocation anchor to ordinary coin selection', async () => { + const ctx = await createHarness() + try { + const created = await ctx.wallet.createAction(protectedArgs(3600)) + let target = verifyOne(await ctx.active.findTransactions({ partial: { txid: created.txid } })) + await ctx.active.updateTransaction(target.transactionId, { noSendExpiryDeadline: 0 }) + await processNoSendExpiryLifecycle(ctx.active) + + target = verifyOne(await ctx.active.findTransactions({ partial: { txid: created.txid } })) + const reclaim = verifyOne( + await ctx.active.findTransactions({ partial: { txid: target.noSendExpiryReclaimTxid } }) + ) + await ctx.active.updateTransactionStatus('failed', reclaim.transactionId) + + target = verifyOne(await ctx.active.findTransactions({ partial: { txid: created.txid } })) + const anchor = verifyOne( + await ctx.active.findOutputs({ + partial: { + userId: target.userId, + txid: target.noSendExpiryAnchorTxid, + vout: target.noSendExpiryAnchorVout + } + }) + ) + const reclaimOutput = verifyOne( + await ctx.active.findOutputs({ partial: { transactionId: reclaim.transactionId, vout: 0 } }) + ) + expect(target.noSendExpiryState).toBe('reclaiming') + expect(anchor.spendable).toBe(false) + expect(anchor.spentBy).toBe(reclaim.transactionId) + expect(reclaimOutput.spendable).toBe(false) + } finally { + await ctx.destroy() + } + }) + + test('unsigned expiry survives restart semantics and releases the anchor without broadcasting', async () => { + const ctx = await createHarness() + try { + const created = await ctx.wallet.createAction(protectedArgs(3600, false)) + const reference = created.signableTransaction!.reference + let target = verifyOne(await ctx.active.findTransactions({ partial: { reference } })) + expect(target.noSendExpiryState).toBe('unsigned') + + // Simulate process loss: pending signer state disappears while durable + // storage retains the expiry and pre-signed reclaim. + delete ctx.wallet.pendingSignActions[reference] + await ctx.active.updateTransaction(target.transactionId, { noSendExpiryDeadline: 0 }) + await processNoSendExpiryLifecycle(ctx.active) + + target = verifyOne(await ctx.active.findTransactions({ partial: { reference } })) + expect(target.noSendExpiryState).toBe('cancelled') + expect(target.status).toBe('failed') + const anchor = verifyOne( + await ctx.active.findOutputs({ + partial: { + txid: target.noSendExpiryAnchorTxid, + vout: target.noSendExpiryAnchorVout + } + }) + ) + expect(anchor.spendable).toBe(true) + expect(anchor.spentBy).toBeUndefined() + expect(await services.storage.getTransaction(target.noSendExpiryReclaimTxid!)).toBeUndefined() + await expect(ctx.wallet.abortAction({ reference })).resolves.toEqual({ aborted: true }) + } finally { + await ctx.destroy() + } + }) + + test('signAction releases the protected transaction and storage handoff leaves only the new active monitor in charge', async () => { + const ctx = await createHarness(true) + try { + const created = await ctx.wallet.createAction(protectedArgs(3600, false)) + const signed = await ctx.wallet.signAction({ + reference: created.signableTransaction!.reference, + spends: {}, + options: { noSend: true } + }) + expect(signed.txid).toBeDefined() + let target = verifyOne(await ctx.active.findTransactions({ partial: { txid: signed.txid } })) + expect(target.noSendExpiryState).toBe('signed') + + await ctx.storage.updateBackups() + await ctx.storage.setActive(ctx.backupKey!) + target = verifyOne(await ctx.backup!.findTransactions({ partial: { txid: signed.txid } })) + await ctx.backup!.updateTransaction(target.transactionId, { noSendExpiryDeadline: 0 }) + + const { user: oldUser } = await ctx.active.findOrInsertUser(ctx.wallet.identityKey) + await expect( + ctx.active.abortAction( + { identityKey: ctx.wallet.identityKey, userId: oldUser.userId, isActive: false }, + { reference: target.reference } + ) + ).rejects.toThrow('BRC-177 requires the active storage provider') + const oldProviderRun = await processNoSendExpiryLifecycle(ctx.active) + expect(oldProviderRun.inspected).toBe(0) + const newProviderRun = await processNoSendExpiryLifecycle(ctx.backup!) + expect(newProviderRun.reclaimActivated).toBe(1) + expect(await services.storage.getTransaction(target.noSendExpiryReclaimTxid!)).toBeDefined() + } finally { + await ctx.destroy() + } + }) + + test('blockheight expiry and early abort both use the same guarded reclaim path', async () => { + const ctx = await createHarness() + try { + const expiryHeight = (await services.getHeight()) + 1 + const created = await ctx.wallet.createAction({ + ...protectedArgs(3600), + labels: [`p nosend expiry blockheight ${expiryHeight}`] + }) + let target = verifyOne(await ctx.active.findTransactions({ partial: { txid: created.txid } })) + expect(target.noSendExpiryMode).toBe('blockheight') + expect(target.noSendExpiryDeadline).toBe(expiryHeight) + + await services.mineBlock() + const expired = await processNoSendExpiryLifecycle(ctx.active) + expect(expired.reclaimActivated).toBe(1) + expect(await services.storage.getTransaction(target.noSendExpiryReclaimTxid!)).toBeDefined() + + const second = await ctx.wallet.createAction(protectedArgs(3600)) + target = verifyOne(await ctx.active.findTransactions({ partial: { txid: second.txid } })) + const originalDeadline = target.noSendExpiryDeadline + await expect(ctx.wallet.abortAction({ reference: target.reference })).resolves.toEqual({ aborted: true }) + const aborted = verifyOne(await ctx.active.findTransactions({ partial: { txid: second.txid } })) + expect(aborted.noSendExpiryState).toBe('reclaiming') + expect(aborted.noSendExpiryDeadline).toBe(originalDeadline) + expect(await services.storage.getTransaction(aborted.noSendExpiryReclaimTxid!)).toBeDefined() + await expect(ctx.wallet.abortAction({ reference: aborted.reference })).resolves.toEqual({ aborted: true }) + } finally { + await ctx.destroy() + } + }) + + test('timestamp expiry remains the exact absolute deadline after pre-funding', async () => { + const ctx = await createHarness() + try { + const deadline = Math.floor(Date.now() / 1000) + 3600 + const created = await ctx.wallet.createAction({ + ...protectedArgs(3600), + labels: [`p nosend expiry timestamp ${deadline}`] + }) + const target = verifyOne(await ctx.active.findTransactions({ partial: { txid: created.txid } })) + expect(target.noSendExpiryMode).toBe('timestamp') + expect(target.noSendExpiryValue).toBe(deadline) + expect(target.noSendExpiryDeadline).toBe(deadline) + } finally { + await ctx.destroy() + } + }) + + test('signAction cannot release an armed transaction at or after its deadline', async () => { + const ctx = await createHarness() + try { + const created = await ctx.wallet.createAction(protectedArgs(3600, false)) + const reference = created.signableTransaction!.reference + const target = verifyOne(await ctx.active.findTransactions({ partial: { reference } })) + await ctx.active.updateTransaction(target.transactionId, { noSendExpiryDeadline: 0 }) + + await expect(ctx.wallet.signAction({ reference, spends: {}, options: { noSend: true } })).rejects.toThrow( + 'expired' + ) + const unchanged = verifyOne(await ctx.active.findTransactions({ partial: { reference } })) + expect(unchanged.noSendExpiryState).toBe('unsigned') + expect(unchanged.txid).toBeUndefined() + } finally { + await ctx.destroy() + } + }) + + test('service ambiguity defers reclaim and malformed option combinations fail before pre-funding', async () => { + const ctx = await createHarness() + try { + const initiallyUnmined = (await services.storage.getUnminedTransactions()).length + const capabilities = jest.spyOn(ctx.storage, 'getCapabilities').mockResolvedValueOnce({}) + await expect(ctx.wallet.createAction(protectedArgs(3600))).rejects.toThrow( + 'Active storage does not support BRC-177' + ) + capabilities.mockRestore() + expect((await services.storage.getUnminedTransactions()).length).toBe(initiallyUnmined) + + await expect( + ctx.storage.prepareNoSendExpiry( + Validation.validateCreateActionArgs({ + description: 'not a BRC-177 action', + outputs: [ + { + satoshis: 5_000, + lockingScript: '51', + outputDescription: 'ordinary output' + } + ], + options: { noSend: true } + }) + ) + ).rejects.toThrow('BRC-177 noSend expiry label') + expect((await services.storage.getUnminedTransactions()).length).toBe(initiallyUnmined) + + const arm = ctx.storage.armNoSendExpiry.bind(ctx.storage) + const tamper = jest.spyOn(ctx.storage, 'armNoSendExpiry').mockImplementationOnce(async args => { + const raw = Array.from(args.reclaimRawTx) + raw[43] ^= 1 + return await arm({ + ...args, + reclaimRawTx: raw, + reclaimTxid: Transaction.fromBinary(raw).id('hex') + }) + }) + await expect(ctx.wallet.createAction(protectedArgs(3600))).rejects.toThrow('SIGHASH_ALL') + tamper.mockRestore() + + const unminedBefore = (await services.storage.getUnminedTransactions()).length + await expect( + ctx.wallet.createAction({ + ...protectedArgs(3600), + options: { noSend: false } + }) + ).rejects.toThrow('options.noSend') + expect((await services.storage.getUnminedTransactions()).length).toBe(unminedBefore) + + const malformedOptions = [ + { noSend: true, sendWith: ['03'.repeat(32)] }, + { noSend: true, noSendChange: [{ txid: '04'.repeat(32), vout: 0 }] }, + { noSend: true, returnTXIDOnly: true } + ] + for (const options of malformedOptions) { + await expect( + ctx.wallet.createAction({ + ...protectedArgs(3600), + options + }) + ).rejects.toThrow() + } + expect((await services.storage.getUnminedTransactions()).length).toBe(unminedBefore) + + await expect( + ctx.wallet.createAction({ + ...protectedArgs(3600), + labels: [`p nosend expiry timestamp ${Math.floor(Date.now() / 1000) - 1}`] + }) + ).rejects.toThrow('timestamp later than the current time') + expect((await services.storage.getUnminedTransactions()).length).toBe(unminedBefore) + + await expect( + ctx.wallet.createAction({ + ...protectedArgs(3600), + labels: [`p nosend expiry blockheight ${await services.getHeight()}`] + }) + ).rejects.toThrow('blockheight later than the current best-chain height') + expect((await services.storage.getUnminedTransactions()).length).toBe(unminedBefore) + + await expect( + ctx.wallet.createAction({ + ...protectedArgs(3600), + labels: [`p nosend expiry seconds ${Number.MAX_SAFE_INTEGER}`] + }) + ).rejects.toThrow('safely schedulable') + expect((await services.storage.getUnminedTransactions()).length).toBe(unminedBefore) + + const created = await ctx.wallet.createAction(protectedArgs(3600)) + const target = verifyOne(await ctx.active.findTransactions({ partial: { txid: created.txid } })) + await ctx.active.updateTransaction(target.transactionId, { noSendExpiryDeadline: 0 }) + const statusSpy = jest.spyOn(services, 'getStatusForTxids').mockResolvedValueOnce({ + status: 'error', + results: [], + name: 'offline' + }) + const run = await processNoSendExpiryLifecycle(ctx.active) + statusSpy.mockRestore() + expect(run.deferred).toBe(1) + expect(run.reclaimActivated).toBe(0) + const unchanged = verifyOne(await ctx.active.findTransactions({ partial: { txid: created.txid } })) + expect(unchanged.noSendExpiryState).toBe('revocation-requested') + expect(await services.storage.getTransaction(unchanged.noSendExpiryReclaimTxid!)).toBeUndefined() + + const utxoSpy = jest.spyOn(services, 'isUtxo').mockRejectedValueOnce(new Error('all UTXO services offline')) + const inconclusive = await processNoSendExpiryLifecycle(ctx.active) + utxoSpy.mockRestore() + expect(inconclusive.deferred).toBe(1) + expect(inconclusive.reclaimActivated).toBe(0) + + const spentSpy = jest.spyOn(services, 'isUtxo').mockResolvedValueOnce(false) + const conflicted = await processNoSendExpiryLifecycle(ctx.active) + spentSpy.mockRestore() + expect(conflicted).toMatchObject({ deferred: 1, reclaimActivated: 0 }) + expect(verifyOne(await ctx.active.findTransactions({ partial: { txid: created.txid } })).noSendExpiryState).toBe( + 'conflicted' + ) + + const knownSpy = jest.spyOn(services, 'getStatusForTxids').mockResolvedValueOnce({ + status: 'success', + results: [{ txid: created.txid!, status: 'known', depth: 0 }] + }) + const lostObservationRace = jest.spyOn(ctx.active, 'compareAndSetNoSendExpiryState').mockResolvedValueOnce(false) + const raced = await processNoSendExpiryLifecycle(ctx.active) + lostObservationRace.mockRestore() + knownSpy.mockRestore() + expect(raced.observed).toBe(0) + expect(verifyOne(await ctx.active.findTransactions({ partial: { txid: created.txid } })).noSendExpiryState).toBe( + 'conflicted' + ) + + const recovered = await processNoSendExpiryLifecycle(ctx.active) + expect(recovered.reclaimActivated).toBe(1) + const reclaiming = verifyOne(await ctx.active.findTransactions({ partial: { txid: created.txid } })) + expect(reclaiming.noSendExpiryState).toBe('reclaiming') + expect(await services.storage.getTransaction(reclaiming.noSendExpiryReclaimTxid!)).toBeDefined() + } finally { + await ctx.destroy() + } + }) + + test('a malformed expiry record cannot starve later valid reclaims', async () => { + const ctx = await createHarness() + try { + const malformed = await ctx.wallet.createAction(protectedArgs(3600)) + const healthy = await ctx.wallet.createAction(protectedArgs(3600)) + const malformedTarget = verifyOne(await ctx.active.findTransactions({ partial: { txid: malformed.txid } })) + const healthyTarget = verifyOne(await ctx.active.findTransactions({ partial: { txid: healthy.txid } })) + await ctx.active.updateTransaction(malformedTarget.transactionId, { + noSendExpiryDeadline: 0, + noSendExpiryReclaimRawTx: [0] + }) + await ctx.active.updateTransaction(healthyTarget.transactionId, { noSendExpiryDeadline: 0 }) + + const run = await processNoSendExpiryLifecycle(ctx.active) + + expect(run).toMatchObject({ inspected: 2, reclaimActivated: 1, deferred: 1, errors: 1 }) + expect(await services.storage.getTransaction(malformedTarget.noSendExpiryReclaimTxid!)).toBeUndefined() + expect(await services.storage.getTransaction(healthyTarget.noSendExpiryReclaimTxid!)).toBeDefined() + } finally { + await ctx.destroy() + } + }) +}) diff --git a/packages/wallet/wallet-toolbox/test/storage/KnexMigrations.test.ts b/packages/wallet/wallet-toolbox/test/storage/KnexMigrations.test.ts index aa7aa7632..06808ecc4 100644 --- a/packages/wallet/wallet-toolbox/test/storage/KnexMigrations.test.ts +++ b/packages/wallet/wallet-toolbox/test/storage/KnexMigrations.test.ts @@ -1,6 +1,7 @@ import { _tu } from '../utils/TestUtilsWalletStorage' import { AUTH_SESSION_MIGRATION, + BRC177_NO_SEND_EXPIRY_MIGRATION, CREATE_ACTION_FUNDING_INDEX_MIGRATION, KnexMigrations, MANAGED_CHANGE_POLICY_MIGRATION, @@ -246,6 +247,41 @@ describe('KnexMigrations tests', () => { } }) + test('5b adds and rolls back durable BRC-177 lifecycle columns and index', async () => { + const localSQLiteFile = await _tu.newTmpFile('migratebrc177.sqlite', false, false, false) + const knex = _tu.createLocalSQLite(localSQLiteFile) + try { + await knex.schema.createTable('transactions', table => { + table.increments('transactionId') + table.integer('userId').notNullable() + }) + const source = new KnexMigrations('test', 'BRC-177 migration test', '1'.repeat(64), 1000) + const migration = await source.getMigration(BRC177_NO_SEND_EXPIRY_MIGRATION) + await migration.up(knex) + + for (const column of [ + 'noSendExpiryMode', + 'noSendExpiryDeadline', + 'noSendExpiryState', + 'noSendExpiryAnchorTxid', + 'noSendExpiryReclaimRawTx' + ]) { + await expect(knex.schema.hasColumn('transactions', column)).resolves.toBe(true) + } + await expect(knex('sqlite_master') + .where({ type: 'index', name: 'idx_transactions_nosend_expiry' }) + .first()).resolves.toBeDefined() + await expect(knex('sqlite_master') + .where({ type: 'index', name: 'idx_transactions_nosend_reclaim' }) + .first()).resolves.toBeDefined() + + await migration.down?.(knex) + await expect(knex.schema.hasColumn('transactions', 'noSendExpiryState')).resolves.toBe(false) + } finally { + await knex.destroy() + } + }) + test.each([ { migrationName: '2026-02-27-001 add listOutputs path indexes', From 9aaebfcbfbcf5a215b855000d3c5dceb73184b52 Mon Sep 17 00:00:00 2001 From: Ty J Everett Date: Sun, 30 Aug 2026 21:06:01 -0700 Subject: [PATCH 02/10] refactor(wallet-toolbox): resolve BRC-177 quality findings --- packages/wallet/wallet-toolbox/CHANGELOG.md | 2 +- .../wallet-toolbox/src/monitor/Monitor.ts | 2 +- .../src/monitor/tasks/TaskNoSendExpiry.ts | 6 +- .../wallet-toolbox/src/storage/StorageIdb.ts | 2 +- .../src/storage/methods/createAction.ts | 101 ++++++++------- .../src/storage/methods/noSendExpiry.ts | 4 +- .../storage/methods/noSendExpiryLifecycle.ts | 28 ++-- .../src/storage/methods/processAction.ts | 121 +++++++++++------- .../src/utility/brc177NoSendExpiry.ts | 2 +- .../test/Wallet/action/noSendExpiry.test.ts | 14 +- 10 files changed, 168 insertions(+), 114 deletions(-) diff --git a/packages/wallet/wallet-toolbox/CHANGELOG.md b/packages/wallet/wallet-toolbox/CHANGELOG.md index 656e1bee1..f1d700324 100644 --- a/packages/wallet/wallet-toolbox/CHANGELOG.md +++ b/packages/wallet/wallet-toolbox/CHANGELOG.md @@ -26,7 +26,7 @@ attention to changes that materially alter behavior or extend functionality. action. Existing actions and ordinary `noSend` calls are unchanged. The macOS reference fixtures measure 1,658,802 raw / 388,052 gzip / 304,787 Brotli bytes with Vite, 1,294,883 raw / 354,950 gzip / 284,733 Brotli bytes - with esbuild, and 3,465,269 raw / 1,404,047 gzip / 1,088,637 Brotli bytes as + with esbuild, and 3,465,269 raw / 1,404,044 gzip / 1,088,905 Brotli bytes as optimized Hermes bytecode. The reviewed ceilings advance to 1,660,000 / 390,000 / 307,000 Vite bytes, 1,296,000 / 357,000 / 287,000 esbuild bytes, and 3,470,000 / 1,406,000 / 1,090,000 Hermes bytes; Metro remains within its diff --git a/packages/wallet/wallet-toolbox/src/monitor/Monitor.ts b/packages/wallet/wallet-toolbox/src/monitor/Monitor.ts index 45e70860c..21c782b50 100644 --- a/packages/wallet/wallet-toolbox/src/monitor/Monitor.ts +++ b/packages/wallet/wallet-toolbox/src/monitor/Monitor.ts @@ -457,7 +457,7 @@ export class Monitor { // TaskCheckNoSends.checkNow flag was designed for this signal // (see TaskCheckNoSends.ts:22-25) but was never wired. TaskCheckNoSends.checkNow = true - TaskNoSendExpiry.checkNow = true + TaskNoSendExpiry.requestCheck() } /** diff --git a/packages/wallet/wallet-toolbox/src/monitor/tasks/TaskNoSendExpiry.ts b/packages/wallet/wallet-toolbox/src/monitor/tasks/TaskNoSendExpiry.ts index db86f9f76..a361a3e41 100644 --- a/packages/wallet/wallet-toolbox/src/monitor/tasks/TaskNoSendExpiry.ts +++ b/packages/wallet/wallet-toolbox/src/monitor/tasks/TaskNoSendExpiry.ts @@ -9,7 +9,11 @@ import { WalletMonitorTask } from './WalletMonitorTask' */ export class TaskNoSendExpiry extends WalletMonitorTask { static readonly taskName = 'NoSendExpiry' - static checkNow = false + private static checkNow = false + + static requestCheck(): void { + TaskNoSendExpiry.checkNow = true + } constructor( monitor: Monitor, diff --git a/packages/wallet/wallet-toolbox/src/storage/StorageIdb.ts b/packages/wallet/wallet-toolbox/src/storage/StorageIdb.ts index 284ca0efd..b7a92eab4 100644 --- a/packages/wallet/wallet-toolbox/src/storage/StorageIdb.ts +++ b/packages/wallet/wallet-toolbox/src/storage/StorageIdb.ts @@ -1230,7 +1230,7 @@ export class StorageIdb extends StorageProvider implements WalletStorageProvider const store = dbTrx.objectStore('transactions') try { const transaction = await store.get(transactionId) - if (transaction == null || transaction.noSendExpiryState !== expected) return false + if (transaction?.noSendExpiryState !== expected) return false await (store.put as (value: TableTransaction) => Promise)({ ...transaction, noSendExpiryState: next, diff --git a/packages/wallet/wallet-toolbox/src/storage/methods/createAction.ts b/packages/wallet/wallet-toolbox/src/storage/methods/createAction.ts index 373ef5add..b69c3a3e4 100644 --- a/packages/wallet/wallet-toolbox/src/storage/methods/createAction.ts +++ b/packages/wallet/wallet-toolbox/src/storage/methods/createAction.ts @@ -580,6 +580,60 @@ async function persistNewOutput( return describeNewOutput(o, tags, txBaskets) } +async function createRequiredOutput( + storage: StorageProvider, + userId: number, + xo: XValidCreateActionOutput, + ctx: CreateTransactionSdkContext, + txBaskets: Record, + trx?: TrxToken +): Promise<{ o: TableOutput; tags: string[] }> { + const o = makeDefaultOutput(userId, ctx.transactionId, xo.satoshis, xo.vout) + if (xo.purpose === 'service-charge') { + const lockingScript = asArray(xo.lockingScript) + const now = new Date() + await storage.insertCommission( + { + userId, + transactionId: ctx.transactionId, + lockingScript, + satoshis: xo.satoshis, + isRedeemed: false, + keyOffset: verifyTruthy(xo.keyOffset), + created_at: now, + updated_at: now, + commissionId: 0 + }, + trx + ) + o.lockingScript = lockingScript + o.providedBy = 'storage' + o.purpose = 'storage-commission' + o.type = 'custom' + o.spendable = false + return { o, tags: [] } + } + if (xo.purpose === 'change') { + o.basketId = ctx.changeBasket.basketId + o.change = true + o.derivationPrefix = verifyTruthy(ctx.derivationPrefix) + o.derivationSuffix = verifyTruthy(xo.derivationSuffix) + o.providedBy = 'storage' + o.purpose = 'change' + o.type = 'P2PKH' + o.spendable = true + return { o, tags: [] } + } + o.lockingScript = asArray(xo.lockingScript) + o.basketId = xo.basket ? txBaskets[xo.basket].basketId : undefined + o.customInstructions = xo.customInstructions + o.outputDescription = xo.outputDescription + o.providedBy = xo.providedBy + o.purpose = xo.purpose || '' + o.type = 'custom' + return { o, tags: xo.tags } +} + async function createNewOutputs( storage: StorageProvider, userId: number, @@ -602,52 +656,7 @@ async function createNewOutputs( const newOutputs: Array<{ o: TableOutput; tags: string[] }> = [] for (const xo of ctx.xoutputs) { - const lockingScript = xo.purpose === 'change' ? undefined : asArray(xo.lockingScript) - if (xo.purpose === 'service-charge') { - const now = new Date() - await storage.insertCommission( - { - userId, - transactionId: ctx.transactionId, - lockingScript: verifyTruthy(lockingScript), - satoshis: xo.satoshis, - isRedeemed: false, - keyOffset: verifyTruthy(xo.keyOffset), - created_at: now, - updated_at: now, - commissionId: 0 - }, - trx - ) - const o = makeDefaultOutput(userId, ctx.transactionId, xo.satoshis, xo.vout) - o.lockingScript = verifyTruthy(lockingScript) - o.providedBy = 'storage' - o.purpose = 'storage-commission' - o.type = 'custom' - o.spendable = false - newOutputs.push({ o, tags: [] }) - } else if (xo.purpose === 'change') { - const o = makeDefaultOutput(userId, ctx.transactionId, xo.satoshis, xo.vout) - o.basketId = ctx.changeBasket.basketId - o.change = true - o.derivationPrefix = verifyTruthy(ctx.derivationPrefix) - o.derivationSuffix = verifyTruthy(xo.derivationSuffix) - o.providedBy = 'storage' - o.purpose = 'change' - o.type = 'P2PKH' - o.spendable = true - newOutputs.push({ o, tags: [] }) - } else { - const o = makeDefaultOutput(userId, ctx.transactionId, xo.satoshis, xo.vout) - o.lockingScript = verifyTruthy(lockingScript) - o.basketId = xo.basket ? txBaskets[xo.basket].basketId : undefined - o.customInstructions = xo.customInstructions - o.outputDescription = xo.outputDescription - o.providedBy = xo.providedBy - o.purpose = xo.purpose || '' - o.type = 'custom' - newOutputs.push({ o, tags: xo.tags }) - } + newOutputs.push(await createRequiredOutput(storage, userId, xo, ctx, txBaskets, trx)) } for (const o of changeOutputs) { diff --git a/packages/wallet/wallet-toolbox/src/storage/methods/noSendExpiry.ts b/packages/wallet/wallet-toolbox/src/storage/methods/noSendExpiry.ts index 7399bd08e..238985fa3 100644 --- a/packages/wallet/wallet-toolbox/src/storage/methods/noSendExpiry.ts +++ b/packages/wallet/wallet-toolbox/src/storage/methods/noSendExpiry.ts @@ -136,7 +136,7 @@ export async function prepareNoSendExpiry( fundingArgs.includeAllSourceTransactions = target.includeAllSourceTransactions const funding = await createAction(storage, auth, fundingArgs) const anchor = funding.outputs.find(output => output.providedBy === 'storage' && output.purpose === 'change') - if (anchor == null || anchor.satoshis !== anchorSatoshis) { + if (anchor?.satoshis !== anchorSatoshis) { throw new WERR_INVALID_OPERATION('BRC-177 funding plan did not contain its exact revocation anchor') } return { @@ -406,7 +406,7 @@ export async function armNoSendExpiry( // no database request is pending, then revalidate the complete snapshot in // the atomic section before publishing the armed state. const snapshot = await validateArmSnapshot(storage, userId, args.reference, args, reclaim) - await verifyReclaimSignature(storage, snapshot, rawTx, args.reclaimTxid, undefined) + await verifyReclaimSignature(storage, snapshot, rawTx, args.reclaimTxid) await storage.transaction(async trx => { const target = await validateArmSnapshot(storage, userId, args.reference, args, reclaim, trx) diff --git a/packages/wallet/wallet-toolbox/src/storage/methods/noSendExpiryLifecycle.ts b/packages/wallet/wallet-toolbox/src/storage/methods/noSendExpiryLifecycle.ts index 569c8bbfa..408f5f5e4 100644 --- a/packages/wallet/wallet-toolbox/src/storage/methods/noSendExpiryLifecycle.ts +++ b/packages/wallet/wallet-toolbox/src/storage/methods/noSendExpiryLifecycle.ts @@ -215,7 +215,7 @@ async function noteTargetObservedDuringRace(storage: StorageProvider, transactio } if (current.txid != null) { const req = await EntityProvenTxReq.fromStorageTxid(storage, current.txid, trx) - if (req != null && req.status === 'nosend') { + if (req?.status === 'nosend') { req.status = 'unmined' req.addHistoryNote({ what: 'brc177-target-observed-during-reclaim-race' }) await req.updateStorageDynamicProperties(storage, trx) @@ -550,6 +550,24 @@ function isKnownOrMined(status: StatusForTxidResult['status'] | undefined): bool return status === 'known' || status === 'mined' } +function recordRaceResult(result: NoSendExpiryLifecycleResult, race: Awaited>): void { + if (race === 'reclaimed') result.reclaimed++ + else if (race === 'target') result.targetWon++ + else result.deferred++ +} + +async function processReclaimRace( + storage: StorageProvider, + transaction: TableTransaction, + targetStatus: StatusForTxidResult['status'] | undefined, + result: NoSendExpiryLifecycleResult +): Promise { + if (isKnownOrMined(targetStatus) && (await noteTargetObservedDuringRace(storage, transaction))) { + result.observed++ + } + recordRaceResult(result, await reconcileRace(storage, transaction)) +} + async function processObservationOrRace( storage: StorageProvider, transaction: TableTransaction, @@ -564,13 +582,7 @@ async function processObservationOrRace( return true } if (transaction.noSendExpiryState === 'reclaiming') { - if (isKnownOrMined(targetStatus)) { - if (await noteTargetObservedDuringRace(storage, transaction)) result.observed++ - } - const race = await reconcileRace(storage, transaction) - if (race === 'reclaimed') result.reclaimed++ - else if (race === 'target') result.targetWon++ - else result.deferred++ + await processReclaimRace(storage, transaction, targetStatus, result) return true } const stateCanObserve = diff --git a/packages/wallet/wallet-toolbox/src/storage/methods/processAction.ts b/packages/wallet/wallet-toolbox/src/storage/methods/processAction.ts index e97ad6dc8..1dceb1212 100644 --- a/packages/wallet/wallet-toolbox/src/storage/methods/processAction.ts +++ b/packages/wallet/wallet-toolbox/src/storage/methods/processAction.ts @@ -32,6 +32,7 @@ import { ProvenTxReqStatus, TransactionStatus } from '../../sdk/types' import { parseTxScriptOffsets, TxScriptOffsets } from '../../utility/parseTxScriptOffsets' import { TableTransaction } from '../schema/tables/TableTransaction' import { TableOutput } from '../schema/tables/TableOutput' +import { TableCommission } from '../schema/tables/TableCommission' import { asArray, asString } from '../../utility/utilityHelpers.noBuffer' import { WalletError } from '../../sdk/WalletError' import { classifyReqStatus } from '../storageProviderHelpers' @@ -379,13 +380,14 @@ interface ValidCommitNewTxToStorageArgs { postStatus?: ReqTxStatus } -async function validateCommitNewTxToStorageArgs( - storage: StorageProvider, - auth: AuthId, - params: StorageProcessActionArgs -): Promise { - const userId = verifyId(auth.userId) - if (!params.reference || !params.txid || params.rawTx == null) { +function parseProcessActionTransaction(params: StorageProcessActionArgs): { + reference: string + txid: string + rawTx: number[] + tx: BsvTransaction +} { + const { reference, txid } = params + if (!reference || !txid || params.rawTx == null) { throw new WERR_INVALID_OPERATION('One or more expected params are undefined.') } const rawTx = asArray(params.rawTx) @@ -395,9 +397,66 @@ async function validateCommitNewTxToStorageArgs( } catch { throw new WERR_INVALID_OPERATION('Parsing serialized transaction failed.') } - if (params.txid !== tx.id('hex')) { + if (txid !== tx.id('hex')) { throw new WERR_INVALID_OPERATION("Hash of serialized transaction doesn't match expected txid") } + return { reference, txid, rawTx, tx } +} + +async function validateNoSendExpiryRelease( + storage: StorageProvider, + auth: AuthId, + params: StorageProcessActionArgs, + transaction: TableTransaction +): Promise { + if (transaction.noSendExpiryState == null) return + if (auth.isActive !== true) throw new WERR_NOT_ACTIVE('BRC-177 requires the active storage provider') + if (!params.isNoSend || params.isSendWith) { + throw new WERR_INVALID_OPERATION('BRC-177 protected actions must remain noSend and cannot use sendWith') + } + if (transaction.noSendExpiryState !== 'unsigned' || transaction.noSendExpiryReclaimRawTx == null) { + throw new WERR_INVALID_OPERATION('BRC-177 protected action is not armed for signature release') + } + const deadline = verifyInteger(transaction.noSendExpiryDeadline) + const expired = + transaction.noSendExpiryMode === 'blockheight' + ? (await storage.getServices().getHeight()) >= deadline + : Math.floor(Date.now() / 1000) >= deadline + if (expired) throw new WERR_INVALID_OPERATION('BRC-177 protected action has expired') +} + +function validatePlannedTransaction(transaction: TableTransaction): void { + if (!transaction.isOutgoing) throw new WERR_INVALID_OPERATION('isOutgoing is not true') + if (transaction.inputBEEF == null) throw new WERR_INVALID_OPERATION() + if (transaction.status !== 'unsigned' && transaction.status !== 'unprocessed') { + throw new WERR_INVALID_OPERATION(`invalid transaction status ${transaction.status}`) + } +} + +function validateCommissionOutput( + storage: StorageProvider, + tx: BsvTransaction, + commissionRows: TableCommission[] +): void { + if (storage.commissionSatoshis <= 0) return + const commission = verifyOneOrNone(commissionRows) + if (commission == null) throw new WERR_INTERNAL() + const commissionValid = tx.outputs.some( + output => + output.satoshis === commission.satoshis && output.lockingScript.toHex() === asString(commission.lockingScript) + ) + if (!commissionValid) { + throw new WERR_INVALID_OPERATION('Transaction did not include an output to cover service fee.') + } +} + +async function validateCommitNewTxToStorageArgs( + storage: StorageProvider, + auth: AuthId, + params: StorageProcessActionArgs +): Promise { + const userId = verifyId(auth.userId) + const { reference, txid, rawTx, tx } = parseProcessActionTransaction(params) const services = storage.getServices() if (!(await services.nLockTimeIsFinal(tx))) { throw new WERR_INVALID_OPERATION(`This transaction is not final. @@ -407,31 +466,11 @@ async function validateCommitNewTxToStorageArgs( const txScriptOffsets = parseTxScriptOffsets(rawTx) const transaction = verifyOne( await storage.findTransactions({ - partial: { userId, reference: params.reference } + partial: { userId, reference } }) ) - if (transaction.noSendExpiryState != null) { - if (auth.isActive !== true) throw new WERR_NOT_ACTIVE('BRC-177 requires the active storage provider') - if (!params.isNoSend || params.isSendWith) { - throw new WERR_INVALID_OPERATION('BRC-177 protected actions must remain noSend and cannot use sendWith') - } - if (transaction.noSendExpiryState !== 'unsigned' || transaction.noSendExpiryReclaimRawTx == null) { - throw new WERR_INVALID_OPERATION('BRC-177 protected action is not armed for signature release') - } - const deadline = verifyInteger(transaction.noSendExpiryDeadline) - const expired = transaction.noSendExpiryMode === 'blockheight' - ? (await storage.getServices().getHeight()) >= deadline - : Math.floor(Date.now() / 1000) >= deadline - if (expired) { - throw new WERR_INVALID_OPERATION('BRC-177 protected action has expired') - } - } - if (!transaction.isOutgoing) throw new WERR_INVALID_OPERATION('isOutgoing is not true') - if (transaction.inputBEEF == null) throw new WERR_INVALID_OPERATION() - // Transaction must have unsigned or unprocessed status - if (transaction.status !== 'unsigned' && transaction.status !== 'unprocessed') { - throw new WERR_INVALID_OPERATION(`invalid transaction status ${transaction.status}`) - } + await validateNoSendExpiryRelease(storage, auth, params, transaction) + validatePlannedTransaction(transaction) const transactionId = verifyId(transaction.transactionId) // These reads are independent once the planned transaction is resolved. // Running them together removes two network-database round trips from every @@ -443,19 +482,9 @@ async function validateCommitNewTxToStorageArgs( : Promise.resolve([]) ]) - const commission = verifyOneOrNone(commissionRows) - if (storage.commissionSatoshis > 0) { - // A commission is required... - if (commission == null) throw new WERR_INTERNAL() - const commissionValid = tx.outputs.some( - x => x.satoshis === commission.satoshis && x.lockingScript.toHex() === asString(commission.lockingScript) - ) - if (!commissionValid) { - throw new WERR_INVALID_OPERATION('Transaction did not include an output to cover service fee.') - } - } + validateCommissionOutput(storage, tx, commissionRows) - const req = EntityProvenTxReq.fromTxid(params.txid, rawTx, transaction.inputBEEF) + const req = EntityProvenTxReq.fromTxid(txid, rawTx, transaction.inputBEEF) req.addNotifyTransactionId(transactionId) // "Processing" a transaction is the final step of creating a new one. @@ -474,8 +503,8 @@ async function validateCommitNewTxToStorageArgs( req.status = status.req const vargs: ValidCommitNewTxToStorageArgs = { - reference: params.reference, - txid: params.txid, + reference, + txid, rawTx, isSendWith: !!params.sendWith && params.sendWith.length > 0, isDelayed: params.isDelayed, @@ -490,7 +519,7 @@ async function validateCommitNewTxToStorageArgs( outputUpdates: [], // update txid, status in transactions table and drop rawTransaction value transactionUpdate: { - txid: params.txid, + txid, rawTx: undefined, inputBEEF: undefined, status: status.tx diff --git a/packages/wallet/wallet-toolbox/src/utility/brc177NoSendExpiry.ts b/packages/wallet/wallet-toolbox/src/utility/brc177NoSendExpiry.ts index 0e8ed17b7..cc678bf22 100644 --- a/packages/wallet/wallet-toolbox/src/utility/brc177NoSendExpiry.ts +++ b/packages/wallet/wallet-toolbox/src/utility/brc177NoSendExpiry.ts @@ -100,7 +100,7 @@ export function parseBrc177NoSendExpiryLabels(labels: string[] | undefined): Brc const label = matching[0] const remainder = label.slice(BRC177_NO_SEND_EXPIRY_PREFIX.length) const separator = remainder.indexOf(' ') - if (separator <= 0 || separator === remainder.length - 1 || remainder.indexOf(' ', separator + 1) !== -1) { + if (separator <= 0 || separator === remainder.length - 1 || remainder.includes(' ', separator + 1)) { throw new WERR_INVALID_PARAMETER('labels', 'a valid BRC-177 noSend expiry label') } diff --git a/packages/wallet/wallet-toolbox/test/Wallet/action/noSendExpiry.test.ts b/packages/wallet/wallet-toolbox/test/Wallet/action/noSendExpiry.test.ts index b59c7a420..4fe12d721 100644 --- a/packages/wallet/wallet-toolbox/test/Wallet/action/noSendExpiry.test.ts +++ b/packages/wallet/wallet-toolbox/test/Wallet/action/noSendExpiry.test.ts @@ -595,7 +595,7 @@ describe('BRC-177 noSend expiry reference implementation', () => { 'Active storage does not support BRC-177' ) capabilities.mockRestore() - expect((await services.storage.getUnminedTransactions()).length).toBe(initiallyUnmined) + expect(await services.storage.getUnminedTransactions()).toHaveLength(initiallyUnmined) await expect( ctx.storage.prepareNoSendExpiry( @@ -612,7 +612,7 @@ describe('BRC-177 noSend expiry reference implementation', () => { }) ) ).rejects.toThrow('BRC-177 noSend expiry label') - expect((await services.storage.getUnminedTransactions()).length).toBe(initiallyUnmined) + expect(await services.storage.getUnminedTransactions()).toHaveLength(initiallyUnmined) const arm = ctx.storage.armNoSendExpiry.bind(ctx.storage) const tamper = jest.spyOn(ctx.storage, 'armNoSendExpiry').mockImplementationOnce(async args => { @@ -634,7 +634,7 @@ describe('BRC-177 noSend expiry reference implementation', () => { options: { noSend: false } }) ).rejects.toThrow('options.noSend') - expect((await services.storage.getUnminedTransactions()).length).toBe(unminedBefore) + expect(await services.storage.getUnminedTransactions()).toHaveLength(unminedBefore) const malformedOptions = [ { noSend: true, sendWith: ['03'.repeat(32)] }, @@ -649,7 +649,7 @@ describe('BRC-177 noSend expiry reference implementation', () => { }) ).rejects.toThrow() } - expect((await services.storage.getUnminedTransactions()).length).toBe(unminedBefore) + expect(await services.storage.getUnminedTransactions()).toHaveLength(unminedBefore) await expect( ctx.wallet.createAction({ @@ -657,7 +657,7 @@ describe('BRC-177 noSend expiry reference implementation', () => { labels: [`p nosend expiry timestamp ${Math.floor(Date.now() / 1000) - 1}`] }) ).rejects.toThrow('timestamp later than the current time') - expect((await services.storage.getUnminedTransactions()).length).toBe(unminedBefore) + expect(await services.storage.getUnminedTransactions()).toHaveLength(unminedBefore) await expect( ctx.wallet.createAction({ @@ -665,7 +665,7 @@ describe('BRC-177 noSend expiry reference implementation', () => { labels: [`p nosend expiry blockheight ${await services.getHeight()}`] }) ).rejects.toThrow('blockheight later than the current best-chain height') - expect((await services.storage.getUnminedTransactions()).length).toBe(unminedBefore) + expect(await services.storage.getUnminedTransactions()).toHaveLength(unminedBefore) await expect( ctx.wallet.createAction({ @@ -673,7 +673,7 @@ describe('BRC-177 noSend expiry reference implementation', () => { labels: [`p nosend expiry seconds ${Number.MAX_SAFE_INTEGER}`] }) ).rejects.toThrow('safely schedulable') - expect((await services.storage.getUnminedTransactions()).length).toBe(unminedBefore) + expect(await services.storage.getUnminedTransactions()).toHaveLength(unminedBefore) const created = await ctx.wallet.createAction(protectedArgs(3600)) const target = verifyOne(await ctx.active.findTransactions({ partial: { txid: created.txid } })) From 64d2c8ccb723c14f6ad5cfbb11c2365ab9ae5c07 Mon Sep 17 00:00:00 2001 From: Ty J Everett Date: Sun, 30 Aug 2026 21:36:49 -0700 Subject: [PATCH 03/10] test(wallet-toolbox): harden BRC-177 coverage --- .../WalletPermissionsManager.pmodules.test.ts | 107 ++++++++- .../tasks/__tests__/TaskNoSendExpiry.test.ts | 86 +++++++ .../createNoSendExpiryAction.test.ts | 32 ++- .../__test/WalletStorageManager.test.ts | 29 +++ .../entities/__tests/TransactionTests.test.ts | 26 +++ .../__tests/brc177NoSendExpiry.test.ts | 22 +- .../test/Wallet/action/noSendExpiry.test.ts | 218 ++++++++++++++++++ 7 files changed, 503 insertions(+), 17 deletions(-) create mode 100644 packages/wallet/wallet-toolbox/src/monitor/tasks/__tests__/TaskNoSendExpiry.test.ts diff --git a/packages/wallet/wallet-toolbox/src/__tests/WalletPermissionsManager.pmodules.test.ts b/packages/wallet/wallet-toolbox/src/__tests/WalletPermissionsManager.pmodules.test.ts index e3c231b0b..ae78cf4c3 100644 --- a/packages/wallet/wallet-toolbox/src/__tests/WalletPermissionsManager.pmodules.test.ts +++ b/packages/wallet/wallet-toolbox/src/__tests/WalletPermissionsManager.pmodules.test.ts @@ -253,34 +253,108 @@ describe('WalletPermissionsManager - Permission Module Support', () => { expect(underlying.createAction).not.toHaveBeenCalled() }) - it('rejects malformed BRC-177 action options before requesting permissions', async () => { + it.each([ + [{ outputs: [] }, 'at least one output'], + [{ options: { noSend: false } }, 'require noSend'], + [{ options: { noSend: true, sendWith: ['01'.repeat(32)] } }, 'cannot use sendWith'], + [{ options: { noSend: true, noSendChange: [{ txid: '02'.repeat(32), vout: 0 }] } }, 'cannot supply noSendChange'], + [{ options: { noSend: true, returnTXIDOnly: true } }, 'cannot use returnTXIDOnly'] + ])('rejects malformed BRC-177 action shape %# before requesting permissions', async (override, message) => { const manager = new WalletPermissionsManager(underlying, 'customToken.domain.com') const labels = jest.spyOn(manager, 'ensureLabelAccess') const spending = jest.spyOn(manager, 'ensureSpendingAuthorization') + const base = { + description: 'Malformed BRC-177 protected action', + labels: ['p nosend expiry seconds 30'], + outputs: [ + { + lockingScript: 'abcd', + satoshis: 1000, + outputDescription: 'protected output' + } + ], + options: { noSend: true } + } + + await expect( + manager.createAction( + { + ...base, + ...override, + options: { ...base.options, ...override.options } + } as any, + 'app.com' + ) + ).rejects.toThrow(message) + + expect(labels).not.toHaveBeenCalled() + expect(spending).not.toHaveBeenCalled() + expect(underlying.createAction).not.toHaveBeenCalled() + }) + + it.each([ + ['not-a-number', 'valid satoshi amounts'], + [-1, 'valid satoshi amounts'], + [1.5, 'valid satoshi amounts'] + ])('rejects an invalid BRC-177 output amount %p before spending authorization', async (satoshis, message) => { + const manager = new WalletPermissionsManager(underlying, 'customToken.domain.com') + jest.spyOn(manager, 'ensureLabelAccess').mockResolvedValueOnce(true) + const spending = jest.spyOn(manager, 'ensureSpendingAuthorization') + await expect( manager.createAction( { - description: 'Malformed BRC-177 protected action', + description: 'Malformed BRC-177 protected amount', + labels: ['p nosend expiry seconds 30'], + outputs: [{ lockingScript: 'abcd', satoshis, outputDescription: 'protected output' }], + options: { noSend: true } + } as any, + 'app.com' + ) + ).rejects.toThrow(message) + + expect(spending).not.toHaveBeenCalled() + expect(underlying.createAction).not.toHaveBeenCalled() + }) + + it('rejects a BRC-177 output total outside the safe integer range', async () => { + const manager = new WalletPermissionsManager(underlying, 'customToken.domain.com') + jest.spyOn(manager, 'ensureLabelAccess').mockResolvedValueOnce(true) + + await expect( + manager.createAction( + { + description: 'Overflowing BRC-177 protected amount', labels: ['p nosend expiry seconds 30'], outputs: [ - { - lockingScript: 'abcd', - satoshis: 1000, - outputDescription: 'protected output' - } + { lockingScript: '51', satoshis: Number.MAX_SAFE_INTEGER, outputDescription: 'first' }, + { lockingScript: '51', satoshis: 1, outputDescription: 'second' } ], - options: { noSend: false } + options: { noSend: true } }, 'app.com' ) - ).rejects.toThrow('require noSend') + ).rejects.toThrow('safely supported range') - expect(labels).not.toHaveBeenCalled() - expect(spending).not.toHaveBeenCalled() expect(underlying.createAction).not.toHaveBeenCalled() }) + it('uses the built-in BRC-177 module for listActions without a spending preflight', async () => { + const manager = new WalletPermissionsManager(underlying, 'customToken.domain.com') + const labels = jest.spyOn(manager, 'ensureLabelAccess').mockResolvedValueOnce(true) + const spending = jest.spyOn(manager, 'ensureSpendingAuthorization') + underlying.listActions.mockResolvedValueOnce({ totalActions: 0, actions: [] }) + + await manager.listActions({ labels: ['p nosend expiry seconds 30'] }, 'app.com') + + expect(labels).toHaveBeenCalledWith( + expect.objectContaining({ label: 'BRC-177 noSend expiry', usageType: 'list', reason: 'listActions' }) + ) + expect(spending).not.toHaveBeenCalled() + expect(underlying.listActions).toHaveBeenCalledTimes(1) + }) + it('forces the final BRC-177 authorization to bypass an identical recent grant', async () => { const manager = new WalletPermissionsManager(underlying, 'customToken.domain.com', { seekBasketInsertionPermissions: false @@ -334,6 +408,17 @@ describe('WalletPermissionsManager - Permission Module Support', () => { expect(request).toHaveBeenCalledTimes(1) }) + it('reuses an amount-scoped recent spending grant by default', async () => { + const manager = new WalletPermissionsManager(underlying, 'customToken.domain.com') + const internals = manager as any + jest.spyOn(internals, 'hasRecentOrPendingGrant').mockResolvedValue(true) + const findToken = jest.spyOn(internals, 'findSpendingToken') + + await expect(manager.ensureSpendingAuthorization({ originator: 'app.com', satoshis: 1000 })).resolves.toBe(true) + + expect(findToken).not.toHaveBeenCalled() + }) + it('should delegate internalizeAction when a P-label is present', async () => { const testModule: PermissionsModule = { onRequest: jest.fn(async req => req), diff --git a/packages/wallet/wallet-toolbox/src/monitor/tasks/__tests__/TaskNoSendExpiry.test.ts b/packages/wallet/wallet-toolbox/src/monitor/tasks/__tests__/TaskNoSendExpiry.test.ts new file mode 100644 index 000000000..3768beac5 --- /dev/null +++ b/packages/wallet/wallet-toolbox/src/monitor/tasks/__tests__/TaskNoSendExpiry.test.ts @@ -0,0 +1,86 @@ +import { processNoSendExpiryLifecycle } from '../../../storage/methods/noSendExpiryLifecycle' +import { TaskNoSendExpiry } from '../TaskNoSendExpiry' + +jest.mock('../../../storage/methods/noSendExpiryLifecycle', () => ({ + processNoSendExpiryLifecycle: jest.fn() +})) + +const lifecycle = processNoSendExpiryLifecycle as jest.MockedFunction + +function makeTask(active = true, triggerMsecs = 10) { + const provider = { name: 'provider' } + const storage = { + isActiveStorageProvider: jest.fn(() => active), + runAsStorageProvider: jest.fn(async callback => await callback(provider)) + } + return { + provider, + storage, + task: new TaskNoSendExpiry({ storage } as any, triggerMsecs) + } +} + +describe('TaskNoSendExpiry', () => { + beforeEach(() => { + jest.clearAllMocks() + ;(TaskNoSendExpiry as any).checkNow = false + }) + + test('supports interval and explicit lifecycle triggers', () => { + const { task } = makeTask(true, 10) + task.lastRunMsecsSinceEpoch = 100 + + expect(task.trigger(105)).toEqual({ run: false }) + expect(task.trigger(111)).toEqual({ run: true }) + + const disabled = makeTask(true, 0).task + expect(disabled.trigger(1_000)).toEqual({ run: false }) + + const defaultInterval = new TaskNoSendExpiry({ storage: makeTask().storage } as any) + expect(defaultInterval.trigger(5 * 1_000 + 1)).toEqual({ run: true }) + + TaskNoSendExpiry.requestCheck() + expect(disabled.trigger(1_000)).toEqual({ run: true }) + }) + + test('does not execute lifecycle work for a non-authoritative provider', async () => { + const { task, storage } = makeTask(false) + + await expect(task.runTask()).resolves.toBe('') + expect(storage.runAsStorageProvider).not.toHaveBeenCalled() + expect(lifecycle).not.toHaveBeenCalled() + }) + + test('runs keylessly on the active provider and reports only inspected work', async () => { + const first = makeTask() + lifecycle.mockResolvedValueOnce({ + inspected: 0, + cancelled: 0, + observed: 0, + reclaimActivated: 0, + reclaimed: 0, + targetWon: 0, + deferred: 0, + errors: 0 + }) + TaskNoSendExpiry.requestCheck() + await expect(first.task.runTask()).resolves.toBe('') + expect(first.task.trigger(0)).toEqual({ run: false }) + expect(lifecycle).toHaveBeenCalledWith(first.provider) + + const second = makeTask() + lifecycle.mockResolvedValueOnce({ + inspected: 7, + cancelled: 1, + observed: 2, + reclaimActivated: 3, + reclaimed: 4, + targetWon: 5, + deferred: 6, + errors: 1 + }) + await expect(second.task.runTask()).resolves.toBe( + 'BRC-177 inspected=7 cancelled=1 observed=2 activated=3 reclaimed=4 targetWon=5 deferred=6 errors=1\n' + ) + }) +}) diff --git a/packages/wallet/wallet-toolbox/src/signer/methods/__tests__/createNoSendExpiryAction.test.ts b/packages/wallet/wallet-toolbox/src/signer/methods/__tests__/createNoSendExpiryAction.test.ts index 3656cad10..5ddfa2e88 100644 --- a/packages/wallet/wallet-toolbox/src/signer/methods/__tests__/createNoSendExpiryAction.test.ts +++ b/packages/wallet/wallet-toolbox/src/signer/methods/__tests__/createNoSendExpiryAction.test.ts @@ -1,6 +1,6 @@ import { Validation } from '@bsv/sdk' import { targetForStorage } from '../createNoSendExpiryAction' -import { makeNoSendExpiryFundingArgs } from '../../../storage/methods/noSendExpiry' +import { makeNoSendExpiryFundingArgs, validateNoSendExpiryRequest } from '../../../storage/methods/noSendExpiry' describe('createNoSendExpiryAction storage boundary', () => { test('keeps unlocking scripts and logger objects on the signer side', () => { @@ -51,5 +51,35 @@ describe('createNoSendExpiryAction storage boundary', () => { ]) expect(funding.labels).toEqual(['admin brc177 funding', 'admin originator app.example', 'admin month 2026-08']) + expect(makeNoSendExpiryFundingArgs(5001).labels).toEqual(['admin brc177 funding']) + }) + + test.each([ + [{ outputs: [] }, 'outputs'], + [{ options: { noSend: false } }, 'options.noSend'], + [{ options: { sendWith: ['01'.repeat(32)] } }, 'options.sendWith'], + [{ options: { noSendChange: [{ txid: '02'.repeat(32), vout: 0 }] } }, 'options.noSendChange'], + [{ options: { returnTXIDOnly: true } }, 'options.returnTXIDOnly'] + ])('rejects invalid protected action shape %#', (override, parameter) => { + const valid = Validation.validateCreateActionArgs({ + description: 'protected action', + labels: ['p nosend expiry seconds 30'], + outputs: [{ lockingScript: '51', satoshis: 1, outputDescription: 'recipient' }], + options: { noSend: true } + }) + const args = { + ...valid, + ...override, + options: { ...valid.options, ...('options' in override ? override.options : {}) } + } + expect(() => validateNoSendExpiryRequest(args as Validation.ValidCreateActionArgs)).toThrow(parameter) + }) + + test('leaves an ordinary valid action outside BRC-177', () => { + const args = Validation.validateCreateActionArgs({ + description: 'ordinary action', + outputs: [{ lockingScript: '51', satoshis: 1, outputDescription: 'recipient' }] + }) + expect(validateNoSendExpiryRequest(args)).toBeUndefined() }) }) diff --git a/packages/wallet/wallet-toolbox/src/storage/__test/WalletStorageManager.test.ts b/packages/wallet/wallet-toolbox/src/storage/__test/WalletStorageManager.test.ts index 81478f3a7..ad9fd29c6 100644 --- a/packages/wallet/wallet-toolbox/src/storage/__test/WalletStorageManager.test.ts +++ b/packages/wallet/wallet-toolbox/src/storage/__test/WalletStorageManager.test.ts @@ -26,6 +26,35 @@ describe('WalletStorageManager tests', () => { const kp = _tu.getKeyPair(root.repeat(8)) const fredsAddress = kp.address + test('BRC-177 storage entry points fail closed when authority or persistence support is absent', async () => { + const { activeStorage: provider, storage: manager } = ctxs[0] + const inactive = { userId: 1, isActive: false } + const active = { userId: 1, isActive: true } + + await expect(provider.prepareNoSendExpiry(inactive, {} as any)).rejects.toThrow('active storage provider') + await expect(provider.activateNoSendExpiry(inactive, {} as any)).rejects.toThrow('active storage provider') + await expect(provider.armNoSendExpiry(inactive, {} as any)).rejects.toThrow('active storage provider') + + const persistence = jest.spyOn(provider as any, 'supportsNoSendExpiryPersistence').mockReturnValue(false) + try { + expect(await provider.getCapabilities()).not.toHaveProperty('brc177NoSendExpiry') + await expect(provider.prepareNoSendExpiry(active, {} as any)).rejects.toThrow('atomic lifecycle persistence') + await expect(provider.activateNoSendExpiry(active, {} as any)).rejects.toThrow('atomic lifecycle persistence') + await expect(provider.armNoSendExpiry(active, {} as any)).rejects.toThrow('atomic lifecycle persistence') + } finally { + persistence.mockRestore() + } + + const writer = jest.spyOn(manager, 'runAsWriter').mockImplementation(async callback => await callback({} as any)) + try { + await expect(manager.prepareNoSendExpiry({} as any)).rejects.toThrow('does not support BRC-177') + await expect(manager.activateNoSendExpiry({} as any)).rejects.toThrow('does not support BRC-177') + await expect(manager.armNoSendExpiry({} as any)).rejects.toThrow('does not support BRC-177') + } finally { + writer.mockRestore() + } + }) + test('1_runAsReader runAsWriter runAsSync interlock correctly', async () => { const { storage } = await _tu.createSQLiteTestSetup1Wallet({ databaseName: 'syncTest1' diff --git a/packages/wallet/wallet-toolbox/src/storage/schema/entities/__tests/TransactionTests.test.ts b/packages/wallet/wallet-toolbox/src/storage/schema/entities/__tests/TransactionTests.test.ts index 44caf3619..69f96f34d 100644 --- a/packages/wallet/wallet-toolbox/src/storage/schema/entities/__tests/TransactionTests.test.ts +++ b/packages/wallet/wallet-toolbox/src/storage/schema/entities/__tests/TransactionTests.test.ts @@ -105,6 +105,19 @@ describe('Transaction class method tests', () => { // New setters tx.version = 2 tx.lockTime = 5000 + tx.noSendExpiryMode = 'timestamp' + tx.noSendExpiryValue = 2_000_000_000 + tx.noSendExpiryDeadline = 2_000_000_000 + tx.noSendExpiryState = 'signed' + tx.noSendExpiryAnchorTxid = '11'.repeat(32) + tx.noSendExpiryAnchorVout = 1 + tx.noSendExpiryReleasedAt = 123 + tx.noSendExpiryObservedAt = 456 + tx.noSendExpiryReclaimTxid = '22'.repeat(32) + tx.noSendExpiryReclaimRawTx = [7, 8, 9] + tx.noSendExpiryReclaimDerivationPrefix = 'prefix' + tx.noSendExpiryReclaimDerivationSuffix = 'suffix' + tx.noSendExpiryReclaimSatoshis = 700 expect(tx.transactionId).toBe(123) expect(tx.userId).toBe(456) @@ -122,6 +135,19 @@ describe('Transaction class method tests', () => { // Check new properties expect(tx.version).toBe(2) // Ensure version is set correctly expect(tx.lockTime).toBe(5000) // Ensure lockTime is set correctly + expect(tx.noSendExpiryMode).toBe('timestamp') + expect(tx.noSendExpiryValue).toBe(2_000_000_000) + expect(tx.noSendExpiryDeadline).toBe(2_000_000_000) + expect(tx.noSendExpiryState).toBe('signed') + expect(tx.noSendExpiryAnchorTxid).toBe('11'.repeat(32)) + expect(tx.noSendExpiryAnchorVout).toBe(1) + expect(tx.noSendExpiryReleasedAt).toBe(123) + expect(tx.noSendExpiryObservedAt).toBe(456) + expect(tx.noSendExpiryReclaimTxid).toBe('22'.repeat(32)) + expect(tx.noSendExpiryReclaimRawTx).toEqual([7, 8, 9]) + expect(tx.noSendExpiryReclaimDerivationPrefix).toBe('prefix') + expect(tx.noSendExpiryReclaimDerivationSuffix).toBe('suffix') + expect(tx.noSendExpiryReclaimSatoshis).toBe(700) }) // Test: `getBsvTx` returns parsed transaction diff --git a/packages/wallet/wallet-toolbox/src/utility/__tests/brc177NoSendExpiry.test.ts b/packages/wallet/wallet-toolbox/src/utility/__tests/brc177NoSendExpiry.test.ts index b6481b619..7308a3531 100644 --- a/packages/wallet/wallet-toolbox/src/utility/__tests/brc177NoSendExpiry.test.ts +++ b/packages/wallet/wallet-toolbox/src/utility/__tests/brc177NoSendExpiry.test.ts @@ -15,7 +15,9 @@ describe('BRC-177 noSend expiry labels', () => { }) test('ignores labels outside the reserved module', () => { + expect(parseBrc177NoSendExpiryLabels(undefined)).toBeUndefined() expect(parseBrc177NoSendExpiryLabels(['application label'])).toBeUndefined() + expect(hasBrc177NoSendExpiryLabel(undefined)).toBe(false) expect(hasBrc177NoSendExpiryLabel(['application label'])).toBe(false) }) @@ -41,10 +43,20 @@ describe('BRC-177 noSend expiry labels', () => { }) test('orders synchronized lifecycle states without allowing unsafe regression', () => { - expect(brc177NoSendExpiryStateRank('cancelled')).toBeGreaterThan(brc177NoSendExpiryStateRank('unsigned')) - expect(brc177NoSendExpiryStateRank('signed')).toBeGreaterThan(brc177NoSendExpiryStateRank('cancelled')) - expect(brc177NoSendExpiryStateRank('revocation-requested')).toBeGreaterThan(brc177NoSendExpiryStateRank('signed')) - expect(brc177NoSendExpiryStateRank('reclaiming')).toBeGreaterThan(brc177NoSendExpiryStateRank('broadcast')) - expect(brc177NoSendExpiryStateRank('target-won')).toBeGreaterThan(brc177NoSendExpiryStateRank('reclaimed')) + expect( + [ + undefined, + 'preparing', + 'unsigned', + 'cancelled', + 'signed', + 'revocation-requested', + 'conflicted', + 'broadcast', + 'reclaiming', + 'reclaimed', + 'target-won' + ].map(state => brc177NoSendExpiryStateRank(state as any)) + ).toEqual([-1, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9]) }) }) diff --git a/packages/wallet/wallet-toolbox/test/Wallet/action/noSendExpiry.test.ts b/packages/wallet/wallet-toolbox/test/Wallet/action/noSendExpiry.test.ts index 4fe12d721..4c77eaafe 100644 --- a/packages/wallet/wallet-toolbox/test/Wallet/action/noSendExpiry.test.ts +++ b/packages/wallet/wallet-toolbox/test/Wallet/action/noSendExpiry.test.ts @@ -197,6 +197,50 @@ describe('BRC-177 noSend expiry reference implementation', () => { } as const } + async function expectArmFailure( + mutate: ( + ctx: WalletHarness, + args: Parameters[0] + ) => + | Parameters[0] + | Promise[0]>, + message: string + ): Promise { + const ctx = await createHarness() + try { + const arm = ctx.storage.armNoSendExpiry.bind(ctx.storage) + jest.spyOn(ctx.storage, 'armNoSendExpiry').mockImplementationOnce(async args => { + return await arm(await mutate(ctx, args)) + }) + await expect(ctx.wallet.createAction(protectedArgs(3600))).rejects.toThrow(message) + } finally { + jest.restoreAllMocks() + await ctx.destroy() + } + } + + async function expectActivationFailure( + mutate: ( + ctx: WalletHarness, + args: Parameters[0] + ) => + | Parameters[0] + | Promise[0]>, + message: string + ): Promise { + const ctx = await createHarness() + try { + const activate = ctx.storage.activateNoSendExpiry.bind(ctx.storage) + jest.spyOn(ctx.storage, 'activateNoSendExpiry').mockImplementationOnce(async args => { + return await activate(await mutate(ctx, args)) + }) + await expect(ctx.wallet.createAction(protectedArgs(3600))).rejects.toThrow(message) + } finally { + jest.restoreAllMocks() + await ctx.destroy() + } + } + test('pre-funds, releases noSend, atomically reclaims, and finalizes after proof', async () => { const ctx = await createHarness() try { @@ -586,6 +630,135 @@ describe('BRC-177 noSend expiry reference implementation', () => { } }) + test('signAction cannot weaken the protected noSend release policy', async () => { + const ctx = await createHarness() + try { + const created = await ctx.wallet.createAction(protectedArgs(3600, false)) + const reference = created.signableTransaction!.reference + + await expect( + ctx.wallet.signAction({ + reference, + spends: {}, + options: { noSend: true, sendWith: ['03'.repeat(32)] } + }) + ).rejects.toThrow('options.sendWith') + await expect( + ctx.wallet.signAction({ reference, spends: {}, options: { noSend: true, returnTXIDOnly: true } }) + ).rejects.toThrow('options.returnTXIDOnly') + + const target = verifyOne(await ctx.active.findTransactions({ partial: { reference } })) + expect(target.noSendExpiryState).toBe('unsigned') + await expect(ctx.wallet.abortAction({ reference })).resolves.toEqual({ aborted: true }) + } finally { + await ctx.destroy() + } + }) + + test('rejects corrupted activation state before constructing a protected transaction', async () => { + await expectActivationFailure( + (_ctx, args) => ({ ...args, target: { ...args.target, labels: [] } }), + 'BRC-177 noSend expiry label' + ) + await expectActivationFailure(async (ctx, args) => { + const funding = verifyOne(await ctx.active.findTransactions({ partial: { txid: args.fundingTxid } })) + await ctx.active.updateTransaction(funding.transactionId, { status: 'failed' }) + return args + }, 'funding transaction was not accepted') + await expectActivationFailure(async (ctx, args) => { + const anchor = verifyOne( + await ctx.active.findOutputs({ partial: { txid: args.fundingTxid, vout: args.anchorVout } }) + ) + await ctx.active.updateOutput(anchor.outputId, { spendable: false }) + return args + }, 'anchor is not available') + await expectActivationFailure(async (ctx, args) => { + const anchor = verifyOne( + await ctx.active.findOutputs({ partial: { txid: args.fundingTxid, vout: args.anchorVout } }) + ) + await ctx.active.updateOutput(anchor.outputId, { satoshis: anchor.satoshis + 1 }) + return args + }, 'no longer exactly funds') + await expectActivationFailure( + (_ctx, args) => ({ + ...args, + target: { + ...args.target, + labels: [`p nosend expiry timestamp ${Math.floor(Date.now() / 1000) - 1}`] + } + }), + 'timestamp later than the current time' + ) + await expectActivationFailure( + async (_ctx, args) => ({ + ...args, + target: { ...args.target, labels: [`p nosend expiry blockheight ${await services.getHeight()}`] } + }), + 'blockheight later than the current best-chain height' + ) + await expectActivationFailure( + (_ctx, args) => ({ + ...args, + target: { ...args.target, labels: [`p nosend expiry seconds ${Number.MAX_SAFE_INTEGER}`] } + }), + 'safely schedulable' + ) + }) + + test('rejects malformed or stale pre-signed reclaim material before arming', async () => { + await expectArmFailure((_ctx, args) => ({ ...args, reclaimDerivationPrefix: '***' }), '16-byte base64') + await expectArmFailure( + (_ctx, args) => ({ ...args, reclaimDerivationPrefix: Utils.toBase64([1]) }), + 'canonical 16-byte base64' + ) + await expectArmFailure((_ctx, args) => ({ ...args, reclaimRawTx: Array(1001).fill(0) }), 'at most 1000 bytes') + await expectArmFailure( + (_ctx, args) => ({ ...args, reclaimRawTx: [1, 0, 0, 0, 1] }), + 'valid serialized reclaim transaction' + ) + await expectArmFailure((_ctx, args) => { + const rawTx = [...asArray(args.reclaimRawTx), 0] + return { ...args, reclaimRawTx: rawTx, reclaimTxid: Transaction.fromBinary(rawTx).id('hex') } + }, 'valid signature for the revocation anchor') + await expectArmFailure((_ctx, args) => ({ ...args, reclaimTxid: '00'.repeat(32) }), 'hash of reclaimRawTx') + await expectArmFailure( + (_ctx, args) => ({ ...args, reclaimSatoshis: args.reclaimSatoshis - 1 }), + 'exact BRC-177 reclaim amount' + ) + await expectArmFailure((_ctx, args) => { + const reclaim = Transaction.fromBinary(asArray(args.reclaimRawTx)) + reclaim.outputs[0].lockingScript = Script.fromHex('51') + return { + ...args, + reclaimRawTx: reclaim.toUint8Array(), + reclaimTxid: reclaim.id('hex') + } + }, 'canonical P2PKH') + await expectArmFailure(async (ctx, args) => { + const target = verifyOne(await ctx.active.findTransactions({ partial: { reference: args.reference } })) + await ctx.active.updateTransaction(target.transactionId, { noSendExpiryState: 'signed' }) + return args + }, 'not waiting to be armed') + await expectArmFailure(async (ctx, args) => { + const target = verifyOne(await ctx.active.findTransactions({ partial: { reference: args.reference } })) + await ctx.active.updateTransaction(target.transactionId, { noSendExpiryAnchorTxid: undefined }) + return args + }, 'metadata is incomplete') + await expectArmFailure(async (ctx, args) => { + const target = verifyOne(await ctx.active.findTransactions({ partial: { reference: args.reference } })) + await ctx.active.updateTransaction(target.transactionId, { noSendExpiryDeadline: 0 }) + return args + }, 'expired before it could be armed') + await expectArmFailure((ctx, args) => { + jest.spyOn(ctx.active, 'getRawTxOfKnownValidTransaction').mockResolvedValue(undefined) + return args + }, 'anchor source transaction is unavailable') + await expectArmFailure((ctx, args) => { + jest.spyOn(ctx.active, 'compareAndSetNoSendExpiryState').mockResolvedValueOnce(false) + return args + }, 'changed before it could be armed') + }) + test('service ambiguity defers reclaim and malformed option combinations fail before pre-funding', async () => { const ctx = await createHarness() try { @@ -675,6 +848,20 @@ describe('BRC-177 noSend expiry reference implementation', () => { ).rejects.toThrow('safely schedulable') expect(await services.storage.getUnminedTransactions()).toHaveLength(unminedBefore) + await expect( + ctx.wallet.createAction({ + ...protectedArgs(3600), + outputs: [ + { + satoshis: 1, + lockingScript: '51', + outputDescription: 'Too-small protected recipient output' + } + ] + }) + ).rejects.toThrow('leaves at least') + expect(await services.storage.getUnminedTransactions()).toHaveLength(unminedBefore) + const created = await ctx.wallet.createAction(protectedArgs(3600)) const target = verifyOne(await ctx.active.findTransactions({ partial: { txid: created.txid } })) await ctx.active.updateTransaction(target.transactionId, { noSendExpiryDeadline: 0 }) @@ -750,4 +937,35 @@ describe('BRC-177 noSend expiry reference implementation', () => { await ctx.destroy() } }) + + test('incomplete and undated lifecycle records fail closed without throwing from the monitor pass', async () => { + const ctx = await createHarness() + try { + const incomplete = await ctx.wallet.createAction(protectedArgs(3600)) + const missingDeadline = await ctx.wallet.createAction(protectedArgs(3600)) + const incompleteTarget = verifyOne(await ctx.active.findTransactions({ partial: { txid: incomplete.txid } })) + const missingDeadlineTarget = verifyOne( + await ctx.active.findTransactions({ partial: { txid: missingDeadline.txid } }) + ) + await ctx.active.updateTransaction(incompleteTarget.transactionId, { + noSendExpiryDeadline: 0, + noSendExpiryReclaimRawTx: null as any + }) + await ctx.active.updateTransaction(missingDeadlineTarget.transactionId, { + noSendExpiryDeadline: null as any + }) + + const run = await processNoSendExpiryLifecycle(ctx.active) + + expect(run).toMatchObject({ inspected: 2, reclaimActivated: 0, deferred: 1, errors: 1 }) + expect(await services.storage.getTransaction(incompleteTarget.noSendExpiryReclaimTxid!)).toBeUndefined() + expect( + verifyOne( + await ctx.active.findTransactions({ partial: { transactionId: missingDeadlineTarget.transactionId } }) + ).noSendExpiryState + ).toBe('signed') + } finally { + await ctx.destroy() + } + }) }) From a93f91159fe9545852772e07e73a5ee5e270c8ad Mon Sep 17 00:00:00 2001 From: Ty J Everett Date: Sun, 30 Aug 2026 22:00:10 -0700 Subject: [PATCH 04/10] test(wallet-toolbox): cover BRC-177 storage races --- .../src/storage/__test/StorageIdb.test.ts | 25 +- .../__test/WalletStorageManager.test.ts | 7 + .../test/Wallet/action/noSendExpiry.test.ts | 281 ++++++++++++++++++ 3 files changed, 311 insertions(+), 2 deletions(-) diff --git a/packages/wallet/wallet-toolbox/src/storage/__test/StorageIdb.test.ts b/packages/wallet/wallet-toolbox/src/storage/__test/StorageIdb.test.ts index 8743f2eee..6633940a4 100644 --- a/packages/wallet/wallet-toolbox/src/storage/__test/StorageIdb.test.ts +++ b/packages/wallet/wallet-toolbox/src/storage/__test/StorageIdb.test.ts @@ -200,7 +200,23 @@ describe('StorageIdb tests', () => { status: 'nosend', txid: '16'.repeat(32) }) - await storage.updateTransaction(transactionId, { noSendExpiryState: 'signed' }) + const reclaimTxid = '17'.repeat(32) + await storage.updateTransaction(transactionId, { + noSendExpiryState: 'signed', + noSendExpiryReclaimTxid: reclaimTxid + }) + + expect((storage as any).supportsNoSendExpiryPersistence()).toBe(true) + expect( + (await storage.findTransactions({ partial: { noSendExpiryState: 'signed' } })).map( + transaction => transaction.transactionId + ) + ).toContain(transactionId) + expect( + (await storage.findTransactions({ partial: { noSendExpiryReclaimTxid: reclaimTxid } })).map( + transaction => transaction.transactionId + ) + ).toContain(transactionId) const contenders = await Promise.all([ storage.compareAndSetNoSendExpiryState(transactionId, 'signed', 'reclaiming'), @@ -208,8 +224,13 @@ describe('StorageIdb tests', () => { ]) expect(contenders.filter(Boolean)).toHaveLength(1) await expect(storage.compareAndSetNoSendExpiryState(transactionId, 'signed', 'conflicted')).resolves.toBe(false) + await storage.transaction(async trx => { + await expect( + storage.compareAndSetNoSendExpiryState(transactionId, 'reclaiming', 'conflicted', trx) + ).resolves.toBe(true) + }) const [transaction] = await storage.findTransactions({ partial: { transactionId } }) - expect(transaction.noSendExpiryState).toBe('reclaiming') + expect(transaction.noSendExpiryState).toBe('conflicted') } finally { await resetStorage(storage) } diff --git a/packages/wallet/wallet-toolbox/src/storage/__test/WalletStorageManager.test.ts b/packages/wallet/wallet-toolbox/src/storage/__test/WalletStorageManager.test.ts index ad9fd29c6..b08563891 100644 --- a/packages/wallet/wallet-toolbox/src/storage/__test/WalletStorageManager.test.ts +++ b/packages/wallet/wallet-toolbox/src/storage/__test/WalletStorageManager.test.ts @@ -1,6 +1,8 @@ import * as bsv from '@bsv/sdk' import { wait } from '../..' import { _tu, TestWalletNoSetup } from '../../../test/utils/TestUtilsWalletStorage' +import { StorageProvider } from '../StorageProvider' +import { StorageReaderWriter } from '../StorageReaderWriter' import * as dotenv from 'dotenv' @@ -35,6 +37,11 @@ describe('WalletStorageManager tests', () => { await expect(provider.activateNoSendExpiry(inactive, {} as any)).rejects.toThrow('active storage provider') await expect(provider.armNoSendExpiry(inactive, {} as any)).rejects.toThrow('active storage provider') + expect((StorageProvider.prototype as any).supportsNoSendExpiryPersistence.call(provider)).toBe(false) + await expect( + StorageReaderWriter.prototype.compareAndSetNoSendExpiryState.call(manager, 1, 'signed', 'reclaiming') + ).rejects.toThrow('BRC-177 atomic lifecycle persistence') + const persistence = jest.spyOn(provider as any, 'supportsNoSendExpiryPersistence').mockReturnValue(false) try { expect(await provider.getCapabilities()).not.toHaveProperty('brc177NoSendExpiry') diff --git a/packages/wallet/wallet-toolbox/test/Wallet/action/noSendExpiry.test.ts b/packages/wallet/wallet-toolbox/test/Wallet/action/noSendExpiry.test.ts index 4c77eaafe..2c82aaa37 100644 --- a/packages/wallet/wallet-toolbox/test/Wallet/action/noSendExpiry.test.ts +++ b/packages/wallet/wallet-toolbox/test/Wallet/action/noSendExpiry.test.ts @@ -241,6 +241,28 @@ describe('BRC-177 noSend expiry reference implementation', () => { } } + async function expectProcessFailure( + mutate: ( + ctx: WalletHarness, + args: Parameters[0] + ) => + | Parameters[0] + | Promise[0]>, + message: string + ): Promise { + const ctx = await createHarness() + try { + const process = ctx.storage.processAction.bind(ctx.storage) + jest.spyOn(ctx.storage, 'processAction').mockImplementation(async args => { + return await process(args.isNoSend ? await mutate(ctx, args) : args) + }) + await expect(ctx.wallet.createAction(protectedArgs(3600))).rejects.toThrow(message) + } finally { + jest.restoreAllMocks() + await ctx.destroy() + } + } + test('pre-funds, releases noSend, atomically reclaims, and finalizes after proof', async () => { const ctx = await createHarness() try { @@ -594,6 +616,163 @@ describe('BRC-177 noSend expiry reference implementation', () => { } }) + test('abort remains fail-closed across unsigned, released, and recipient-broadcast races', async () => { + const unsigned = await createHarness() + try { + const created = await unsigned.wallet.createAction(protectedArgs(3600, false)) + const target = verifyOne( + await unsigned.active.findTransactions({ partial: { reference: created.signableTransaction!.reference } }) + ) + const compareAndSet = unsigned.active.compareAndSetNoSendExpiryState.bind(unsigned.active) + jest + .spyOn(unsigned.active, 'compareAndSetNoSendExpiryState') + .mockImplementation(async (transactionId, expected, next, trx) => + expected === 'unsigned' && next === 'cancelled' + ? false + : await compareAndSet(transactionId, expected, next, trx) + ) + await expect(unsigned.wallet.abortAction({ reference: target.reference })).rejects.toThrow( + 'changed while it was being aborted' + ) + } finally { + jest.restoreAllMocks() + await unsigned.destroy() + } + + const released = await createHarness() + try { + const created = await released.wallet.createAction(protectedArgs(3600)) + const target = verifyOne(await released.active.findTransactions({ partial: { txid: created.txid } })) + const compareAndSet = released.active.compareAndSetNoSendExpiryState.bind(released.active) + jest + .spyOn(released.active, 'compareAndSetNoSendExpiryState') + .mockImplementation(async (transactionId, expected, next, trx) => + expected === 'signed' && next === 'revocation-requested' + ? false + : await compareAndSet(transactionId, expected, next, trx) + ) + await expect(released.wallet.abortAction({ reference: target.reference })).rejects.toThrow( + 'changed while early revocation was requested' + ) + } finally { + jest.restoreAllMocks() + await released.destroy() + } + + const requested = await createHarness() + try { + const created = await requested.wallet.createAction(protectedArgs(3600)) + const target = verifyOne(await requested.active.findTransactions({ partial: { txid: created.txid } })) + await requested.active.updateTransaction(target.transactionId, { noSendExpiryState: 'revocation-requested' }) + await expect(requested.wallet.abortAction({ reference: target.reference })).resolves.toEqual({ aborted: true }) + } finally { + await requested.destroy() + } + + const broadcast = await createHarness() + try { + const created = await broadcast.wallet.createAction(protectedArgs(3600)) + const target = verifyOne(await broadcast.active.findTransactions({ partial: { txid: created.txid } })) + const posted = await services.postBeef(Beef.fromBinary(created.tx!), [created.txid!]) + expect(posted[0].status).toBe('success') + + await expect(broadcast.wallet.abortAction({ reference: target.reference })).resolves.toEqual({ aborted: false }) + expect( + verifyOne(await broadcast.active.findTransactions({ partial: { txid: created.txid } })).noSendExpiryState + ).toBe('broadcast') + } finally { + await broadcast.destroy() + } + }) + + test('monitor CAS losses and incomplete proof state defer without violating lifecycle ownership', async () => { + const unsigned = await createHarness() + try { + const created = await unsigned.wallet.createAction(protectedArgs(3600, false)) + const target = verifyOne( + await unsigned.active.findTransactions({ partial: { reference: created.signableTransaction!.reference } }) + ) + await unsigned.active.updateTransaction(target.transactionId, { noSendExpiryDeadline: 0 }) + const compareAndSet = unsigned.active.compareAndSetNoSendExpiryState.bind(unsigned.active) + jest + .spyOn(unsigned.active, 'compareAndSetNoSendExpiryState') + .mockImplementation(async (transactionId, expected, next, trx) => + expected === 'unsigned' && next === 'cancelled' + ? false + : await compareAndSet(transactionId, expected, next, trx) + ) + await expect(processNoSendExpiryLifecycle(unsigned.active)).resolves.toMatchObject({ cancelled: 0 }) + } finally { + jest.restoreAllMocks() + await unsigned.destroy() + } + + const activation = await createHarness() + try { + const created = await activation.wallet.createAction(protectedArgs(3600)) + const target = verifyOne(await activation.active.findTransactions({ partial: { txid: created.txid } })) + await activation.active.updateTransaction(target.transactionId, { noSendExpiryDeadline: 0 }) + const compareAndSet = activation.active.compareAndSetNoSendExpiryState.bind(activation.active) + jest + .spyOn(activation.active, 'compareAndSetNoSendExpiryState') + .mockImplementation(async (transactionId, expected, next, trx) => + expected === 'revocation-requested' && next === 'reclaiming' + ? false + : await compareAndSet(transactionId, expected, next, trx) + ) + await expect(processNoSendExpiryLifecycle(activation.active)).resolves.toMatchObject({ reclaimActivated: 0 }) + expect( + verifyOne(await activation.active.findTransactions({ partial: { txid: created.txid } })).noSendExpiryState + ).toBe('revocation-requested') + } finally { + jest.restoreAllMocks() + await activation.destroy() + } + + const conflicted = await createHarness() + try { + const created = await conflicted.wallet.createAction(protectedArgs(3600)) + const target = verifyOne(await conflicted.active.findTransactions({ partial: { txid: created.txid } })) + await conflicted.active.updateTransaction(target.transactionId, { noSendExpiryDeadline: 0 }) + const spent = jest.spyOn(services, 'isUtxo').mockResolvedValueOnce(false) + await processNoSendExpiryLifecycle(conflicted.active) + spent.mockRestore() + const offline = jest.spyOn(services, 'getStatusForTxids').mockResolvedValueOnce({ + status: 'error', + results: [], + name: 'offline' + }) + await expect(processNoSendExpiryLifecycle(conflicted.active)).resolves.toMatchObject({ deferred: 1 }) + offline.mockRestore() + expect( + verifyOne(await conflicted.active.findTransactions({ partial: { txid: created.txid } })).noSendExpiryState + ).toBe('conflicted') + } finally { + jest.restoreAllMocks() + await conflicted.destroy() + } + + const proofless = await createHarness() + try { + const created = await proofless.wallet.createAction(protectedArgs(3600)) + let target = verifyOne(await proofless.active.findTransactions({ partial: { txid: created.txid } })) + await proofless.active.updateTransaction(target.transactionId, { noSendExpiryDeadline: 0 }) + await processNoSendExpiryLifecycle(proofless.active) + target = verifyOne(await proofless.active.findTransactions({ partial: { txid: created.txid } })) + const reclaim = verifyOne( + await proofless.active.findTransactions({ partial: { txid: target.noSendExpiryReclaimTxid } }) + ) + await proofless.active.updateTransaction(reclaim.transactionId, { status: 'completed' }) + + await expect(processNoSendExpiryLifecycle(proofless.active)).resolves.toMatchObject({ deferred: 1 }) + expect( + verifyOne(await proofless.active.findTransactions({ partial: { txid: created.txid } })).noSendExpiryState + ).toBe('reclaiming') + } finally { + await proofless.destroy() + } + }) + test('timestamp expiry remains the exact absolute deadline after pre-funding', async () => { const ctx = await createHarness() try { @@ -655,6 +834,89 @@ describe('BRC-177 noSend expiry reference implementation', () => { } }) + test('cleans up a failed funding release and accepts binary input BEEF from storage', async () => { + const failed = await createHarness() + try { + const abort = jest.spyOn(failed.storage, 'abortAction') + jest.spyOn(failed.storage, 'processAction').mockRejectedValueOnce(new Error('simulated funding release failure')) + + await expect(failed.wallet.createAction(protectedArgs(3600))).rejects.toThrow('simulated funding release failure') + expect(abort).toHaveBeenCalledTimes(1) + } finally { + jest.restoreAllMocks() + await failed.destroy() + } + + const binary = await createHarness() + try { + const prepare = binary.storage.prepareNoSendExpiry.bind(binary.storage) + jest.spyOn(binary.storage, 'prepareNoSendExpiry').mockImplementationOnce(async args => { + const result = await prepare(args) + return { + ...result, + funding: { + ...result.funding, + inputBeef: Uint8Array.from(result.funding.inputBeef!) + } + } + }) + + await expect(binary.wallet.createAction(protectedArgs(3600))).resolves.toMatchObject({ + txid: expect.any(String) + }) + } finally { + jest.restoreAllMocks() + await binary.destroy() + } + }) + + test('rejects protected release races at every storage revalidation boundary', async () => { + await expectProcessFailure((_ctx, args) => ({ ...args, isNoSend: false }), 'must remain noSend') + await expectProcessFailure(async (ctx, args) => { + const target = verifyOne(await ctx.active.findTransactions({ partial: { reference: args.reference } })) + await ctx.active.updateTransaction(target.transactionId, { noSendExpiryState: 'signed' }) + return args + }, 'not armed for signature release') + await expectProcessFailure(async (ctx, args) => { + const target = verifyOne(await ctx.active.findTransactions({ partial: { reference: args.reference } })) + await ctx.active.updateTransaction(target.transactionId, { noSendExpiryDeadline: 0 }) + return args + }, 'has expired') + await expectProcessFailure(async (ctx, args) => { + const target = verifyOne(await ctx.active.findTransactions({ partial: { reference: args.reference } })) + await ctx.active.updateTransaction(target.transactionId, { status: 'failed' }) + return args + }, 'invalid transaction status') + await expectProcessFailure((ctx, args) => { + const compareAndSet = ctx.active.compareAndSetNoSendExpiryState.bind(ctx.active) + jest + .spyOn(ctx.active, 'compareAndSetNoSendExpiryState') + .mockImplementation(async (transactionId, expected, next, trx) => + expected === 'unsigned' && next === 'signed' ? false : await compareAndSet(transactionId, expected, next, trx) + ) + return args + }, 'changed before signature release') + }) + + test('preserves exact-anchor semantics when the active storage charges a commission', async () => { + const ctx = await createHarness() + try { + ctx.active.commissionSatoshis = 5 + ctx.active.commissionPubKeyHex = ctx.wallet.identityKey + + const created = await ctx.wallet.createAction(protectedArgs(3600)) + const target = verifyOne(await ctx.active.findTransactions({ partial: { txid: created.txid } })) + const targetTx = Transaction.fromAtomicBEEF(created.tx!) + const outputs = await ctx.active.findOutputs({ partial: { transactionId: target.transactionId } }) + + expect(targetTx.inputs).toHaveLength(1) + expect(outputs.filter(output => output.purpose === 'storage-commission')).toHaveLength(1) + expect(outputs.filter(output => output.change)).toHaveLength(0) + } finally { + await ctx.destroy() + } + }) + test('rejects corrupted activation state before constructing a protected transaction', async () => { await expectActivationFailure( (_ctx, args) => ({ ...args, target: { ...args.target, labels: [] } }), @@ -703,6 +965,25 @@ describe('BRC-177 noSend expiry reference implementation', () => { }), 'safely schedulable' ) + + const ctx = await createHarness() + try { + const activate = ctx.storage.activateNoSendExpiry.bind(ctx.storage) + jest.spyOn(ctx.storage, 'activateNoSendExpiry').mockImplementationOnce(async args => { + const result = await activate(args) + return { + ...result, + action: { + ...result.action, + inputs: [...result.action.inputs, ...result.action.inputs] + } + } + }) + await expect(ctx.wallet.createAction(protectedArgs(3600))).rejects.toThrow('exactly one managed anchor input') + } finally { + jest.restoreAllMocks() + await ctx.destroy() + } }) test('rejects malformed or stale pre-signed reclaim material before arming', async () => { From 6c17212dae59398ea8d9a6b87112f1905f3d7e27 Mon Sep 17 00:00:00 2001 From: Ty J Everett Date: Mon, 31 Aug 2026 16:53:54 -0700 Subject: [PATCH 05/10] fix(wallet-toolbox): address BRC-177 review findings --- governance/package-release-notes.json | 2 +- packages/wallet/wallet-toolbox/CHANGELOG.md | 7 +- .../wallet-toolbox/docs/no-send-expiry.md | 33 +++- .../src/monitor/tasks/TaskNoSendExpiry.ts | 3 +- .../tasks/__tests__/TaskNoSendExpiry.test.ts | 4 +- .../createNoSendExpiryAction.test.ts | 28 ++- .../src/storage/methods/noSendExpiry.ts | 53 +++++- .../storage/methods/noSendExpiryLifecycle.ts | 144 +++++++++++++++- .../src/storage/methods/processAction.ts | 24 ++- .../test/Wallet/action/noSendExpiry.test.ts | 159 ++++++++++++++++++ 10 files changed, 430 insertions(+), 27 deletions(-) diff --git a/governance/package-release-notes.json b/governance/package-release-notes.json index e7017ebe2..904622431 100644 --- a/governance/package-release-notes.json +++ b/governance/package-release-notes.json @@ -217,7 +217,7 @@ "name": "@bsv/wallet-toolbox", "publishedVersion": "2.10.4", "releaseType": "minor", - "summary": "Adds the built-in BRC-177 noSend-expiry reference implementation with exact prefunding, durable pre-signed reclaim, atomic active-storage monitoring, cross-device lifecycle synchronization, and proof-finalized race handling, plus the optional semantic handleRequest hook for BRC-98/99/111 permission modules. Retains the existing transformation hooks, BRC-95/BRC-100 compatibility, and stable bounded pagination, removes the obsolete JSight application bundle, and preserves the package's earlier Open BSV grant in the distribution notice archive.", + "summary": "Adds the built-in BRC-177 noSend-expiry reference implementation with exact prefunding, durable pre-signed reclaim, atomic active-storage monitoring, backoff-controlled recovery of terminally rejected reclaims, cross-device lifecycle synchronization, and proof-finalized race handling, plus the optional semantic handleRequest hook for BRC-98/99/111 permission modules. Retains the existing transformation hooks, BRC-95/BRC-100 compatibility, and stable bounded pagination, removes the obsolete JSight application bundle, and preserves the package's earlier Open BSV grant in the distribution notice archive.", "migration": "Existing actions, ordinary noSend calls, and permission modules require no migration. To use BRC-177, migrate every active Knex store before serving requests and run the default Wallet Toolbox monitor; IndexedDB upgrades automatically to schema version 5. Upgrade signer, active storage service, and remote monitor together to 2.11.0 or later. Older remote storage is rejected before prefunding. Semantic modules may add handleRequest; hosts installing @bsv/ecpm-permission-module register it under the ecpm scheme. Canonical AtomicBEEF and number-array behavior are unchanged; use @bsv/sdk 2.4.2 or later, use docs/storage.md instead of the removed JSight export, and retain THIRD_PARTY_NOTICES.md and LICENSES/." }, { diff --git a/packages/wallet/wallet-toolbox/CHANGELOG.md b/packages/wallet/wallet-toolbox/CHANGELOG.md index f1d700324..905dda616 100644 --- a/packages/wallet/wallet-toolbox/CHANGELOG.md +++ b/packages/wallet/wallet-toolbox/CHANGELOG.md @@ -18,9 +18,10 @@ attention to changes that materially alter behavior or extend functionality. accepted transaction, contain no wallet change, and retain a pre-signed reclaim across restarts, synchronized storage, devices, and keyless remote monitors. Atomic lifecycle transitions, active-storage ownership, - fail-closed status checks, quarantined race outputs, and locally validated - proof finality prevent duplicate reclaim activation and unsafe state - regression. Wallet Permissions Manager authorizes module use and spending + fail-closed status checks, backoff-controlled recovery of terminally rejected + reclaims, quarantined race outputs, and locally validated proof finality + prevent duplicate reclaim activation and unsafe state regression. Wallet + Permissions Manager authorizes module use and spending before prefunding, attributes the funding fee to the requesting originator, and rechecks the current monthly ledger before releasing the protected action. Existing actions and ordinary `noSend` calls are unchanged. The diff --git a/packages/wallet/wallet-toolbox/docs/no-send-expiry.md b/packages/wallet/wallet-toolbox/docs/no-send-expiry.md index 1a10abd07..11496a2d6 100644 --- a/packages/wallet/wallet-toolbox/docs/no-send-expiry.md +++ b/packages/wallet/wallet-toolbox/docs/no-send-expiry.md @@ -83,9 +83,14 @@ reclaim output remains unavailable for wallet funding until a locally validated Merkle proof establishes that the reclaim won. A processor rejection does not release the anchor: the lifecycle remains quarantined for proof reconciliation because another submission may already have reached the network. -A conclusive spent-anchor verdict is likewise quarantined; if the conflicting -spend later disappears, reclaim resumes only after fresh explicit `unknown` -target and unspent-anchor verdicts. +If the reclaim was terminally rejected, the target has never been observed, and +later checks still report the target as explicitly `unknown` and the anchor as +conclusively unspent, the monitor revives and resubmits that same pre-signed +reclaim after a persistent exponential backoff (30 seconds, doubling to one +hour). It never creates a different spend or releases the anchor during +recovery. A conclusive spent-anchor verdict is likewise quarantined; if the +conflicting spend later disappears, reclaim resumes only after fresh explicit +`unknown` target and unspent-anchor verdicts. Seeing the protected transaction as known or mined permanently stops a new reclaim and moves it into ordinary proof tracking. If a reclaim was already @@ -94,6 +99,28 @@ but retains both transactions for proof tracking. Only a locally validated proof finalizes either winner. A processor status by itself is never reported as final. +An observed target's `broadcast` state is intentionally sticky: BRC-177 makes +expiry a deadline for broadcast, not confirmation, so an automatic timeout +must not later double-spend a target that a recipient submitted on time. If an +operator establishes that `known` was a status-provider false positive, recovery +is therefore an explicit, security-sensitive repair rather than a timer: + +1. stop every monitor and storage writer and snapshot each synchronized store; +2. verify independently that neither target nor reclaim has a validated proof, + that trusted services report the target `unknown`, and that the anchor is an + unspent output on the canonical chain; +3. remove or repair the provider that produced the false observation; +4. change only the protected row's lifecycle from `broadcast` to `conflicted` + in the active store and every synchronized copy, leaving transaction status, + request records, `spentBy`, and output spendability untouched; and +5. restart exactly one authoritative monitor and retain the snapshot until the + resulting race is proven. + +A stale synchronized copy still carrying the higher-ranked `broadcast` state +can restore it during merge, so all copies must be repaired together. If any +proof or anchor-spend evidence is ambiguous, do not reset the lifecycle; repair +status/proof services and let ordinary reconciliation remain fail-closed. + `abortAction` cancels an unreleased action locally. For a released action it durably requests immediate revocation through the same guarded reclaim path; it does not clear the anchor reservation. An already observed target is diff --git a/packages/wallet/wallet-toolbox/src/monitor/tasks/TaskNoSendExpiry.ts b/packages/wallet/wallet-toolbox/src/monitor/tasks/TaskNoSendExpiry.ts index a361a3e41..e30b79f68 100644 --- a/packages/wallet/wallet-toolbox/src/monitor/tasks/TaskNoSendExpiry.ts +++ b/packages/wallet/wallet-toolbox/src/monitor/tasks/TaskNoSendExpiry.ts @@ -41,7 +41,8 @@ export class TaskNoSendExpiry extends WalletMonitorTask { if (result.inspected === 0) return '' return ( `BRC-177 inspected=${result.inspected} cancelled=${result.cancelled} observed=${result.observed} ` + - `activated=${result.reclaimActivated} reclaimed=${result.reclaimed} targetWon=${result.targetWon} ` + + `activated=${result.reclaimActivated} retried=${result.reclaimRetried} ` + + `reclaimed=${result.reclaimed} targetWon=${result.targetWon} ` + `deferred=${result.deferred} errors=${result.errors}\n` ) } diff --git a/packages/wallet/wallet-toolbox/src/monitor/tasks/__tests__/TaskNoSendExpiry.test.ts b/packages/wallet/wallet-toolbox/src/monitor/tasks/__tests__/TaskNoSendExpiry.test.ts index 3768beac5..d0f6ae9ec 100644 --- a/packages/wallet/wallet-toolbox/src/monitor/tasks/__tests__/TaskNoSendExpiry.test.ts +++ b/packages/wallet/wallet-toolbox/src/monitor/tasks/__tests__/TaskNoSendExpiry.test.ts @@ -58,6 +58,7 @@ describe('TaskNoSendExpiry', () => { cancelled: 0, observed: 0, reclaimActivated: 0, + reclaimRetried: 0, reclaimed: 0, targetWon: 0, deferred: 0, @@ -74,13 +75,14 @@ describe('TaskNoSendExpiry', () => { cancelled: 1, observed: 2, reclaimActivated: 3, + reclaimRetried: 4, reclaimed: 4, targetWon: 5, deferred: 6, errors: 1 }) await expect(second.task.runTask()).resolves.toBe( - 'BRC-177 inspected=7 cancelled=1 observed=2 activated=3 reclaimed=4 targetWon=5 deferred=6 errors=1\n' + 'BRC-177 inspected=7 cancelled=1 observed=2 activated=3 retried=4 reclaimed=4 targetWon=5 deferred=6 errors=1\n' ) }) }) diff --git a/packages/wallet/wallet-toolbox/src/signer/methods/__tests__/createNoSendExpiryAction.test.ts b/packages/wallet/wallet-toolbox/src/signer/methods/__tests__/createNoSendExpiryAction.test.ts index 5ddfa2e88..635d2a753 100644 --- a/packages/wallet/wallet-toolbox/src/signer/methods/__tests__/createNoSendExpiryAction.test.ts +++ b/packages/wallet/wallet-toolbox/src/signer/methods/__tests__/createNoSendExpiryAction.test.ts @@ -1,6 +1,10 @@ import { Validation } from '@bsv/sdk' import { targetForStorage } from '../createNoSendExpiryAction' -import { makeNoSendExpiryFundingArgs, validateNoSendExpiryRequest } from '../../../storage/methods/noSendExpiry' +import { + makeNoSendExpiryFundingArgs, + selectNoSendExpiryFundingAnchor, + validateNoSendExpiryRequest +} from '../../../storage/methods/noSendExpiry' describe('createNoSendExpiryAction storage boundary', () => { test('keeps unlocking scripts and logger objects on the signer side', () => { @@ -54,6 +58,28 @@ describe('createNoSendExpiryAction storage boundary', () => { expect(makeNoSendExpiryFundingArgs(5001).labels).toEqual(['admin brc177 funding']) }) + test('selects the fixed exact-value anchor independently of result ordering and equal-value change', () => { + const output = (vout: number, satoshis: number, purpose = 'change') => ({ + vout, + satoshis, + providedBy: 'storage' as const, + purpose, + lockingScript: '', + outputDescription: '', + tags: [] + }) + const generatedCollision = output(7, 5001) + const fixedAnchor = output(1, 5001) + const serviceCharge = output(0, 5001, 'storage-commission') + + expect( + selectNoSendExpiryFundingAnchor([generatedCollision, serviceCharge, fixedAnchor, output(8, 99)], 5001) + ).toBe(fixedAnchor) + expect(() => selectNoSendExpiryFundingAnchor([serviceCharge, output(2, 99)], 5001)).toThrow( + 'exact revocation anchor' + ) + }) + test.each([ [{ outputs: [] }, 'outputs'], [{ options: { noSend: false } }, 'options.noSend'], diff --git a/packages/wallet/wallet-toolbox/src/storage/methods/noSendExpiry.ts b/packages/wallet/wallet-toolbox/src/storage/methods/noSendExpiry.ts index 238985fa3..f551c8628 100644 --- a/packages/wallet/wallet-toolbox/src/storage/methods/noSendExpiry.ts +++ b/packages/wallet/wallet-toolbox/src/storage/methods/noSendExpiry.ts @@ -4,6 +4,7 @@ import { StorageActivateNoSendExpiryArgs, StorageActivateNoSendExpiryResult, StorageArmNoSendExpiryArgs, + StorageCreateTransactionSdkOutput, StoragePrepareNoSendExpiryResult, TrxToken } from '../../sdk/WalletStorage.interfaces' @@ -67,6 +68,27 @@ export function makeNoSendExpiryFundingArgs( return args } +export function selectNoSendExpiryFundingAnchor( + outputs: StorageCreateTransactionSdkOutput[], + anchorSatoshis: number +): StorageCreateTransactionSdkOutput { + const anchors = outputs.filter( + output => output.providedBy === 'storage' && output.purpose === 'change' && output.satoshis === anchorSatoshis + ) + // Required outputs precede generated change when randomization is disabled. + // Select by the exact amount and fixed-output vout rather than response-array + // order. An ordinary generated change output can coincidentally have the same + // amount, so requiring amount uniqueness would reject a valid funding plan. + const anchor = anchors.reduce<(typeof anchors)[number] | undefined>( + (selected, output) => (selected == null || output.vout < selected.vout ? output : selected), + undefined + ) + if (anchor == null) { + throw new WERR_INVALID_OPERATION('BRC-177 funding plan did not contain its exact revocation anchor') + } + return anchor +} + function feeForSize(storage: StorageProvider, size: number, minimumSatsPerKb = 0): number { const feeModel = validateStorageFeeModel(storage.feeModel) const satsPerKb = Math.max(feeModel.value || 0, minimumSatsPerKb) @@ -135,10 +157,7 @@ export async function prepareNoSendExpiry( const fundingArgs = makeNoSendExpiryFundingArgs(anchorSatoshis, target.labels) fundingArgs.includeAllSourceTransactions = target.includeAllSourceTransactions const funding = await createAction(storage, auth, fundingArgs) - const anchor = funding.outputs.find(output => output.providedBy === 'storage' && output.purpose === 'change') - if (anchor?.satoshis !== anchorSatoshis) { - throw new WERR_INVALID_OPERATION('BRC-177 funding plan did not contain its exact revocation anchor') - } + const anchor = selectNoSendExpiryFundingAnchor(funding.outputs, anchorSatoshis) return { funding, anchorSatoshis, @@ -353,7 +372,9 @@ async function validateArmSnapshot( reference: string, args: StorageArmNoSendExpiryArgs, reclaim: Transaction, - trx?: TrxToken + trx?: TrxToken, + observedBlockheight?: number, + enforceBlockheight = false ): Promise { const target = verifyOne( await storage.findTransactions({ @@ -372,8 +393,11 @@ async function validateArmSnapshot( ) { throw new WERR_INVALID_OPERATION('BRC-177 action metadata is incomplete') } - const now = Math.floor(Date.now() / 1000) - if (target.noSendExpiryMode !== 'blockheight' && target.noSendExpiryDeadline <= now) { + const expired = + target.noSendExpiryMode === 'blockheight' + ? enforceBlockheight && (observedBlockheight == null || observedBlockheight >= target.noSendExpiryDeadline) + : target.noSendExpiryDeadline <= Math.floor(Date.now() / 1000) + if (expired) { throw new WERR_INVALID_OPERATION('BRC-177 action expired before it could be armed') } const anchor = verifyOne( @@ -407,9 +431,22 @@ export async function armNoSendExpiry( // the atomic section before publishing the armed state. const snapshot = await validateArmSnapshot(storage, userId, args.reference, args, reclaim) await verifyReclaimSignature(storage, snapshot, rawTx, args.reclaimTxid) + // Keep chain I/O outside the IndexedDB write transaction, but carry the + // freshest observed height into the atomic snapshot/CAS validation. + const observedBlockheight = + snapshot.noSendExpiryMode === 'blockheight' ? await storage.getServices().getHeight() : undefined await storage.transaction(async trx => { - const target = await validateArmSnapshot(storage, userId, args.reference, args, reclaim, trx) + const target = await validateArmSnapshot( + storage, + userId, + args.reference, + args, + reclaim, + trx, + observedBlockheight, + true + ) if (!(await storage.compareAndSetNoSendExpiryState(target.transactionId, 'preparing', 'unsigned', trx))) { throw new WERR_INVALID_OPERATION('BRC-177 action changed before it could be armed') } diff --git a/packages/wallet/wallet-toolbox/src/storage/methods/noSendExpiryLifecycle.ts b/packages/wallet/wallet-toolbox/src/storage/methods/noSendExpiryLifecycle.ts index 408f5f5e4..bfd222ab1 100644 --- a/packages/wallet/wallet-toolbox/src/storage/methods/noSendExpiryLifecycle.ts +++ b/packages/wallet/wallet-toolbox/src/storage/methods/noSendExpiryLifecycle.ts @@ -3,7 +3,7 @@ import { ProvenTxReqStatus, TrxToken } from '../../sdk' import { WERR_INTERNAL, WERR_INVALID_OPERATION } from '../../sdk/WERR_errors' import { StatusForTxidResult } from '../../sdk/WalletServices.interfaces' import { parseTxScriptOffsets } from '../../utility/parseTxScriptOffsets' -import { randomBytesBase64, verifyId, verifyOne, verifyOneOrNone } from '../../utility/utilityHelpers' +import { randomBytesBase64, verifyId, verifyOne, verifyOneOrNone, verifyTruthy } from '../../utility/utilityHelpers' import { asArray } from '../../utility/utilityHelpers.noBuffer' import type { Brc177NoSendExpiryState } from '../../utility/brc177NoSendExpiry' import type { StorageProvider } from '../StorageProvider' @@ -25,12 +25,15 @@ const ACTIVE_STATES: Brc177NoSendExpiryState[] = [ ] const QUERY_PAGE_SIZE = 100 const STATUS_BATCH_SIZE = 100 +const RECLAIM_RETRY_BASE_MSECS = 30_000 +const RECLAIM_RETRY_MAX_MSECS = 60 * 60_000 export interface NoSendExpiryLifecycleResult { inspected: number cancelled: number observed: number reclaimActivated: number + reclaimRetried: number reclaimed: number targetWon: number deferred: number @@ -43,6 +46,7 @@ function emptyResult(): NoSendExpiryLifecycleResult { cancelled: 0, observed: 0, reclaimActivated: 0, + reclaimRetried: 0, reclaimed: 0, targetWon: 0, deferred: 0, @@ -556,6 +560,135 @@ function recordRaceResult(result: NoSendExpiryLifecycleResult, race: Awaited { + if ( + targetStatus !== 'unknown' || + target.noSendExpiryObservedAt != null || + target.noSendExpiryReclaimTxid == null + ) { + return 'not-applicable' + } + + const reclaim = verifyOneOrNone( + await storage.findTransactions({ + partial: { userId: target.userId, txid: target.noSendExpiryReclaimTxid } + }) + ) + const req = await EntityProvenTxReq.fromStorageTxid(storage, target.noSendExpiryReclaimTxid) + if ( + reclaim?.status !== 'failed' || + reclaim.provenTxId != null || + req == null || + (req.status !== 'invalid' && req.status !== 'doubleSpend') + ) { + return 'not-applicable' + } + + const retryAfter = verifyTruthy(req.updated_at).getTime() + reclaimRetryDelay(req.rebroadcastAttempts) + if (Date.now() < retryAfter) return 'deferred' + + const anchor = verifyOne( + await storage.findOutputs({ + partial: { + userId: target.userId, + txid: target.noSendExpiryAnchorTxid, + vout: target.noSendExpiryAnchorVout + } + }) + ) + let anchorIsUtxo: boolean + try { + anchorIsUtxo = await storage.getServices().isUtxo(anchor) + } catch { + return 'deferred' + } + if (!anchorIsUtxo) return 'deferred' + + const sendable = await storage.transaction(async trx => { + if (!(await storage.compareAndSetNoSendExpiryState(target.transactionId, 'reclaiming', 'reclaiming', trx))) { + return undefined + } + const currentTarget = verifyOne( + await storage.findTransactions({ + partial: { transactionId: target.transactionId, userId: target.userId }, + trx + }) + ) + if ( + currentTarget.noSendExpiryState !== 'reclaiming' || + currentTarget.status === 'completed' || + currentTarget.provenTxId != null || + currentTarget.noSendExpiryObservedAt != null || + currentTarget.noSendExpiryReclaimTxid == null + ) { + return undefined + } + const currentReclaim = verifyOneOrNone( + await storage.findTransactions({ + partial: { userId: currentTarget.userId, txid: currentTarget.noSendExpiryReclaimTxid }, + trx + }) + ) + const currentReq = await EntityProvenTxReq.fromStorageTxid(storage, currentTarget.noSendExpiryReclaimTxid, trx) + const currentAnchor = verifyOne( + await storage.findOutputs({ + partial: { + userId: currentTarget.userId, + txid: currentTarget.noSendExpiryAnchorTxid, + vout: currentTarget.noSendExpiryAnchorVout + }, + trx + }) + ) + if ( + currentReclaim?.status !== 'failed' || + currentReclaim.provenTxId != null || + currentReq == null || + (currentReq.status !== 'invalid' && currentReq.status !== 'doubleSpend') || + currentAnchor.spendable || + currentAnchor.spentBy !== currentReclaim.transactionId + ) { + return undefined + } + + // Generic transaction APIs deliberately prohibit un-failing a row. This + // narrowly scoped recovery is safe because it revives only the same signed + // reclaim while retaining its input reservation and quarantined output. + await storage.updateTransaction(currentReclaim.transactionId, { status: 'unprocessed' }, trx) + currentReq.status = 'unsent' + currentReq.attempts = 0 + currentReq.rebroadcastAttempts = currentReq.rebroadcastAttempts + 1 + currentReq.notified = false + currentReq.addHistoryNote({ + what: 'brc177-reclaim-retry', + targetTxid: currentTarget.txid, + retry: currentReq.rebroadcastAttempts + }) + await currentReq.updateStorageDynamicProperties(storage, trx) + return currentReq + }) + if (sendable == null) return 'deferred' + + await storage.attemptToPostReqsToNetwork([sendable]).catch(() => undefined) + return 'retried' +} + async function processReclaimRace( storage: StorageProvider, transaction: TableTransaction, @@ -565,7 +698,14 @@ async function processReclaimRace( if (isKnownOrMined(targetStatus) && (await noteTargetObservedDuringRace(storage, transaction))) { result.observed++ } - recordRaceResult(result, await reconcileRace(storage, transaction)) + const race = await reconcileRace(storage, transaction) + if (race !== 'deferred') { + recordRaceResult(result, race) + return + } + const recovery = await retryRejectedReclaim(storage, transaction, targetStatus) + if (recovery === 'retried') result.reclaimRetried++ + else result.deferred++ } async function processObservationOrRace( diff --git a/packages/wallet/wallet-toolbox/src/storage/methods/processAction.ts b/packages/wallet/wallet-toolbox/src/storage/methods/processAction.ts index 1dceb1212..eb817a646 100644 --- a/packages/wallet/wallet-toolbox/src/storage/methods/processAction.ts +++ b/packages/wallet/wallet-toolbox/src/storage/methods/processAction.ts @@ -551,9 +551,17 @@ async function commitNewTxToStorage( ): Promise { let log = vargs.log - const blockheightExpired = vargs.transaction.noSendExpiryState != null && - vargs.transaction.noSendExpiryMode === 'blockheight' && - (await storage.getServices().getHeight()) >= verifyInteger(vargs.transaction.noSendExpiryDeadline) + // The chain tip and the storage transaction cannot share one atomic + // transaction, particularly for IndexedDB where awaiting network I/O may + // auto-commit the write transaction. Capture the wallet's canonical height + // immediately before the write transaction and bind that exact observation + // to the row revalidated under the lifecycle CAS below. + const observedBlockheight = + vargs.transaction.noSendExpiryState != null && vargs.transaction.noSendExpiryMode === 'blockheight' + ? await storage.getServices().getHeight() + : undefined + const blockheightExpired = + observedBlockheight != null && observedBlockheight >= verifyInteger(vargs.transaction.noSendExpiryDeadline) if (blockheightExpired) { throw new WERR_INVALID_OPERATION('BRC-177 protected action expired before signature release') } @@ -573,10 +581,12 @@ async function commitNewTxToStorage( if (current.noSendExpiryState !== 'unsigned') { throw new WERR_INVALID_OPERATION('BRC-177 protected action changed before signature release') } - if (current.noSendExpiryMode !== 'blockheight' && - Math.floor(Date.now() / 1000) >= verifyInteger(current.noSendExpiryDeadline)) { - throw new WERR_INVALID_OPERATION('BRC-177 protected action expired before signature release') - } + const deadline = verifyInteger(current.noSendExpiryDeadline) + const expired = + current.noSendExpiryMode === 'blockheight' + ? observedBlockheight == null || observedBlockheight >= deadline + : Math.floor(Date.now() / 1000) >= deadline + if (expired) throw new WERR_INVALID_OPERATION('BRC-177 protected action expired before signature release') if (!await storage.compareAndSetNoSendExpiryState(current.transactionId, 'unsigned', 'signed', trx)) { throw new WERR_INVALID_OPERATION('BRC-177 protected action changed before signature release') } diff --git a/packages/wallet/wallet-toolbox/test/Wallet/action/noSendExpiry.test.ts b/packages/wallet/wallet-toolbox/test/Wallet/action/noSendExpiry.test.ts index 2c82aaa37..d073edf91 100644 --- a/packages/wallet/wallet-toolbox/test/Wallet/action/noSendExpiry.test.ts +++ b/packages/wallet/wallet-toolbox/test/Wallet/action/noSendExpiry.test.ts @@ -516,6 +516,112 @@ describe('BRC-177 noSend expiry reference implementation', () => { } }) + test('a terminally rejected reclaim retries once after backoff when chain evidence is safe', async () => { + const ctx = await createHarness() + try { + const created = await ctx.wallet.createAction(protectedArgs(3600)) + let target = verifyOne(await ctx.active.findTransactions({ partial: { txid: created.txid } })) + await ctx.active.updateTransaction(target.transactionId, { noSendExpiryDeadline: 0 }) + + const initialPost = jest + .spyOn(ctx.active, 'attemptToPostReqsToNetwork') + .mockRejectedValueOnce(new Error('processor disconnected before submission')) + await processNoSendExpiryLifecycle(ctx.active) + initialPost.mockRestore() + + target = verifyOne(await ctx.active.findTransactions({ partial: { txid: created.txid } })) + const reclaim = verifyOne( + await ctx.active.findTransactions({ partial: { txid: target.noSendExpiryReclaimTxid } }) + ) + const req = verifyOne(await ctx.active.findProvenTxReqs({ partial: { txid: target.noSendExpiryReclaimTxid } })) + await ctx.active.updateTransactionStatus('failed', reclaim.transactionId) + await ctx.active.updateProvenTxReq(req.provenTxReqId, { status: 'invalid' }) + + const backedOff = await processNoSendExpiryLifecycle(ctx.active) + expect(backedOff).toMatchObject({ reclaimRetried: 0, deferred: 1 }) + expect( + verifyOne(await ctx.active.findTransactions({ partial: { transactionId: reclaim.transactionId } })).status + ).toBe('failed') + + await ctx.active.updateProvenTxReq(req.provenTxReqId, { + updated_at: new Date(Date.now() - 31_000) + }) + const spentAnchor = jest.spyOn(services, 'isUtxo').mockResolvedValueOnce(false) + const stillConflicted = await processNoSendExpiryLifecycle(ctx.active) + spentAnchor.mockRestore() + expect(stillConflicted).toMatchObject({ reclaimRetried: 0, deferred: 1 }) + + const retries = await Promise.all([ + processNoSendExpiryLifecycle(ctx.active), + processNoSendExpiryLifecycle(ctx.active) + ]) + expect(retries.reduce((sum, run) => sum + run.reclaimRetried, 0)).toBe(1) + expect(await services.storage.getTransaction(target.noSendExpiryReclaimTxid!)).toBeDefined() + + const retriedReclaim = verifyOne( + await ctx.active.findTransactions({ partial: { transactionId: reclaim.transactionId } }) + ) + const retriedReq = verifyOne(await ctx.active.findProvenTxReqs({ partial: { provenTxReqId: req.provenTxReqId } })) + const anchor = verifyOne( + await ctx.active.findOutputs({ + partial: { + userId: target.userId, + txid: target.noSendExpiryAnchorTxid, + vout: target.noSendExpiryAnchorVout + } + }) + ) + const reclaimOutput = verifyOne( + await ctx.active.findOutputs({ partial: { transactionId: reclaim.transactionId, vout: 0 } }) + ) + expect(retriedReclaim.status).toBe('unproven') + expect(retriedReq.status).toBe('unmined') + expect(retriedReq.rebroadcastAttempts).toBe(1) + expect(anchor).toMatchObject({ spendable: false, spentBy: reclaim.transactionId }) + expect(reclaimOutput.spendable).toBe(false) + } finally { + jest.restoreAllMocks() + await ctx.destroy() + } + }) + + test('a rejected reclaim never resumes after any target-broadcast observation', async () => { + const ctx = await createHarness() + try { + const created = await ctx.wallet.createAction(protectedArgs(3600)) + let target = verifyOne(await ctx.active.findTransactions({ partial: { txid: created.txid } })) + await ctx.active.updateTransaction(target.transactionId, { noSendExpiryDeadline: 0 }) + const initialPost = jest + .spyOn(ctx.active, 'attemptToPostReqsToNetwork') + .mockRejectedValueOnce(new Error('processor disconnected before submission')) + await processNoSendExpiryLifecycle(ctx.active) + initialPost.mockRestore() + + target = verifyOne(await ctx.active.findTransactions({ partial: { txid: created.txid } })) + const reclaim = verifyOne( + await ctx.active.findTransactions({ partial: { txid: target.noSendExpiryReclaimTxid } }) + ) + const req = verifyOne(await ctx.active.findProvenTxReqs({ partial: { txid: target.noSendExpiryReclaimTxid } })) + await ctx.active.updateTransactionStatus('failed', reclaim.transactionId) + await ctx.active.updateTransaction(target.transactionId, { noSendExpiryObservedAt: Date.now() }) + await ctx.active.updateProvenTxReq(req.provenTxReqId, { + status: 'invalid', + updated_at: new Date(Date.now() - 31_000) + }) + + const isUtxo = jest.spyOn(services, 'isUtxo') + const run = await processNoSendExpiryLifecycle(ctx.active) + expect(run).toMatchObject({ reclaimRetried: 0, deferred: 1 }) + expect(isUtxo).not.toHaveBeenCalled() + expect( + verifyOne(await ctx.active.findTransactions({ partial: { transactionId: reclaim.transactionId } })).status + ).toBe('failed') + } finally { + jest.restoreAllMocks() + await ctx.destroy() + } + }) + test('unsigned expiry survives restart semantics and releases the anchor without broadcasting', async () => { const ctx = await createHarness() try { @@ -790,6 +896,29 @@ describe('BRC-177 noSend expiry reference implementation', () => { } }) + test('arming rejects a blockheight deadline reached after reclaim signature validation', async () => { + const ctx = await createHarness() + try { + const height = await services.getHeight() + const arm = ctx.storage.armNoSendExpiry.bind(ctx.storage) + jest.spyOn(ctx.storage, 'armNoSendExpiry').mockImplementationOnce(async args => { + const target = verifyOne(await ctx.active.findTransactions({ partial: { reference: args.reference } })) + await ctx.active.updateTransaction(target.transactionId, { noSendExpiryDeadline: height }) + return await arm(args) + }) + + await expect( + ctx.wallet.createAction({ + ...protectedArgs(3600), + labels: [`p nosend expiry blockheight ${height + 10}`] + }) + ).rejects.toThrow('expired before it could be armed') + } finally { + jest.restoreAllMocks() + await ctx.destroy() + } + }) + test('signAction cannot release an armed transaction at or after its deadline', async () => { const ctx = await createHarness() try { @@ -809,6 +938,36 @@ describe('BRC-177 noSend expiry reference implementation', () => { } }) + test('signAction binds its observed blockheight to the row revalidated by the release CAS', async () => { + const ctx = await createHarness() + try { + const height = await services.getHeight() + const created = await ctx.wallet.createAction({ + ...protectedArgs(3600, false), + labels: [`p nosend expiry blockheight ${height + 10}`] + }) + const reference = created.signableTransaction!.reference + const target = verifyOne(await ctx.active.findTransactions({ partial: { reference } })) + const getHeight = services.getHeight.bind(services) + jest.spyOn(services, 'getHeight').mockImplementationOnce(async () => { + const observed = await getHeight() + await ctx.active.updateTransaction(target.transactionId, { noSendExpiryDeadline: observed }) + return observed + }) + + await expect(ctx.wallet.signAction({ reference, spends: {}, options: { noSend: true } })).rejects.toThrow( + 'expired before signature release' + ) + expect( + verifyOne(await ctx.active.findTransactions({ partial: { transactionId: target.transactionId } })) + .noSendExpiryState + ).toBe('unsigned') + } finally { + jest.restoreAllMocks() + await ctx.destroy() + } + }) + test('signAction cannot weaken the protected noSend release policy', async () => { const ctx = await createHarness() try { From d17945340580ac8770fc53a9332e99120dcc92f2 Mon Sep 17 00:00:00 2001 From: Ty J Everett Date: Mon, 31 Aug 2026 17:05:03 -0700 Subject: [PATCH 06/10] chore(wallet-toolbox): ratchet platform bundle budgets --- packages/wallet/wallet-toolbox/CHANGELOG.md | 15 ++++++++------- .../wallet-toolbox/client/platform-budget.json | 4 ++-- .../wallet-toolbox/mobile/platform-budget.json | 8 ++++---- 3 files changed, 14 insertions(+), 13 deletions(-) diff --git a/packages/wallet/wallet-toolbox/CHANGELOG.md b/packages/wallet/wallet-toolbox/CHANGELOG.md index 905dda616..341ad14a3 100644 --- a/packages/wallet/wallet-toolbox/CHANGELOG.md +++ b/packages/wallet/wallet-toolbox/CHANGELOG.md @@ -25,13 +25,14 @@ attention to changes that materially alter behavior or extend functionality. before prefunding, attributes the funding fee to the requesting originator, and rechecks the current monthly ledger before releasing the protected action. Existing actions and ordinary `noSend` calls are unchanged. The - macOS reference fixtures measure 1,658,802 raw / 388,052 gzip / 304,787 - Brotli bytes with Vite, 1,294,883 raw / 354,950 gzip / 284,733 Brotli bytes - with esbuild, and 3,465,269 raw / 1,404,044 gzip / 1,088,905 Brotli bytes as - optimized Hermes bytecode. The reviewed ceilings advance to 1,660,000 / - 390,000 / 307,000 Vite bytes, 1,296,000 / 357,000 / 287,000 esbuild bytes, - and 3,470,000 / 1,406,000 / 1,090,000 Hermes bytes; Metro remains within its - existing ceilings at 1,707,158 raw / 429,877 gzip / 334,010 Brotli bytes. + current-main macOS reference fixtures measure 1,661,938 raw / 388,676 gzip / + 305,386 Brotli bytes with Vite, 1,297,416 raw / 355,497 gzip / 285,075 Brotli + bytes with esbuild, 1,710,198 raw / 430,529 gzip / 334,202 Brotli bytes with + Metro, and 3,473,582 raw / 1,406,126 gzip / 1,090,069 Brotli bytes as + optimized Hermes bytecode. The reviewed ceilings advance to 1,665,000 / + 390,000 / 307,000 Vite bytes, 1,300,000 / 357,000 / 287,000 esbuild bytes, + 1,712,000 / 455,000 / 360,000 Metro bytes, and 3,475,000 / 1,407,000 / + 1,095,000 Hermes bytes. - Report `listOutputs` `totalOutputs` as the size of the whole result set on every page, in both the IndexedDB and Knex storage providers. A short final diff --git a/packages/wallet/wallet-toolbox/client/platform-budget.json b/packages/wallet/wallet-toolbox/client/platform-budget.json index ecf345d3c..feddfa455 100644 --- a/packages/wallet/wallet-toolbox/client/platform-budget.json +++ b/packages/wallet/wallet-toolbox/client/platform-budget.json @@ -2,12 +2,12 @@ "profile": "browser", "maximumBytes": { "vite": { - "raw": 1660000, + "raw": 1665000, "gzip": 390000, "brotli": 307000 }, "esbuild": { - "raw": 1296000, + "raw": 1300000, "gzip": 357000, "brotli": 287000 } diff --git a/packages/wallet/wallet-toolbox/mobile/platform-budget.json b/packages/wallet/wallet-toolbox/mobile/platform-budget.json index a4068eda1..16d4fe76a 100644 --- a/packages/wallet/wallet-toolbox/mobile/platform-budget.json +++ b/packages/wallet/wallet-toolbox/mobile/platform-budget.json @@ -2,14 +2,14 @@ "profile": "mobile", "maximumBytes": { "metro": { - "raw": 1710000, + "raw": 1712000, "gzip": 455000, "brotli": 360000 }, "hermes": { - "raw": 3470000, - "gzip": 1406000, - "brotli": 1090000 + "raw": 3475000, + "gzip": 1407000, + "brotli": 1095000 } } } From 68ea8a171cab46642f5a745e8443a3fc15d41073 Mon Sep 17 00:00:00 2001 From: Ty J Everett Date: Mon, 31 Aug 2026 17:08:33 -0700 Subject: [PATCH 07/10] docs(wallet-toolbox): reconcile release ledger --- docs/reference/package-api-migrations.md | 27 ++++++++---------------- 1 file changed, 9 insertions(+), 18 deletions(-) diff --git a/docs/reference/package-api-migrations.md b/docs/reference/package-api-migrations.md index 53156ade3..89788361a 100644 --- a/docs/reference/package-api-migrations.md +++ b/docs/reference/package-api-migrations.md @@ -55,12 +55,9 @@ and clean-consumer tests remain the executable type authority. | `@bsv/verifast` | `0.3.0` | `0.3.5` | 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. Keep THIRD_PARTY_NOTICES.md and LICENSES/ with every JavaScript and WebAssembly distribution. | | `@bsv/wallet-helper` | `0.1.1` | `0.1.7` | patch | [API and usage](../packages/helpers/wallet-helper.md) | No consumer migration is required; fluent builder APIs and transaction semantics are unchanged. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/ with the package. | | `@bsv/wallet-relay` | `0.2.2` | `0.3.6` | minor | [API and usage](../packages/wallet/wallet-relay.md) | No wallet RPC migration is required; upgrade to @bsv/sdk 2.4.1 or later. Existing relay sessions and number arrays remain valid, and host applications continue to provide their matching Express runtime and type graph. | -| `@bsv/wallet-toolbox` | `2.10.4` | `2.11.0` | minor | [API and usage](../packages/wallet/wallet-toolbox.md) | Existing permission modules require no changes because onRequest and onResponse remain supported. Semantic modules may add handleRequest; hosts installing @bsv/ecpm-permission-module register it under the ecpm scheme. Upgrade to @bsv/sdk 2.4.2 or later, use docs/storage.md instead of the removed JSight export, and retain THIRD_PARTY_NOTICES.md and LICENSES/ when redistributing. | -| `@bsv/wallet-toolbox-client` | `2.10.4` | `2.11.0` | minor | [API and usage](../packages/wallet/wallet-toolbox-client.md) | Existing permission modules require no changes. Semantic modules may add handleRequest; installing @bsv/ecpm-permission-module requires registration under the ecpm scheme. Use @bsv/sdk 2.4.2 or later and retain THIRD_PARTY_NOTICES.md and LICENSES/ when redistributing. | -| `@bsv/wallet-toolbox-mobile` | `2.10.4` | `2.11.0` | minor | [API and usage](../packages/wallet/wallet-toolbox-mobile.md) | Existing permission modules require no changes. Semantic modules may add handleRequest; mobile hosts can register compatible semantic modules without changing the Wallet interface. Use @bsv/sdk 2.4.2 or later and retain THIRD_PARTY_NOTICES.md and LICENSES/ when redistributing. | -| `@bsv/wallet-toolbox` | `2.10.0` | `2.11.0` | minor | [API and usage](../packages/wallet/wallet-toolbox.md) | Existing actions and ordinary noSend calls require no consumer migration. To use BRC-177, migrate every active Knex store before serving requests and run the default Wallet Toolbox monitor; IndexedDB upgrades automatically to schema version 5. Upgrade signer, active storage service, and remote monitor together to 2.11.0 or later. Older remote storage is rejected before prefunding. Canonical AtomicBEEF and number-array behavior are unchanged; use @bsv/sdk 2.4.2 or later and retain THIRD_PARTY_NOTICES.md and LICENSES/. | -| `@bsv/wallet-toolbox-client` | `2.10.0` | `2.11.0` | minor | [API and usage](../packages/wallet/wallet-toolbox-client.md) | Existing browser actions require no migration and IndexedDB upgrades automatically. To use BRC-177 with remote storage, upgrade the active storage service and its default monitor to Wallet Toolbox 2.11.0 or later before upgrading clients; an older server is rejected before prefunding. Browser exports, wire types, canonical AtomicBEEF behavior, and pagination contracts are unchanged; use @bsv/sdk 2.4.2 or later and retain THIRD_PARTY_NOTICES.md and LICENSES/. | -| `@bsv/wallet-toolbox-mobile` | `2.10.0` | `2.11.0` | minor | [API and usage](../packages/wallet/wallet-toolbox-mobile.md) | Existing mobile actions require no migration. To use BRC-177, migrate and upgrade the active remote storage service and its default monitor to Wallet Toolbox 2.11.0 or later before upgrading clients; an older server is rejected before prefunding. React Native exports, wire types, and canonical AtomicBEEF behavior are unchanged; use @bsv/sdk 2.4.2 or later and retain THIRD_PARTY_NOTICES.md and LICENSES/. | +| `@bsv/wallet-toolbox` | `2.10.4` | `2.11.0` | minor | [API and usage](../packages/wallet/wallet-toolbox.md) | Existing actions, ordinary noSend calls, and permission modules require no migration. To use BRC-177, migrate every active Knex store before serving requests and run the default Wallet Toolbox monitor; IndexedDB upgrades automatically to schema version 5. Upgrade signer, active storage service, and remote monitor together to 2.11.0 or later. Older remote storage is rejected before prefunding. Semantic modules may add handleRequest; hosts installing @bsv/ecpm-permission-module register it under the ecpm scheme. Canonical AtomicBEEF and number-array behavior are unchanged; use @bsv/sdk 2.4.2 or later, use docs/storage.md instead of the removed JSight export, and retain THIRD_PARTY_NOTICES.md and LICENSES/. | +| `@bsv/wallet-toolbox-client` | `2.10.4` | `2.11.0` | minor | [API and usage](../packages/wallet/wallet-toolbox-client.md) | Existing browser actions and permission modules require no migration; IndexedDB upgrades automatically. To use BRC-177 with remote storage, upgrade the active storage service and its default monitor to Wallet Toolbox 2.11.0 or later before upgrading clients; an older server is rejected before prefunding. Semantic modules may add handleRequest; installing @bsv/ecpm-permission-module requires registration under the ecpm scheme. Browser exports, wire types, canonical AtomicBEEF behavior, and pagination contracts are unchanged; use @bsv/sdk 2.4.2 or later and retain THIRD_PARTY_NOTICES.md and LICENSES/. | +| `@bsv/wallet-toolbox-mobile` | `2.10.4` | `2.11.0` | minor | [API and usage](../packages/wallet/wallet-toolbox-mobile.md) | Existing mobile actions and permission modules require no migration. To use BRC-177, migrate and upgrade the active remote storage service and its default monitor to Wallet Toolbox 2.11.0 or later before upgrading clients; an older server is rejected before prefunding. Semantic modules may add handleRequest without changing the Wallet interface. React Native exports, wire types, and canonical AtomicBEEF behavior are unchanged; use @bsv/sdk 2.4.2 or later and retain THIRD_PARTY_NOTICES.md and LICENSES/. | | `create-bsv-app` | `1.0.2` | `1.1.1` | minor | [API and usage](../packages/helpers/create-bsv-app.md) | Existing mainnet and testnet scaffolds are unchanged. New TTN projects pass --network ttn or select TerraTestNet in the configurator. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/ with the package. | `none` means the source manifest matches the recorded npm baseline. Any other @@ -523,10 +520,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: Adds the optional semantic handleRequest hook to BRC-98/99/111 permission modules while retaining the existing transformation hooks, BRC-95/BRC-100 compatibility, and stable bounded pagination. It also removes the obsolete JSight application bundle and preserves the package's earlier Open BSV grant in the distribution notice archive. -- Migration: Existing permission modules require no changes because onRequest and onResponse remain supported. Semantic modules may add handleRequest; hosts installing @bsv/ecpm-permission-module register it under the ecpm scheme. Upgrade to @bsv/sdk 2.4.2 or later, use docs/storage.md instead of the removed JSight export, and retain THIRD_PARTY_NOTICES.md and LICENSES/ when redistributing. -- Release note: Adds the built-in BRC-177 noSend-expiry reference implementation with exact prefunding, durable pre-signed reclaim, atomic active-storage monitoring, cross-device lifecycle synchronization, and proof-finalized race handling. Retains BRC-95 and BRC-100 compatibility and stable bounded pagination, removes an obsolete exported JSight application bundle that lacked its required third-party license companion, preserves the package's earlier Open BSV grant in the distribution notice archive, and standardizes first-party author metadata on the current BSV Association name. -- Migration: Existing actions and ordinary noSend calls require no consumer migration. To use BRC-177, migrate every active Knex store before serving requests and run the default Wallet Toolbox monitor; IndexedDB upgrades automatically to schema version 5. Upgrade signer, active storage service, and remote monitor together to 2.11.0 or later. Older remote storage is rejected before prefunding. Canonical AtomicBEEF and number-array behavior are unchanged; use @bsv/sdk 2.4.2 or later and retain THIRD_PARTY_NOTICES.md and LICENSES/. +- Release note: Adds the built-in BRC-177 noSend-expiry reference implementation with exact prefunding, durable pre-signed reclaim, atomic active-storage monitoring, backoff-controlled recovery of terminally rejected reclaims, cross-device lifecycle synchronization, and proof-finalized race handling, plus the optional semantic handleRequest hook for BRC-98/99/111 permission modules. Retains the existing transformation hooks, BRC-95/BRC-100 compatibility, and stable bounded pagination, removes the obsolete JSight application bundle, and preserves the package's earlier Open BSV grant in the distribution notice archive. +- Migration: Existing actions, ordinary noSend calls, and permission modules require no migration. To use BRC-177, migrate every active Knex store before serving requests and run the default Wallet Toolbox monitor; IndexedDB upgrades automatically to schema version 5. Upgrade signer, active storage service, and remote monitor together to 2.11.0 or later. Older remote storage is rejected before prefunding. Semantic modules may add handleRequest; hosts installing @bsv/ecpm-permission-module register it under the ecpm scheme. Canonical AtomicBEEF and number-array behavior are unchanged; use @bsv/sdk 2.4.2 or later, use docs/storage.md instead of the removed JSight export, and retain THIRD_PARTY_NOTICES.md and LICENSES/. | Public subpath | Runtime target(s) | Declaration target(s) | | ---------------- | ---------------------------------------------------- | -------------------------- | @@ -539,10 +534,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: Exports the optional semantic handleRequest permission-module hook for browser and ESM wallet hosts while retaining transformation modules, BRC-100 wire compatibility, stable IndexedDB totals, and the current browser Wallet Toolbox compatibility fixes. It preserves earlier Open BSV grants in the distribution notice archive. -- Migration: Existing permission modules require no changes. Semantic modules may add handleRequest; installing @bsv/ecpm-permission-module requires registration under the ecpm scheme. Use @bsv/sdk 2.4.2 or later and retain THIRD_PARTY_NOTICES.md and LICENSES/ when redistributing. -- Release note: Adds the built-in browser BRC-177 noSend-expiry signer, IndexedDB schema version 5 lifecycle state, remote storage capability negotiation, and default monitor coordination. Also carries the browser Wallet Toolbox internalization and BRC-100 compatibility fixes, stable IndexedDB totals, earlier Open BSV grant notices, and current BSV Association author metadata. -- Migration: Existing browser actions require no migration and IndexedDB upgrades automatically. To use BRC-177 with remote storage, upgrade the active storage service and its default monitor to Wallet Toolbox 2.11.0 or later before upgrading clients; an older server is rejected before prefunding. Browser exports, wire types, canonical AtomicBEEF behavior, and pagination contracts are unchanged; use @bsv/sdk 2.4.2 or later and retain THIRD_PARTY_NOTICES.md and LICENSES/. +- Release note: Adds the built-in browser BRC-177 noSend-expiry signer, IndexedDB schema version 5 lifecycle state, remote storage capability negotiation, and default monitor coordination, plus the optional semantic handleRequest permission-module hook for browser and ESM wallet hosts. Retains transformation modules, BRC-100 wire compatibility, stable IndexedDB totals, current browser Wallet Toolbox compatibility fixes, and earlier Open BSV grants. +- Migration: Existing browser actions and permission modules require no migration; IndexedDB upgrades automatically. To use BRC-177 with remote storage, upgrade the active storage service and its default monitor to Wallet Toolbox 2.11.0 or later before upgrading clients; an older server is rejected before prefunding. Semantic modules may add handleRequest; installing @bsv/ecpm-permission-module requires registration under the ecpm scheme. Browser exports, wire types, canonical AtomicBEEF behavior, and pagination contracts are unchanged; use @bsv/sdk 2.4.2 or later and retain THIRD_PARTY_NOTICES.md and LICENSES/. | Public subpath | Runtime target(s) | Declaration target(s) | | ---------------- | -------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------- | @@ -553,10 +546,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: Exports the optional semantic handleRequest permission-module hook for React Native wallet hosts while retaining transformation modules, BRC-100 wire compatibility, and the current mobile Wallet Toolbox compatibility fixes. It preserves earlier Open BSV grants in the distribution notice archive. -- Migration: Existing permission modules require no changes. Semantic modules may add handleRequest; mobile hosts can register compatible semantic modules without changing the Wallet interface. Use @bsv/sdk 2.4.2 or later and retain THIRD_PARTY_NOTICES.md and LICENSES/ when redistributing. -- Release note: Adds the built-in mobile BRC-177 noSend-expiry signer, remote storage capability negotiation, and default-monitor ownership coordination across restarts and devices. Also carries the mobile Wallet Toolbox internalization and BRC-100 compatibility fixes, earlier Open BSV grant notices, and current BSV Association author metadata. -- Migration: Existing mobile actions require no migration. To use BRC-177, migrate and upgrade the active remote storage service and its default monitor to Wallet Toolbox 2.11.0 or later before upgrading clients; an older server is rejected before prefunding. React Native exports, wire types, and canonical AtomicBEEF behavior are unchanged; use @bsv/sdk 2.4.2 or later and retain THIRD_PARTY_NOTICES.md and LICENSES/. +- Release note: Adds the built-in mobile BRC-177 noSend-expiry signer, remote storage capability negotiation, and default-monitor ownership coordination across restarts and devices, plus the optional semantic handleRequest permission-module hook for React Native wallet hosts. Retains transformation modules, BRC-100 wire compatibility, current mobile Wallet Toolbox compatibility fixes, and earlier Open BSV grants. +- Migration: Existing mobile actions and permission modules require no migration. To use BRC-177, migrate and upgrade the active remote storage service and its default monitor to Wallet Toolbox 2.11.0 or later before upgrading clients; an older server is rejected before prefunding. Semantic modules may add handleRequest without changing the Wallet interface. React Native exports, wire types, and canonical AtomicBEEF behavior are unchanged; use @bsv/sdk 2.4.2 or later and retain THIRD_PARTY_NOTICES.md and LICENSES/. | Public subpath | Runtime target(s) | Declaration target(s) | | ---------------- | -------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------- | From de5e030a38097617290fe4f51b4e861a9b0a120d Mon Sep 17 00:00:00 2001 From: Ty J Everett Date: Mon, 31 Aug 2026 17:12:57 -0700 Subject: [PATCH 08/10] chore(wallet-toolbox): preserve mobile bundle headroom --- packages/wallet/wallet-toolbox/CHANGELOG.md | 10 +++++----- .../wallet/wallet-toolbox/mobile/platform-budget.json | 4 ++-- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/packages/wallet/wallet-toolbox/CHANGELOG.md b/packages/wallet/wallet-toolbox/CHANGELOG.md index 341ad14a3..eb7a1ccc2 100644 --- a/packages/wallet/wallet-toolbox/CHANGELOG.md +++ b/packages/wallet/wallet-toolbox/CHANGELOG.md @@ -25,13 +25,13 @@ attention to changes that materially alter behavior or extend functionality. before prefunding, attributes the funding fee to the requesting originator, and rechecks the current monthly ledger before releasing the protected action. Existing actions and ordinary `noSend` calls are unchanged. The - current-main macOS reference fixtures measure 1,661,938 raw / 388,676 gzip / - 305,386 Brotli bytes with Vite, 1,297,416 raw / 355,497 gzip / 285,075 Brotli - bytes with esbuild, 1,710,198 raw / 430,529 gzip / 334,202 Brotli bytes with - Metro, and 3,473,582 raw / 1,406,126 gzip / 1,090,069 Brotli bytes as + current-main macOS reference fixtures measure 1,662,220 raw / 388,763 gzip / + 305,307 Brotli bytes with Vite, 1,297,621 raw / 355,579 gzip / 285,031 Brotli + bytes with esbuild, 1,710,494 raw / 430,613 gzip / 334,490 Brotli bytes with + Metro, and 3,474,604 raw / 1,406,878 gzip / 1,090,948 Brotli bytes as optimized Hermes bytecode. The reviewed ceilings advance to 1,665,000 / 390,000 / 307,000 Vite bytes, 1,300,000 / 357,000 / 287,000 esbuild bytes, - 1,712,000 / 455,000 / 360,000 Metro bytes, and 3,475,000 / 1,407,000 / + 1,712,000 / 455,000 / 360,000 Metro bytes, and 3,480,000 / 1,410,000 / 1,095,000 Hermes bytes. - Report `listOutputs` `totalOutputs` as the size of the whole result set on diff --git a/packages/wallet/wallet-toolbox/mobile/platform-budget.json b/packages/wallet/wallet-toolbox/mobile/platform-budget.json index 16d4fe76a..8e9245f9a 100644 --- a/packages/wallet/wallet-toolbox/mobile/platform-budget.json +++ b/packages/wallet/wallet-toolbox/mobile/platform-budget.json @@ -7,8 +7,8 @@ "brotli": 360000 }, "hermes": { - "raw": 3475000, - "gzip": 1407000, + "raw": 3480000, + "gzip": 1410000, "brotli": 1095000 } } From 81610038137db1c2f21b9aee8cfa0ac917b9e05f Mon Sep 17 00:00:00 2001 From: Ty J Everett Date: Mon, 31 Aug 2026 17:17:18 -0700 Subject: [PATCH 09/10] chore(governance): renew mutation policy review --- governance/mutation-testing/policy.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/governance/mutation-testing/policy.json b/governance/mutation-testing/policy.json index 493c4c465..88529f27b 100644 --- a/governance/mutation-testing/policy.json +++ b/governance/mutation-testing/policy.json @@ -1,7 +1,7 @@ { "schemaVersion": 1, - "lastReviewed": "2026-07-31", - "reviewBy": "2026-08-31", + "lastReviewed": "2026-09-01", + "reviewBy": "2026-09-30", "owner": "ts-stack-maintainers", "tool": { "package": "@stryker-mutator/core", From ac4b24b9cfea705d3e38759bc53b11835ab5c9cf Mon Sep 17 00:00:00 2001 From: Ty J Everett Date: Mon, 31 Aug 2026 17:20:01 -0700 Subject: [PATCH 10/10] refactor(wallet-toolbox): group arm validation options --- .../src/storage/methods/noSendExpiry.ts | 22 +++++++++---------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/packages/wallet/wallet-toolbox/src/storage/methods/noSendExpiry.ts b/packages/wallet/wallet-toolbox/src/storage/methods/noSendExpiry.ts index f551c8628..a4291d736 100644 --- a/packages/wallet/wallet-toolbox/src/storage/methods/noSendExpiry.ts +++ b/packages/wallet/wallet-toolbox/src/storage/methods/noSendExpiry.ts @@ -366,16 +366,21 @@ async function verifyReclaimSignature( } } +interface ArmSnapshotValidationOptions { + trx?: TrxToken + observedBlockheight?: number + enforceBlockheight?: boolean +} + async function validateArmSnapshot( storage: StorageProvider, userId: number, reference: string, args: StorageArmNoSendExpiryArgs, reclaim: Transaction, - trx?: TrxToken, - observedBlockheight?: number, - enforceBlockheight = false + options: ArmSnapshotValidationOptions = {} ): Promise { + const { trx, observedBlockheight, enforceBlockheight = false } = options const target = verifyOne( await storage.findTransactions({ partial: { userId, reference }, @@ -437,16 +442,11 @@ export async function armNoSendExpiry( snapshot.noSendExpiryMode === 'blockheight' ? await storage.getServices().getHeight() : undefined await storage.transaction(async trx => { - const target = await validateArmSnapshot( - storage, - userId, - args.reference, - args, - reclaim, + const target = await validateArmSnapshot(storage, userId, args.reference, args, reclaim, { trx, observedBlockheight, - true - ) + enforceBlockheight: true + }) if (!(await storage.compareAndSetNoSendExpiryState(target.transactionId, 'preparing', 'unsigned', trx))) { throw new WERR_INVALID_OPERATION('BRC-177 action changed before it could be armed') }