diff --git a/docs/packages/helpers/simple.md b/docs/packages/helpers/simple.md index 1e88edee6..071079201 100644 --- a/docs/packages/helpers/simple.md +++ b/docs/packages/helpers/simple.md @@ -3,10 +3,10 @@ id: pkg-simple title: '@bsv/simple' kind: package domain: helpers -version: '0.5.0' +version: '0.5.1' source_repo: 'bsv-blockchain/ts-stack' -last_updated: '2026-08-10' -last_verified: '2026-08-10' +last_updated: '2026-08-14' +last_verified: '2026-08-14' review_cadence_days: 30 npm: 'https://www.npmjs.com/package/@bsv/simple' repo: 'https://github.com/bsv-blockchain/ts-stack/tree/main/packages/helpers/simple' diff --git a/docs/packages/messaging/authsocket-client.md b/docs/packages/messaging/authsocket-client.md index 2c289b210..80b3c1dff 100644 --- a/docs/packages/messaging/authsocket-client.md +++ b/docs/packages/messaging/authsocket-client.md @@ -3,10 +3,10 @@ id: pkg-authsocket-client title: '@bsv/authsocket-client' kind: package domain: messaging -version: '2.1.4' +version: '2.1.5' source_repo: 'bsv-blockchain/ts-stack' -last_updated: '2026-07-31' -last_verified: '2026-07-31' +last_updated: '2026-08-14' +last_verified: '2026-08-14' review_cadence_days: 30 npm: 'https://www.npmjs.com/package/@bsv/authsocket-client' repo: 'https://github.com/bsv-blockchain/ts-stack/tree/main/packages/messaging/authsocket-client' diff --git a/docs/packages/messaging/authsocket.md b/docs/packages/messaging/authsocket.md index 56900549b..29c22b5dc 100644 --- a/docs/packages/messaging/authsocket.md +++ b/docs/packages/messaging/authsocket.md @@ -3,10 +3,10 @@ id: pkg-authsocket title: '@bsv/authsocket' kind: package domain: messaging -version: '2.1.5' +version: '2.1.6' source_repo: 'bsv-blockchain/ts-stack' -last_updated: '2026-07-31' -last_verified: '2026-07-31' +last_updated: '2026-08-14' +last_verified: '2026-08-14' review_cadence_days: 30 npm: 'https://www.npmjs.com/package/@bsv/authsocket' repo: 'https://github.com/bsv-blockchain/ts-stack/tree/main/packages/messaging/authsocket' diff --git a/docs/packages/messaging/message-box-client.md b/docs/packages/messaging/message-box-client.md index c2bf0f1d2..412b48b35 100644 --- a/docs/packages/messaging/message-box-client.md +++ b/docs/packages/messaging/message-box-client.md @@ -3,10 +3,10 @@ id: pkg-message-box-client title: '@bsv/message-box-client' kind: package domain: messaging -version: '2.4.0' +version: '2.4.1' source_repo: 'bsv-blockchain/ts-stack' -last_updated: '2026-08-10' -last_verified: '2026-08-10' +last_updated: '2026-08-14' +last_verified: '2026-08-14' review_cadence_days: 30 npm: 'https://www.npmjs.com/package/@bsv/message-box-client' repo: 'https://github.com/bsv-blockchain/ts-stack/tree/main/packages/messaging/message-box-client' @@ -27,6 +27,10 @@ npm install @bsv/message-box-client @bsv/sdk ``` `@bsv/sdk` is a required peer. Node.js 22 or newer is supported. +Use SDK 2.4.1 or newer so BRC-29 sends accept both historical `number[]` and +binary Wallet Wire `Uint8Array` transaction results. Payment receipt also +recovers pending typed-array tokens serialized through JSON as numeric-key +objects. ## Quick start diff --git a/docs/packages/middleware/auth-express-middleware.md b/docs/packages/middleware/auth-express-middleware.md index b27745dca..d3764b1d6 100644 --- a/docs/packages/middleware/auth-express-middleware.md +++ b/docs/packages/middleware/auth-express-middleware.md @@ -3,10 +3,10 @@ id: pkg-auth-express-middleware title: '@bsv/auth-express-middleware' kind: package domain: middleware -version: '2.2.1' +version: '2.2.2' source_repo: 'bsv-blockchain/ts-stack' -last_updated: '2026-08-04' -last_verified: '2026-08-04' +last_updated: '2026-08-14' +last_verified: '2026-08-14' review_cadence_days: 30 npm: 'https://www.npmjs.com/package/@bsv/auth-express-middleware' repo: 'https://github.com/bsv-blockchain/ts-stack/tree/main/packages/middleware/auth-express-middleware' diff --git a/docs/packages/sdk/bsv-sdk.md b/docs/packages/sdk/bsv-sdk.md index aa7e78e9b..3d0af58f0 100644 --- a/docs/packages/sdk/bsv-sdk.md +++ b/docs/packages/sdk/bsv-sdk.md @@ -3,10 +3,10 @@ id: bsv-sdk title: '@bsv/sdk' kind: package domain: sdk -version: '2.4.0' +version: '2.4.1' npm: '@bsv/sdk' -last_updated: '2026-08-10' -last_verified: '2026-08-10' +last_updated: '2026-08-14' +last_verified: '2026-08-14' review_cadence_days: 30 status: stable tags: ['sdk', 'crypto', 'transactions'] @@ -142,6 +142,10 @@ console.log(publicKey, action.txid) `WalletClient` implements the BRC-100 method surface. It discovers a wallet substrate such as BSV Desktop over localhost or BSV Browser over a postMessage bridge. +`CreateActionResult` can carry AtomicBEEF as either a historical `number[]` or +a binary Wallet Wire `Uint8Array`. BRC-29 remittance accepts both and emits a +portable `number[]` settlement artifact for JSON-safe transport. + For advanced postMessage integrations, `XDM` defaults to the wildcard target origin so public apps, mobile webviews, and opaque origins can reach an embedded wallet. Every response must still come from the current parent window and match diff --git a/docs/packages/wallet/btms.md b/docs/packages/wallet/btms.md index 695e373d6..057732077 100644 --- a/docs/packages/wallet/btms.md +++ b/docs/packages/wallet/btms.md @@ -4,9 +4,9 @@ title: '@bsv/btms' kind: package domain: wallet npm: '@bsv/btms' -version: '1.2.0' -last_updated: '2026-08-10' -last_verified: '2026-08-10' +version: '1.2.1' +last_updated: '2026-08-14' +last_verified: '2026-08-14' review_cadence_days: 30 status: stable tags: ['tokens', 'protocol'] diff --git a/docs/packages/wallet/wallet-relay.md b/docs/packages/wallet/wallet-relay.md index f8dadd1eb..5429ef870 100644 --- a/docs/packages/wallet/wallet-relay.md +++ b/docs/packages/wallet/wallet-relay.md @@ -4,9 +4,9 @@ title: '@bsv/wallet-relay' kind: package domain: wallet npm: '@bsv/wallet-relay' -version: '0.3.4' -last_updated: '2026-07-30' -last_verified: '2026-07-30' +version: '0.3.5' +last_updated: '2026-08-14' +last_verified: '2026-08-14' review_cadence_days: 30 status: stable tags: ['wallet', 'relay'] diff --git a/docs/packages/wallet/wallet-toolbox-client.md b/docs/packages/wallet/wallet-toolbox-client.md index 5b7c53d0f..d77a65565 100644 --- a/docs/packages/wallet/wallet-toolbox-client.md +++ b/docs/packages/wallet/wallet-toolbox-client.md @@ -3,9 +3,9 @@ id: pkg-wallet-toolbox-client title: '@bsv/wallet-toolbox-client' kind: package domain: wallet -version: '2.10.1' -last_updated: '2026-08-13' -last_verified: '2026-08-13' +version: '2.10.2' +last_updated: '2026-08-14' +last_verified: '2026-08-14' review_cadence_days: 30 npm: 'https://www.npmjs.com/package/@bsv/wallet-toolbox-client' repo: 'https://github.com/bsv-blockchain/ts-stack/tree/main/packages/wallet/wallet-toolbox/client' diff --git a/docs/packages/wallet/wallet-toolbox-mobile.md b/docs/packages/wallet/wallet-toolbox-mobile.md index d37e6d11d..861bb427a 100644 --- a/docs/packages/wallet/wallet-toolbox-mobile.md +++ b/docs/packages/wallet/wallet-toolbox-mobile.md @@ -3,9 +3,9 @@ id: pkg-wallet-toolbox-mobile title: '@bsv/wallet-toolbox-mobile' kind: package domain: wallet -version: '2.10.1' -last_updated: '2026-08-13' -last_verified: '2026-08-13' +version: '2.10.2' +last_updated: '2026-08-14' +last_verified: '2026-08-14' review_cadence_days: 30 npm: 'https://www.npmjs.com/package/@bsv/wallet-toolbox-mobile' repo: 'https://github.com/bsv-blockchain/ts-stack/tree/main/packages/wallet/wallet-toolbox/mobile' diff --git a/docs/packages/wallet/wallet-toolbox.md b/docs/packages/wallet/wallet-toolbox.md index cdb2307fd..c26021863 100644 --- a/docs/packages/wallet/wallet-toolbox.md +++ b/docs/packages/wallet/wallet-toolbox.md @@ -4,9 +4,9 @@ title: '@bsv/wallet-toolbox' kind: package domain: wallet npm: '@bsv/wallet-toolbox' -version: '2.10.1' -last_updated: '2026-08-13' -last_verified: '2026-08-13' +version: '2.10.2' +last_updated: '2026-08-14' +last_verified: '2026-08-14' review_cadence_days: 30 status: stable tags: ['wallet', 'brc100'] diff --git a/docs/reference/package-api-migrations.md b/docs/reference/package-api-migrations.md index 6a3779ca1..f3b093b6c 100644 --- a/docs/reference/package-api-migrations.md +++ b/docs/reference/package-api-migrations.md @@ -29,32 +29,32 @@ and clean-consumer tests remain the executable type authority. | `@bsv/air-gap` | `0.0.0` | `0.1.1` | minor | [API and usage](../packages/helpers/air-gap.md) | No consumer migration is required; this is the first published release of a new package with no prior public API. The experimental pre-release framing that circulated on the unmerged feature branch is not accepted by the v1 decoder. | | `@bsv/amountinator` | `2.1.1` | `2.1.4` | patch | [API and usage](../packages/helpers/amountinator.md) | No consumer migration is required; this is a backward-compatible patch candidate. | | `@bsv/auth` | `0.1.1` | `0.1.3` | patch | [API and usage](../packages/middleware/auth.md) | No consumer migration is required; authentication APIs, wire behavior, and runtime defaults are unchanged. | -| `@bsv/auth-express-middleware` | `2.2.0` | `2.2.1` | patch | [API and usage](../packages/middleware/auth-express-middleware.md) | No consumer migration is required; valid request, response, authentication, and error contracts are unchanged. | -| `@bsv/authsocket` | `2.1.1` | `2.1.5` | patch | [API and usage](../packages/messaging/authsocket.md) | Valid traffic and the wire contract are unchanged. A socket is now disconnected when its authentication processing exceeds the concurrency limit or a callback fails; use onError for diagnostics and maxPendingAuthMessages to tune the default limit of 32. | -| `@bsv/authsocket-client` | `2.1.1` | `2.1.4` | patch | [API and usage](../packages/messaging/authsocket-client.md) | Valid traffic and supported imports are unchanged. The client now disconnects from a server that causes authentication failure or exceeds the concurrency limit; use onError for diagnostics and maxPendingAuthMessages to tune the default limit of 32. | -| `@bsv/btms` | `1.1.1` | `1.2.0` | minor | [API and usage](../packages/wallet/btms.md) | Existing local, mainnet, and testnet behavior is unchanged. TTN consumers select networkPreset teratestnet and use @bsv/sdk 2.4 or later. | +| `@bsv/auth-express-middleware` | `2.2.0` | `2.2.2` | patch | [API and usage](../packages/middleware/auth-express-middleware.md) | No API migration is required. Upgrade to @bsv/sdk 2.4.1 or later for the shared byte-boundary contract. Generic signed application-body canonicalization remains unchanged so old and new peers verify identical bytes. | +| `@bsv/authsocket` | `2.1.1` | `2.1.6` | patch | [API and usage](../packages/messaging/authsocket.md) | No API migration is required. Existing event data, including numeric-key objects under byte-like names, is unchanged; typed payment protocols recover historical byte objects at their explicit fields. | +| `@bsv/authsocket-client` | `2.1.1` | `2.1.5` | patch | [API and usage](../packages/messaging/authsocket-client.md) | No API migration is required. Existing event data, including numeric-key objects under byte-like names, is unchanged; typed payment protocols recover historical byte objects at their explicit fields. | +| `@bsv/btms` | `1.1.1` | `1.2.1` | minor | [API and usage](../packages/wallet/btms.md) | Existing local, mainnet, testnet, and number-array behavior is unchanged. TTN consumers select networkPreset teratestnet; all consumers should upgrade to @bsv/sdk 2.4.1 or later for byte-boundary compatibility. | | `@bsv/btms-permission-module` | `1.1.1` | `1.1.3` | patch | [API and usage](../packages/wallet/btms-permission-module.md) | No consumer migration is required; permission-module APIs and token semantics are unchanged. | | `@bsv/did` | `0.2.1` | `0.2.4` | patch | [API and usage](../packages/helpers/did.md) | No consumer migration is required; DID APIs, encodings, credential behavior, and supported import forms are unchanged. | | `@bsv/did-client` | `1.2.1` | `1.3.0` | minor | [API and usage](../packages/helpers/did-client.md) | Existing local, mainnet, and testnet behavior is unchanged. TTN consumers select networkPreset teratestnet and use @bsv/sdk 2.4 or later. | | `@bsv/fund-wallet` | `1.4.1` | `1.4.3` | patch | [API and usage](../packages/helpers/fund-wallet.md) | No consumer migration is required; wallet funding APIs and transaction behavior are unchanged. | | `@bsv/gasp` | `1.3.1` | `1.3.5` | patch | [API and usage](../packages/overlays/gasp.md) | No consumer migration is required; existing constructor calls, imports, synchronization behavior, and wire semantics are unchanged. | -| `@bsv/message-box-client` | `2.2.2` | `2.4.0` | minor | [API and usage](../packages/messaging/message-box-client.md) | Existing local, mainnet, testnet, and explicit-host clients are unchanged. TTN consumers set networkPreset to teratestnet and must provide the dedicated TTN host explicitly. | +| `@bsv/message-box-client` | `2.4.0` | `2.4.1` | patch | [API and usage](../packages/messaging/message-box-client.md) | No API migration is required. Upgrade @bsv/sdk and @bsv/message-box-client together; historical number-array wallets, current Uint8Array substrates, and already-pending numeric-key messages interoperate through the same portable transaction form. | | `@bsv/overlay` | `2.2.1` | `2.3.0` | minor | [API and usage](../packages/overlays/overlay.md) | Existing Engine and TopicManager implementations remain valid. Lookup results default to 1,000 formulas; pass -1 only when an equivalent deployment bound exists. Topic managers whose validation creates provisional external state should implement abortAdmissibleOutputs, while read-only managers require no change. | | `@bsv/overlay-discovery-services` | `2.1.1` | `2.2.0` | minor | [API and usage](../packages/overlays/overlay-discovery-services.md) | Existing mainnet and testnet advertisers are unchanged. TTN operators pass chain ttn and provision the staging storage and overlay endpoints before advertising. | | `@bsv/overlay-express` | `2.5.0` | `2.6.0` | minor | [API and usage](../packages/overlays/overlay-express.md) | Existing mainnet and testnet servers are unchanged. TTN servers call configureNetwork('ttn'), configureArcade with the TTN endpoint, and configureChaintracks or configureChainTracker before engine initialization. | | `@bsv/overlay-topics` | `1.6.10` | `1.7.0` | minor | [API and usage](../packages/overlays/overlay-topics.md) | Existing topic and lookup identifiers remain unchanged. Production UMP overlays must give UMPTopicManager and the UMP lookup service Mongo-backed stores that use the same database, then roll out before updated wallet clients; the no-argument manager is bounded but intended only for isolated single-process use. The reservation and bootstrap-marker collections are additive and initialize from currently indexed UMP UTXOs; take a MongoDB backup before rollout. Legacy ambiguous rows remain visible and can be resolved with WAB pinning rather than deleted. | | `@bsv/paymail` | `2.4.2` | `2.4.6` | patch | [API and usage](../packages/messaging/paymail.md) | Existing Paymail client APIs and protocol semantics are retained. Consumers provide one Express 4.18 or 5 runtime and matching type graph; browser bundles continue to exclude the server router implementation. | | `@bsv/payment-express-middleware` | `2.1.1` | `2.1.5` | patch | [API and usage](../packages/middleware/payment-express-middleware.md) | No consumer migration is required; legacy x-bsv-payment JSON behavior remains supported, and Express 4 and 5 applications use their own peer-provided Express installation. | -| `@bsv/sdk` | `2.3.1` | `2.4.0` | minor | [API and usage](../packages/sdk/bsv-sdk.md) | Existing mainnet, testnet, and local routing is unchanged. TTN consumers select teratestnet explicitly because BRC-100 getNetwork continues to report testnet for all test-family chains. | -| `@bsv/simple` | `0.4.1` | `0.5.0` | minor | [API and usage](../packages/helpers/simple.md) | Existing overlay configurations are unchanged. TTN consumers select network teratestnet and use @bsv/sdk 2.4 or later. | +| `@bsv/sdk` | `2.4.0` | `2.4.1` | patch | [API and usage](../packages/sdk/bsv-sdk.md) | No API migration is required. Historical number-array fast paths remain unchanged; consumers using JSON or WebView transports should upgrade both endpoints when possible, while mixed old/new wallet peers remain compatible through portable output and typed-boundary recovery. | +| `@bsv/simple` | `0.4.1` | `0.5.1` | minor | [API and usage](../packages/helpers/simple.md) | Existing overlay configurations and number-array behavior are unchanged. TTN consumers select network teratestnet; all consumers should upgrade to @bsv/sdk 2.4.1 or later for byte-boundary compatibility. | | `@bsv/templates` | `1.9.1` | `1.10.0` | minor | [API and usage](../packages/helpers/templates.md) | No existing consumer migration is required; existing template APIs and generated scripts are unchanged. New R1K1Wallet consumers await lock(), retain each private 32-byte salt, and provide a PIV signer that signs the supplied digest directly without hashing it again. | | `@bsv/teranode-listener` | `1.1.1` | `1.1.4` | patch | [API and usage](../packages/network/teranode-listener.md) | No consumer migration is required; listener APIs, topics, and network configuration are unchanged. | | `@bsv/verifast` | `0.3.0` | `0.3.4` | patch | [API and usage](../packages/sdk/verifast.md) | No consumer migration is required; exports, verification behavior, worker protocols, package paths, and runtime defaults are unchanged. | | `@bsv/wallet-helper` | `0.1.1` | `0.1.6` | patch | [API and usage](../packages/helpers/wallet-helper.md) | No consumer migration is required; fluent builder APIs and transaction semantics are unchanged. | -| `@bsv/wallet-relay` | `0.2.2` | `0.3.4` | minor | [API and usage](../packages/wallet/wallet-relay.md) | QRPairingCode now renders a native button and accepts button wrapper attributes. Existing className, style, data, and ARIA props continue to work; update div-specific wrapper selectors or explicitly typed div event handlers. Express integrations now use the host application's matching Express runtime and type graph. | -| `@bsv/wallet-toolbox` | `2.10.0` | `2.10.1` | patch | [API and usage](../packages/wallet/wallet-toolbox.md) | No consumer migration is required. Canonical AtomicBEEF behavior is unchanged; legacy envelopes containing unrelated branches are accepted only when the declared transaction, its complete recursive dependency proof, and every requested BRC-29 payment output validate normally. | -| `@bsv/wallet-toolbox-client` | `2.10.0` | `2.10.1` | patch | [API and usage](../packages/wallet/wallet-toolbox-client.md) | No consumer migration is required. Browser exports, wire types, canonical AtomicBEEF behavior, proof validation, and BRC-29 locking-script validation are unchanged. | -| `@bsv/wallet-toolbox-mobile` | `2.10.0` | `2.10.1` | patch | [API and usage](../packages/wallet/wallet-toolbox-mobile.md) | No consumer migration is required. React Native exports, wire types, canonical AtomicBEEF behavior, proof validation, and BRC-29 locking-script validation are unchanged. | +| `@bsv/wallet-relay` | `0.2.2` | `0.3.5` | 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.0` | `2.10.2` | patch | [API and usage](../packages/wallet/wallet-toolbox.md) | No consumer migration is required. Canonical AtomicBEEF and existing number-array behavior are unchanged; upgrade to @bsv/sdk 2.4.1 or later for cross-version JSON and wallet-error compatibility. | +| `@bsv/wallet-toolbox-client` | `2.10.0` | `2.10.2` | patch | [API and usage](../packages/wallet/wallet-toolbox-client.md) | No consumer migration is required. Browser exports, wire types, and canonical AtomicBEEF behavior are unchanged; use @bsv/sdk 2.4.1 or later. | +| `@bsv/wallet-toolbox-mobile` | `2.10.0` | `2.10.2` | patch | [API and usage](../packages/wallet/wallet-toolbox-mobile.md) | No consumer migration is required. React Native exports, wire types, and canonical AtomicBEEF behavior are unchanged; use @bsv/sdk 2.4.1 or later. | | `create-bsv-app` | `1.0.2` | `1.1.0` | 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. | `none` means the source manifest matches the recorded npm baseline. Any other @@ -114,8 +114,8 @@ explicitly authorized operations. - Package documentation: [docs/packages/middleware/auth-express-middleware.md](../packages/middleware/auth-express-middleware.md) - Source: [packages/middleware/auth-express-middleware](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/middleware/auth-express-middleware) -- Release note: Contains late BRC-104 peer-processing failures after the HTTP response or connection has already settled, preventing a second Express response from terminating the service process. -- Migration: No consumer migration is required; valid request, response, authentication, and error contracts are unchanged. +- Release note: Contains late BRC-104 peer-processing failures after the HTTP response or connection has already settled, and preserves BRC-100 byte fields in handshake and buffered JSON responses across number-array, Uint8Array, and historical numeric-key JSON runtimes. +- Migration: No API migration is required. Upgrade to @bsv/sdk 2.4.1 or later for the shared byte-boundary contract. Generic signed application-body canonicalization remains unchanged so old and new peers verify identical bytes. | Public subpath | Runtime target(s) | Declaration target(s) | | ---------------- | ------------------------------------ | ---------------------------------------- | @@ -126,8 +126,8 @@ explicitly authorized operations. - Package documentation: [docs/packages/messaging/authsocket.md](../packages/messaging/authsocket.md) - Source: [packages/messaging/authsocket](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/messaging/authsocket) -- Release note: Contains authentication and application callback failures to the offending socket, caps per-socket authentication concurrency, validates authenticated event envelopes, adds safe error reporting, and adds randomized and real-Socket.IO process-survival regression coverage. -- Migration: Valid traffic and the wire contract are unchanged. A socket is now disconnected when its authentication processing exceeds the concurrency limit or a callback fails; use onError for diagnostics and maxPendingAuthMessages to tune the default limit of 32. +- Release note: Contains authentication and application callback failures, caps per-socket authentication concurrency, serializes real typed arrays portably, and preserves arbitrary signed event JSON exactly. +- Migration: No API migration is required. Existing event data, including numeric-key objects under byte-like names, is unchanged; typed payment protocols recover historical byte objects at their explicit fields. | Public subpath | Runtime target(s) | Declaration target(s) | | ---------------- | ------------------------------------ | ---------------------------------------- | @@ -138,8 +138,8 @@ explicitly authorized operations. - Package documentation: [docs/packages/messaging/authsocket-client.md](../packages/messaging/authsocket-client.md) - Source: [packages/messaging/authsocket-client](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/messaging/authsocket-client) -- Release note: Contains authentication and application callback failures, caps authentication concurrency, validates authenticated event envelopes, adds safe error reporting, and registers the remote payload boundary for randomized and mutation testing. -- Migration: Valid traffic and supported imports are unchanged. The client now disconnects from a server that causes authentication failure or exceeds the concurrency limit; use onError for diagnostics and maxPendingAuthMessages to tune the default limit of 32. +- Release note: Contains authentication and application callback failures, caps authentication concurrency, serializes real typed arrays portably, and preserves arbitrary signed event JSON exactly. +- Migration: No API migration is required. Existing event data, including numeric-key objects under byte-like names, is unchanged; typed payment protocols recover historical byte objects at their explicit fields. | Public subpath | Runtime target(s) | Declaration target(s) | | ---------------- | ----------------------------------- | --------------------------------------- | @@ -150,8 +150,8 @@ explicitly authorized operations. - Package documentation: [docs/packages/wallet/btms.md](../packages/wallet/btms.md) - Source: [packages/wallet/btms](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/wallet/btms) -- Release note: Adds TerraTestNet as an explicit overlay-network preset alongside the existing token-manager hardening and package contracts. -- Migration: Existing local, mainnet, and testnet behavior is unchanged. TTN consumers select networkPreset teratestnet and use @bsv/sdk 2.4 or later. +- Release note: Adds TerraTestNet and preserves token settlement and refund transaction bytes across number-array, Uint8Array, and historical numeric-key JSON wallet runtimes. +- Migration: Existing local, mainnet, testnet, and number-array behavior is unchanged. TTN consumers select networkPreset teratestnet; all consumers should upgrade to @bsv/sdk 2.4.1 or later for byte-boundary compatibility. | Public subpath | Runtime target(s) | Declaration target(s) | | -------------- | ---------------------------------------- | -------------------------------------------- | @@ -222,8 +222,8 @@ CLI entry points: `{"fund-metanet":"./dist/index.mjs"}`. - Package documentation: [docs/packages/messaging/message-box-client.md](../packages/messaging/message-box-client.md) - Source: [packages/messaging/message-box-client](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/messaging/message-box-client) -- Release note: Adds first-class TerraTestNet overlay routing while requiring an explicit TTN Message Box host until a dedicated deployment is available. -- Migration: Existing local, mainnet, testnet, and explicit-host clients are unchanged. TTN consumers set networkPreset to teratestnet and must provide the dedicated TTN host explicitly. +- Release note: Preserves BRC-29 payments, paid messages, remittances, and peer tokens across binary Wallet Wire results and historical numeric-key JSON payloads, with strict rejection of sparse or invalid byte records. +- Migration: No API migration is required. Upgrade @bsv/sdk and @bsv/message-box-client together; historical number-array wallets, current Uint8Array substrates, and already-pending numeric-key messages interoperate through the same portable transaction form. | Public subpath | Runtime target(s) | Declaration target(s) | | ---------------- | ----------------------------------- | --------------------------------------- | @@ -316,8 +316,8 @@ CLI entry points: `{"fund-metanet":"./dist/index.mjs"}`. - Package documentation: [docs/packages/sdk/bsv-sdk.md](../packages/sdk/bsv-sdk.md) - Source: [packages/sdk](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/sdk) -- Release note: Adds the teratestnet overlay preset, an isolated TTN SHIP/SLAP root, and explicit resolver selection for identity and registry clients. -- Migration: Existing mainnet, testnet, and local routing is unchanged. TTN consumers select teratestnet explicitly because BRC-100 getNetwork continues to report testnet for all test-family chains. +- Release note: Adds one strict BRC-100 byte-boundary contract: real typed arrays serialize portably, historical bytes recover only at typed protocol fields, arbitrary JSON round-trips unchanged, and React Native discovery remains compatible and bounded. +- Migration: No API migration is required. Historical number-array fast paths remain unchanged; consumers using JSON or WebView transports should upgrade both endpoints when possible, while mixed old/new wallet peers remain compatible through portable output and typed-boundary recovery. | Public subpath | Runtime target(s) | Declaration target(s) | | ---------------------------------- | ---------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------- | @@ -398,8 +398,8 @@ CLI entry points: `{"fund-metanet":"./dist/index.mjs"}`. - Package documentation: [docs/packages/helpers/simple.md](../packages/helpers/simple.md) - Source: [packages/helpers/simple](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/helpers/simple) -- Release note: Adds TerraTestNet to the public Overlay configuration while retaining strict browser and server package contracts. -- Migration: Existing overlay configurations are unchanged. TTN consumers select network teratestnet and use @bsv/sdk 2.4 or later. +- Release note: Adds TerraTestNet and preserves Message Box, token send/receive, and server-adapter transaction bytes across number-array, Uint8Array, and historical numeric-key JSON wallet runtimes. +- Migration: Existing overlay configurations and number-array behavior are unchanged. TTN consumers select network teratestnet; all consumers should upgrade to @bsv/sdk 2.4.1 or later for byte-boundary compatibility. | Public subpath | Runtime target(s) | Declaration target(s) | | -------------- | -------------------------------------------- | ------------------------------------------------ | @@ -461,8 +461,8 @@ CLI entry points: `{"fund-metanet":"./dist/index.mjs"}`. - Package documentation: [docs/packages/wallet/wallet-relay.md](../packages/wallet/wallet-relay.md) - Source: [packages/wallet/ts-wallet-relay](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/wallet/ts-wallet-relay) -- Release note: Adds the strict package and artifact contract, strengthens relay runtime, declaration consistency, native QR accessibility, CLI control flow, and host-owned Express type compatibility, and distinguishes generated consumer customization markers from unfinished library work. -- Migration: QRPairingCode now renders a native button and accepts button wrapper attributes. Existing className, style, data, and ARIA props continue to work; update div-specific wrapper selectors or explicitly typed div event handlers. Express integrations now use the host application's matching Express runtime and type graph. +- Release note: Adds the strict package contract and preserves wallet RPC, encrypted relay payloads, signatures, and transaction bytes across number-array, Uint8Array, Buffer, and historical numeric-key JSON runtimes. +- Migration: 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. CLI entry points: `{"wallet-relay":"./bin/init.mjs"}`. @@ -477,8 +477,8 @@ CLI entry points: `{"wallet-relay":"./bin/init.mjs"}`. - Package documentation: [docs/packages/wallet/wallet-toolbox.md](../packages/wallet/wallet-toolbox.md) - Source: [packages/wallet/wallet-toolbox](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/wallet/wallet-toolbox) -- Release note: Restores BRC-95 backward compatibility for internalizeAction by pruning unrelated legacy BEEF branches around the transaction declared by the atomic prefix before strict transaction, proof, and BRC-29 output validation. -- Migration: No consumer migration is required. Canonical AtomicBEEF behavior is unchanged; legacy envelopes containing unrelated branches are accepted only when the declared transaction, its complete recursive dependency proof, and every requested BRC-29 payment output validate normally. +- Release note: Retains BRC-95 internalization compatibility, preserves typed AtomicBEEF, competing BEEF, and wallet review errors across portable and historical wallet JSON representations, and leaves opaque WAB response JSON unchanged. +- Migration: No consumer migration is required. Canonical AtomicBEEF and existing number-array behavior are unchanged; upgrade to @bsv/sdk 2.4.1 or later for cross-version JSON and wallet-error compatibility. | Public subpath | Runtime target(s) | Declaration target(s) | | ---------------- | ---------------------------------------------------- | -------------------------- | @@ -491,8 +491,8 @@ CLI entry points: `{"wallet-relay":"./bin/init.mjs"}`. - Package documentation: [docs/packages/wallet/wallet-toolbox-client.md](../packages/wallet/wallet-toolbox-client.md) - Source: [packages/wallet/wallet-toolbox/client](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/wallet/wallet-toolbox/client) -- Release note: Carries the browser Wallet Toolbox internalizeAction compatibility fix that removes unrelated legacy BEEF branches before strict validation of the declared AtomicBEEF transaction closure. -- Migration: No consumer migration is required. Browser exports, wire types, canonical AtomicBEEF behavior, proof validation, and BRC-29 locking-script validation are unchanged. +- Release note: Carries the browser Wallet Toolbox internalization and BRC-100 JSON byte-boundary compatibility fixes, including portable wallet review errors and historical response recovery. +- Migration: No consumer migration is required. Browser exports, wire types, and canonical AtomicBEEF behavior are unchanged; use @bsv/sdk 2.4.1 or later. | Public subpath | Runtime target(s) | Declaration target(s) | | ---------------- | -------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------- | @@ -503,8 +503,8 @@ CLI entry points: `{"wallet-relay":"./bin/init.mjs"}`. - Package documentation: [docs/packages/wallet/wallet-toolbox-mobile.md](../packages/wallet/wallet-toolbox-mobile.md) - Source: [packages/wallet/wallet-toolbox/mobile](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/wallet/wallet-toolbox/mobile) -- Release note: Carries the mobile Wallet Toolbox internalizeAction compatibility fix that removes unrelated legacy BEEF branches before strict validation of the declared AtomicBEEF transaction closure. -- Migration: No consumer migration is required. React Native exports, wire types, canonical AtomicBEEF behavior, proof validation, and BRC-29 locking-script validation are unchanged. +- Release note: Carries the mobile Wallet Toolbox internalization and BRC-100 JSON byte-boundary compatibility fixes, including portable wallet review errors and historical response recovery. +- Migration: No consumer migration is required. React Native exports, wire types, and canonical AtomicBEEF behavior are unchanged; use @bsv/sdk 2.4.1 or later. | Public subpath | Runtime target(s) | Declaration target(s) | | ---------------- | -------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------- | diff --git a/docs/reference/stack-facts.md b/docs/reference/stack-facts.md index 0dd41484d..2a8927ecd 100644 --- a/docs/reference/stack-facts.md +++ b/docs/reference/stack-facts.md @@ -42,17 +42,17 @@ authorized release action. | helpers | `@bsv/did` | `0.2.4` | browser-library | browser-bundler, browser-esm, node-cjs, node-esm | browser, node | `>=22` | [packages/helpers/did](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/helpers/did) | | helpers | `@bsv/did-client` | `1.3.0` | browser-library | browser-bundler, browser-esm, node-cjs, node-esm, umd-global | browser, node, umd | `>=22` | [packages/helpers/did-client](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/helpers/did-client) | | helpers | `@bsv/fund-wallet` | `1.4.3` | cli | cli | node | `>=22` | [packages/helpers/fund-wallet](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/helpers/fund-wallet) | -| helpers | `@bsv/simple` | `0.5.0` | browser-library | browser-bundler, browser-esm, node-cjs, node-esm | browser, node | `>=22` | [packages/helpers/simple](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/helpers/simple) | +| helpers | `@bsv/simple` | `0.5.1` | browser-library | browser-bundler, browser-esm, node-cjs, node-esm | browser, node | `>=22` | [packages/helpers/simple](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/helpers/simple) | | helpers | `@bsv/templates` | `1.10.0` | browser-library | browser-bundler, browser-esm, node-cjs, node-esm | browser, node | `>=22` | [packages/helpers/ts-templates](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/helpers/ts-templates) | | helpers | `@bsv/wallet-helper` | `0.1.6` | node-library | node-cjs, node-esm | node | `>=22` | [packages/helpers/bsv-wallet-helper](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/helpers/bsv-wallet-helper) | | helpers | `create-bsv-app` | `1.1.0` | cli | cli | node | `>=22` | [packages/helpers/create-bsv-app](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/helpers/create-bsv-app) | -| messaging | `@bsv/authsocket` | `2.1.5` | node-library | node-cjs, node-esm | node | `>=22` | [packages/messaging/authsocket](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/messaging/authsocket) | -| messaging | `@bsv/authsocket-client` | `2.1.4` | browser-library | browser-bundler, browser-esm, node-cjs, node-esm, umd-global | browser, node, umd | `>=22` | [packages/messaging/authsocket-client](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/messaging/authsocket-client) | -| messaging | `@bsv/message-box-client` | `2.4.0` | browser-library | browser-bundler, browser-esm, node-cjs, node-esm, umd-global | browser, node, umd | `>=22` | [packages/messaging/message-box-client](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/messaging/message-box-client) | +| messaging | `@bsv/authsocket` | `2.1.6` | node-library | node-cjs, node-esm | node | `>=22` | [packages/messaging/authsocket](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/messaging/authsocket) | +| messaging | `@bsv/authsocket-client` | `2.1.5` | browser-library | browser-bundler, browser-esm, node-cjs, node-esm, umd-global | browser, node, umd | `>=22` | [packages/messaging/authsocket-client](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/messaging/authsocket-client) | +| messaging | `@bsv/message-box-client` | `2.4.1` | browser-library | browser-bundler, browser-esm, node-cjs, node-esm, umd-global | browser, node, umd | `>=22` | [packages/messaging/message-box-client](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/messaging/message-box-client) | | messaging | `@bsv/paymail` | `2.4.6` | browser-library | browser-bundler, browser-esm, node-cjs, node-esm | browser, node | `>=22` | [packages/messaging/ts-paymail](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/messaging/ts-paymail) | | middleware | `@bsv/402-pay` | `0.2.4` | browser-library | browser-bundler, browser-esm, node-cjs, node-esm | browser, node | `>=22` | [packages/middleware/402-pay](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/middleware/402-pay) | | middleware | `@bsv/auth` | `0.1.3` | node-library | node-cjs, node-esm | node | `>=22` | [packages/middleware/auth](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/middleware/auth) | -| middleware | `@bsv/auth-express-middleware` | `2.2.1` | node-library | node-cjs, node-esm | node | `>=22` | [packages/middleware/auth-express-middleware](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/middleware/auth-express-middleware) | +| middleware | `@bsv/auth-express-middleware` | `2.2.2` | node-library | node-cjs, node-esm | node | `>=22` | [packages/middleware/auth-express-middleware](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/middleware/auth-express-middleware) | | middleware | `@bsv/payment-express-middleware` | `2.1.5` | node-library | node-cjs, node-esm | node | `>=22` | [packages/middleware/payment-express-middleware](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/middleware/payment-express-middleware) | | network | `@bsv/teranode-listener` | `1.1.4` | node-library | node-esm | node | `>=22` | [packages/network/ts-p2p](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/network/ts-p2p) | | overlays | `@bsv/gasp` | `1.3.5` | browser-library | browser-bundler, browser-esm, node-cjs, node-esm | browser, node | `>=22` | [packages/overlays/gasp-core](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/overlays/gasp-core) | @@ -60,14 +60,14 @@ authorized release action. | overlays | `@bsv/overlay-discovery-services` | `2.2.0` | node-library | node-cjs, node-esm | node | `>=22` | [packages/overlays/overlay-discovery-services](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/overlays/overlay-discovery-services) | | overlays | `@bsv/overlay-express` | `2.6.0` | node-library | node-cjs, node-esm | node | `>=22` | [packages/overlays/overlay-express](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/overlays/overlay-express) | | overlays | `@bsv/overlay-topics` | `1.7.0` | node-library | node-esm | node | `>=22` | [packages/overlays/topics](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/overlays/topics) | -| sdk | `@bsv/sdk` | `2.4.0` | browser-library | browser-bundler, browser-esm, node-cjs, node-esm, umd-global | browser, node, umd | `>=22` | [packages/sdk](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/sdk) | +| sdk | `@bsv/sdk` | `2.4.1` | browser-library | browser-bundler, browser-esm, node-cjs, node-esm, umd-global | browser, node, umd | `>=22` | [packages/sdk](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/sdk) | | sdk | `@bsv/verifast` | `0.3.4` | wasm-library | browser-bundler, browser-esm, node-cjs, node-esm, umd-global, wasm-worker | browser, node, umd, wasm, worker | `>=22` | [packages/verifast](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/verifast) | -| wallet | `@bsv/btms` | `1.2.0` | node-library | node-cjs, node-esm | node | `>=22` | [packages/wallet/btms](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/wallet/btms) | +| wallet | `@bsv/btms` | `1.2.1` | node-library | node-cjs, node-esm | node | `>=22` | [packages/wallet/btms](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/wallet/btms) | | wallet | `@bsv/btms-permission-module` | `1.1.3` | node-library | node-esm | node | `>=22` | [packages/wallet/btms-permission-module](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/wallet/btms-permission-module) | -| wallet | `@bsv/wallet-relay` | `0.3.4` | cli-library | browser-bundler, browser-esm, cli, node-cjs, node-esm | browser, node | `>=22` | [packages/wallet/ts-wallet-relay](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/wallet/ts-wallet-relay) | -| wallet | `@bsv/wallet-toolbox` | `2.10.1` | node-library | node-cjs | node | `>=22` | [packages/wallet/wallet-toolbox](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/wallet/wallet-toolbox) | -| wallet | `@bsv/wallet-toolbox-client` | `2.10.1` | browser-library | browser-bundler, browser-esm, node-cjs, node-esm | browser, node | `>=22` | [packages/wallet/wallet-toolbox/client](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/wallet/wallet-toolbox/client) | -| wallet | `@bsv/wallet-toolbox-mobile` | `2.10.1` | react-native-library | react-native-metro | react-native | `>=22` | [packages/wallet/wallet-toolbox/mobile](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/wallet/wallet-toolbox/mobile) | +| wallet | `@bsv/wallet-relay` | `0.3.5` | cli-library | browser-bundler, browser-esm, cli, node-cjs, node-esm | browser, node | `>=22` | [packages/wallet/ts-wallet-relay](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/wallet/ts-wallet-relay) | +| wallet | `@bsv/wallet-toolbox` | `2.10.2` | node-library | node-cjs | node | `>=22` | [packages/wallet/wallet-toolbox](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/wallet/wallet-toolbox) | +| wallet | `@bsv/wallet-toolbox-client` | `2.10.2` | browser-library | browser-bundler, browser-esm, node-cjs, node-esm | browser, node | `>=22` | [packages/wallet/wallet-toolbox/client](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/wallet/wallet-toolbox/client) | +| wallet | `@bsv/wallet-toolbox-mobile` | `2.10.2` | react-native-library | react-native-metro | react-native | `>=22` | [packages/wallet/wallet-toolbox/mobile](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/wallet/wallet-toolbox/mobile) | ## Standalone infrastructure manifests diff --git a/docs/specs/brc-29-peer-payment.md b/docs/specs/brc-29-peer-payment.md index aa752c2e0..bb8c9fb53 100644 --- a/docs/specs/brc-29-peer-payment.md +++ b/docs/specs/brc-29-peer-payment.md @@ -3,8 +3,8 @@ id: spec-brc-29-peer-payment title: BRC-29 Simple Authenticated P2P Payment Protocol kind: spec version: "1.0.0" -last_updated: "2026-04-30" -last_verified: "2026-04-30" +last_updated: "2026-08-14" +last_verified: "2026-08-14" status: stable tags: ["spec", "payments", "brc-29"] --- @@ -64,6 +64,11 @@ tags: ["spec", "payments", "brc-29"] - `derivationSuffix: string` — Client-provided derivation suffix - `metadata: object` — Optional sender metadata (memo, reference ID, etc.) +Wallet implementations may return the AtomicBEEF created for this message as +either the historical `number[]` representation or a binary `Uint8Array`. +Remittance producers accept both BRC-100 representations and normalize the +transport artifact to a JSON-safe byte array without changing its bytes. + For compatibility with older senders, recipients may prune unrelated BEEF branches from the AtomicBEEF envelope before internalization. The transaction identified by the BRC-95 prefix, all of its recursive dependencies, every proof, diff --git a/governance/mutation-testing/policy.json b/governance/mutation-testing/policy.json index 1d6883b0c..3c9f4fe42 100644 --- a/governance/mutation-testing/policy.json +++ b/governance/mutation-testing/policy.json @@ -27,6 +27,16 @@ "maximumNoCoverage": 0, "maximumInvalid": 0 }, + { + "id": "sdk-brc100-json", + "manifest": "packages/sdk/package.json", + "propertyTest": "packages/sdk/src/wallet/__tests/BRC100ByteEncoding.property.test.ts", + "risk": "critical", + "boundary": "BRC-100 wallet byte recovery and arbitrary application JSON serialization", + "minimumScore": 90, + "maximumNoCoverage": 0, + "maximumInvalid": 0 + }, { "id": "sdk-auth-http", "manifest": "packages/sdk/package.json", diff --git a/governance/mutation-testing/targets.mjs b/governance/mutation-testing/targets.mjs index a9e0b518f..3926f6d67 100644 --- a/governance/mutation-testing/targets.mjs +++ b/governance/mutation-testing/targets.mjs @@ -70,6 +70,42 @@ export function buildMutationTargets(repositoryRoot) { esm: true }) }, + 'sdk-brc100-json': { + packageDirectory: 'packages/sdk', + manifest: 'packages/sdk/package.json', + propertyTest: 'packages/sdk/src/wallet/__tests/BRC100ByteEncoding.property.test.ts', + mutate: [ + sourceLineRange( + repositoryRoot, + 'packages/sdk', + 'src/wallet/BRC100ByteEncoding.ts', + 'export function normalizeBRC100ByteArray(', + '/** Convert a valid BRC-100 byte array' + ), + sourceLineRange( + repositoryRoot, + 'packages/sdk', + 'src/wallet/BRC100ByteEncoding.ts', + 'export function brc100JsonReplacer(', + '/** Serialize a BRC-100 payload' + ), + sourceLineRange( + repositoryRoot, + 'packages/sdk', + 'src/wallet/BRC100ByteEncoding.ts', + 'export function stringifyBRC100(', + '* Repairs byte arrays only in explicitly selected own fields' + ) + ], + ...jestTarget( + 'jest.config.js', + [ + '/src/wallet/__tests/BRC100ByteEncoding.property.test.ts', + '/src/wallet/__tests/BRC100ByteEncoding.test.ts' + ], + { esm: true } + ) + }, 'sdk-auth-http': { packageDirectory: 'packages/sdk', manifest: 'packages/sdk/package.json', @@ -112,8 +148,8 @@ export function buildMutationTargets(repositoryRoot) { ], [ 'src/auth/transports/SimplifiedFetchTransport.ts', - 'await this.onDataCallback!((await response.json()) as AuthMessage)', - "if (message.messageType === 'initialRequest') resolve()" + 'private async sendAuthMessage(message: AuthMessage): Promise {', + 'private encodeRequestBody(' ], [ 'src/auth/transports/SimplifiedFetchTransport.ts', diff --git a/governance/package-release-notes.json b/governance/package-release-notes.json index d18511770..393ed8dea 100644 --- a/governance/package-release-notes.json +++ b/governance/package-release-notes.json @@ -35,29 +35,29 @@ "name": "@bsv/auth-express-middleware", "publishedVersion": "2.2.0", "releaseType": "patch", - "summary": "Contains late BRC-104 peer-processing failures after the HTTP response or connection has already settled, preventing a second Express response from terminating the service process.", - "migration": "No consumer migration is required; valid request, response, authentication, and error contracts are unchanged." + "summary": "Contains late BRC-104 peer-processing failures after the HTTP response or connection has already settled, and preserves BRC-100 byte fields in handshake and buffered JSON responses across number-array, Uint8Array, and historical numeric-key JSON runtimes.", + "migration": "No API migration is required. Upgrade to @bsv/sdk 2.4.1 or later for the shared byte-boundary contract. Generic signed application-body canonicalization remains unchanged so old and new peers verify identical bytes." }, { "name": "@bsv/authsocket", "publishedVersion": "2.1.1", "releaseType": "patch", - "summary": "Contains authentication and application callback failures to the offending socket, caps per-socket authentication concurrency, validates authenticated event envelopes, adds safe error reporting, and adds randomized and real-Socket.IO process-survival regression coverage.", - "migration": "Valid traffic and the wire contract are unchanged. A socket is now disconnected when its authentication processing exceeds the concurrency limit or a callback fails; use onError for diagnostics and maxPendingAuthMessages to tune the default limit of 32." + "summary": "Contains authentication and application callback failures, caps per-socket authentication concurrency, serializes real typed arrays portably, and preserves arbitrary signed event JSON exactly.", + "migration": "No API migration is required. Existing event data, including numeric-key objects under byte-like names, is unchanged; typed payment protocols recover historical byte objects at their explicit fields." }, { "name": "@bsv/authsocket-client", "publishedVersion": "2.1.1", "releaseType": "patch", - "summary": "Contains authentication and application callback failures, caps authentication concurrency, validates authenticated event envelopes, adds safe error reporting, and registers the remote payload boundary for randomized and mutation testing.", - "migration": "Valid traffic and supported imports are unchanged. The client now disconnects from a server that causes authentication failure or exceeds the concurrency limit; use onError for diagnostics and maxPendingAuthMessages to tune the default limit of 32." + "summary": "Contains authentication and application callback failures, caps authentication concurrency, serializes real typed arrays portably, and preserves arbitrary signed event JSON exactly.", + "migration": "No API migration is required. Existing event data, including numeric-key objects under byte-like names, is unchanged; typed payment protocols recover historical byte objects at their explicit fields." }, { "name": "@bsv/btms", "publishedVersion": "1.1.1", "releaseType": "minor", - "summary": "Adds TerraTestNet as an explicit overlay-network preset alongside the existing token-manager hardening and package contracts.", - "migration": "Existing local, mainnet, and testnet behavior is unchanged. TTN consumers select networkPreset teratestnet and use @bsv/sdk 2.4 or later." + "summary": "Adds TerraTestNet and preserves token settlement and refund transaction bytes across number-array, Uint8Array, and historical numeric-key JSON wallet runtimes.", + "migration": "Existing local, mainnet, testnet, and number-array behavior is unchanged. TTN consumers select networkPreset teratestnet; all consumers should upgrade to @bsv/sdk 2.4.1 or later for byte-boundary compatibility." }, { "name": "@bsv/btms-permission-module", @@ -96,10 +96,10 @@ }, { "name": "@bsv/message-box-client", - "publishedVersion": "2.2.2", - "releaseType": "minor", - "summary": "Adds first-class TerraTestNet overlay routing while requiring an explicit TTN Message Box host until a dedicated deployment is available.", - "migration": "Existing local, mainnet, testnet, and explicit-host clients are unchanged. TTN consumers set networkPreset to teratestnet and must provide the dedicated TTN host explicitly." + "publishedVersion": "2.4.0", + "releaseType": "patch", + "summary": "Preserves BRC-29 payments, paid messages, remittances, and peer tokens across binary Wallet Wire results and historical numeric-key JSON payloads, with strict rejection of sparse or invalid byte records.", + "migration": "No API migration is required. Upgrade @bsv/sdk and @bsv/message-box-client together; historical number-array wallets, current Uint8Array substrates, and already-pending numeric-key messages interoperate through the same portable transaction form." }, { "name": "@bsv/overlay", @@ -145,17 +145,17 @@ }, { "name": "@bsv/sdk", - "publishedVersion": "2.3.1", - "releaseType": "minor", - "summary": "Adds the teratestnet overlay preset, an isolated TTN SHIP/SLAP root, and explicit resolver selection for identity and registry clients.", - "migration": "Existing mainnet, testnet, and local routing is unchanged. TTN consumers select teratestnet explicitly because BRC-100 getNetwork continues to report testnet for all test-family chains." + "publishedVersion": "2.4.0", + "releaseType": "patch", + "summary": "Adds one strict BRC-100 byte-boundary contract: real typed arrays serialize portably, historical bytes recover only at typed protocol fields, arbitrary JSON round-trips unchanged, and React Native discovery remains compatible and bounded.", + "migration": "No API migration is required. Historical number-array fast paths remain unchanged; consumers using JSON or WebView transports should upgrade both endpoints when possible, while mixed old/new wallet peers remain compatible through portable output and typed-boundary recovery." }, { "name": "@bsv/simple", "publishedVersion": "0.4.1", "releaseType": "minor", - "summary": "Adds TerraTestNet to the public Overlay configuration while retaining strict browser and server package contracts.", - "migration": "Existing overlay configurations are unchanged. TTN consumers select network teratestnet and use @bsv/sdk 2.4 or later." + "summary": "Adds TerraTestNet and preserves Message Box, token send/receive, and server-adapter transaction bytes across number-array, Uint8Array, and historical numeric-key JSON wallet runtimes.", + "migration": "Existing overlay configurations and number-array behavior are unchanged. TTN consumers select network teratestnet; all consumers should upgrade to @bsv/sdk 2.4.1 or later for byte-boundary compatibility." }, { "name": "@bsv/templates", @@ -189,29 +189,29 @@ "name": "@bsv/wallet-relay", "publishedVersion": "0.2.2", "releaseType": "minor", - "summary": "Adds the strict package and artifact contract, strengthens relay runtime, declaration consistency, native QR accessibility, CLI control flow, and host-owned Express type compatibility, and distinguishes generated consumer customization markers from unfinished library work.", - "migration": "QRPairingCode now renders a native button and accepts button wrapper attributes. Existing className, style, data, and ARIA props continue to work; update div-specific wrapper selectors or explicitly typed div event handlers. Express integrations now use the host application's matching Express runtime and type graph." + "summary": "Adds the strict package contract and preserves wallet RPC, encrypted relay payloads, signatures, and transaction bytes across number-array, Uint8Array, Buffer, and historical numeric-key JSON runtimes.", + "migration": "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." }, { "name": "@bsv/wallet-toolbox", "publishedVersion": "2.10.0", "releaseType": "patch", - "summary": "Restores BRC-95 backward compatibility for internalizeAction by pruning unrelated legacy BEEF branches around the transaction declared by the atomic prefix before strict transaction, proof, and BRC-29 output validation.", - "migration": "No consumer migration is required. Canonical AtomicBEEF behavior is unchanged; legacy envelopes containing unrelated branches are accepted only when the declared transaction, its complete recursive dependency proof, and every requested BRC-29 payment output validate normally." + "summary": "Retains BRC-95 internalization compatibility, preserves typed AtomicBEEF, competing BEEF, and wallet review errors across portable and historical wallet JSON representations, and leaves opaque WAB response JSON unchanged.", + "migration": "No consumer migration is required. Canonical AtomicBEEF and existing number-array behavior are unchanged; upgrade to @bsv/sdk 2.4.1 or later for cross-version JSON and wallet-error compatibility." }, { "name": "@bsv/wallet-toolbox-client", "publishedVersion": "2.10.0", "releaseType": "patch", - "summary": "Carries the browser Wallet Toolbox internalizeAction compatibility fix that removes unrelated legacy BEEF branches before strict validation of the declared AtomicBEEF transaction closure.", - "migration": "No consumer migration is required. Browser exports, wire types, canonical AtomicBEEF behavior, proof validation, and BRC-29 locking-script validation are unchanged." + "summary": "Carries the browser Wallet Toolbox internalization and BRC-100 JSON byte-boundary compatibility fixes, including portable wallet review errors and historical response recovery.", + "migration": "No consumer migration is required. Browser exports, wire types, and canonical AtomicBEEF behavior are unchanged; use @bsv/sdk 2.4.1 or later." }, { "name": "@bsv/wallet-toolbox-mobile", "publishedVersion": "2.10.0", "releaseType": "patch", - "summary": "Carries the mobile Wallet Toolbox internalizeAction compatibility fix that removes unrelated legacy BEEF branches before strict validation of the declared AtomicBEEF transaction closure.", - "migration": "No consumer migration is required. React Native exports, wire types, canonical AtomicBEEF behavior, proof validation, and BRC-29 locking-script validation are unchanged." + "summary": "Carries the mobile Wallet Toolbox internalization and BRC-100 JSON byte-boundary compatibility fixes, including portable wallet review errors and historical response recovery.", + "migration": "No consumer migration is required. React Native exports, wire types, and canonical AtomicBEEF behavior are unchanged; use @bsv/sdk 2.4.1 or later." }, { "name": "create-bsv-app", diff --git a/governance/repository-health/baselines.json b/governance/repository-health/baselines.json index 8807d8363..dedb65461 100644 --- a/governance/repository-health/baselines.json +++ b/governance/repository-health/baselines.json @@ -304,15 +304,15 @@ "@bsv/did": "0.2.4", "@bsv/did-client": "1.3.0", "@bsv/fund-wallet": "1.4.3", - "@bsv/simple": "0.5.0", + "@bsv/simple": "0.5.1", "@bsv/templates": "1.10.0", - "@bsv/authsocket": "2.1.5", - "@bsv/authsocket-client": "2.1.4", - "@bsv/message-box-client": "2.4.0", + "@bsv/authsocket": "2.1.6", + "@bsv/authsocket-client": "2.1.5", + "@bsv/message-box-client": "2.4.1", "@bsv/paymail": "2.4.6", "@bsv/402-pay": "0.2.4", "@bsv/auth": "0.1.3", - "@bsv/auth-express-middleware": "2.2.1", + "@bsv/auth-express-middleware": "2.2.2", "@bsv/payment-express-middleware": "2.1.5", "@bsv/teranode-listener": "1.1.4", "@bsv/gasp": "1.3.5", @@ -320,13 +320,13 @@ "@bsv/overlay-discovery-services": "2.2.0", "@bsv/overlay-express": "2.6.0", "@bsv/overlay-topics": "1.7.0", - "@bsv/sdk": "2.4.0", + "@bsv/sdk": "2.4.1", "@bsv/verifast": "0.3.4", - "@bsv/btms": "1.2.0", + "@bsv/btms": "1.2.1", "@bsv/btms-permission-module": "1.1.3", - "@bsv/wallet-relay": "0.3.4", - "@bsv/wallet-toolbox-client": "2.10.1", - "@bsv/wallet-toolbox-mobile": "2.10.1", - "@bsv/wallet-toolbox": "2.10.1" + "@bsv/wallet-relay": "0.3.5", + "@bsv/wallet-toolbox-client": "2.10.2", + "@bsv/wallet-toolbox-mobile": "2.10.2", + "@bsv/wallet-toolbox": "2.10.2" } } diff --git a/governance/test-quality/policy.json b/governance/test-quality/policy.json index 301076b40..0ec37e7db 100644 --- a/governance/test-quality/policy.json +++ b/governance/test-quality/policy.json @@ -56,6 +56,18 @@ "A deterministic checksum mutation is rejected." ] }, + { + "path": "packages/sdk/src/wallet/__tests/BRC100ByteEncoding.property.test.ts", + "manifest": "packages/sdk/package.json", + "risk": "critical", + "boundary": "BRC-100 wallet byte recovery and arbitrary application JSON serialization", + "target": "Portable typed-array encoding without reinterpretation of ordinary or numeric-key JSON objects", + "invariants": [ + "Every ordinary JSON value retains exactly the native JSON serialization.", + "Every Uint8Array is serialized as the equivalent portable number array.", + "Adjacent application-defined JSON remains unchanged while typed bytes are encoded." + ] + }, { "path": "packages/sdk/src/auth/clients/__tests__/AuthFetch.property.test.ts", "manifest": "packages/sdk/package.json", diff --git a/packages/helpers/simple/README.md b/packages/helpers/simple/README.md index ca8e4e13c..f1188a0bd 100644 --- a/packages/helpers/simple/README.md +++ b/packages/helpers/simple/README.md @@ -40,6 +40,12 @@ The library has two entry points: Both entry points provide the same API surface — the only difference is how they connect to the underlying wallet. +Message Box payment helpers normalize historical number-array, current binary +Wallet Wire, and numeric-key JSON transaction representations before sending +or internalizing them. Malformed transaction records fail without acknowledging +the pending payment. The server adapter applies the same compatibility contract +to request and response bodies before the host framework's JSON boundary. + ## A taste of the API ```typescript diff --git a/packages/helpers/simple/package.json b/packages/helpers/simple/package.json index 65810721c..e83b8e255 100644 --- a/packages/helpers/simple/package.json +++ b/packages/helpers/simple/package.json @@ -1,6 +1,6 @@ { "name": "@bsv/simple", - "version": "0.5.0", + "version": "0.5.1", "sideEffects": false, "engines": { "node": ">=22" @@ -115,7 +115,7 @@ }, "homepage": "https://github.com/bsv-blockchain/ts-stack/tree/main/packages/helpers/simple#readme", "peerDependencies": { - "@bsv/sdk": "^2.4.0" + "@bsv/sdk": "^2.4.1" }, "peerDependenciesMeta": { "@bsv/sdk": { diff --git a/packages/helpers/simple/src/modules/__tests__/tokens.test.ts b/packages/helpers/simple/src/modules/__tests__/tokens.test.ts new file mode 100644 index 000000000..0be6751cd --- /dev/null +++ b/packages/helpers/simple/src/modules/__tests__/tokens.test.ts @@ -0,0 +1,128 @@ +import { PeerPayClient } from '@bsv/message-box-client' +import { WalletCore } from '../../core/WalletCore' +import { createTokenMethods } from '../tokens' + +jest.mock('@bsv/message-box-client', () => ({ + PeerPayClient: jest.fn() +})) + +const listMessages = jest.fn() +const acknowledgeMessage = jest.fn() +const internalizeAction = jest.fn() + +function createCore(): WalletCore { + return { + defaults: { + messageBoxHost: 'https://messagebox.example', + tokenBasket: 'received-tokens' + }, + getClient: jest.fn().mockReturnValue({ internalizeAction }), + getIdentityKey: jest.fn().mockReturnValue('02'.repeat(33)) + } as unknown as WalletCore +} + +describe('MessageBox token byte compatibility', () => { + beforeEach(() => { + jest.clearAllMocks() + jest.mocked(PeerPayClient).mockImplementation( + () => + ({ + listMessages, + acknowledgeMessage + }) as any + ) + }) + + it('repairs historical numeric-key transaction objects when listing tokens', async () => { + listMessages.mockResolvedValue([ + { + messageId: 'message-1', + sender: 'fallback-sender', + created_at: '2026-08-14T00:00:00.000Z', + body: JSON.stringify({ + sender: 'sender-1', + transaction: { 0: 1, 1: 2, 2: 3 }, + protocolID: [2, 'tokens'], + keyID: 'key-1', + outputIndex: 4 + }) + } + ]) + + const tokens = await createTokenMethods(createCore()).listIncomingTokens() + + expect(tokens).toEqual([ + { + messageId: 'message-1', + sender: 'sender-1', + transaction: [1, 2, 3], + protocolID: [2, 'tokens'], + keyID: 'key-1', + outputIndex: 4, + createdAt: '2026-08-14T00:00:00.000Z' + } + ]) + }) + + it('internalizes valid historical bytes before acknowledging the message', async () => { + internalizeAction.mockResolvedValue({ accepted: true }) + acknowledgeMessage.mockResolvedValue(undefined) + const methods = createTokenMethods(createCore()) + + await expect( + methods.acceptIncomingToken({ + messageId: 'message-2', + sender: 'sender-2', + transaction: { 0: 7, 1: 8, 2: 9 }, + protocolID: [2, 'tokens'], + keyID: 'key-2', + outputIndex: 1 + }) + ).resolves.toEqual({ + accepted: true, + basket: 'received-tokens', + sender: 'sender-2' + }) + + expect(internalizeAction).toHaveBeenCalledWith( + expect.objectContaining({ + tx: [7, 8, 9], + outputs: [ + expect.objectContaining({ + outputIndex: 1, + insertionRemittance: expect.objectContaining({ + basket: 'received-tokens', + customInstructions: JSON.stringify({ + protocolID: [2, 'tokens'], + keyID: 'key-2', + counterparty: 'sender-2' + }) + }) + }) + ] + }) + ) + expect(acknowledgeMessage).toHaveBeenCalledWith({ messageIds: ['message-2'] }) + expect(internalizeAction.mock.invocationCallOrder[0]).toBeLessThan( + acknowledgeMessage.mock.invocationCallOrder[0] + ) + }) + + it.each([ + [{}, 'empty'], + [{ 0: 1, 2: 3 }, 'sparse'], + [{ 0: 256 }, 'out-of-range'] + ])('rejects %s transaction bytes before wallet mutation (%s)', async (transaction, _label) => { + const methods = createTokenMethods(createCore()) + + await expect( + methods.acceptIncomingToken({ + messageId: 'message-invalid', + sender: 'sender-invalid', + transaction + }) + ).rejects.toThrow('Incoming token contains an invalid transaction byte payload') + expect(internalizeAction).not.toHaveBeenCalled() + expect(acknowledgeMessage).not.toHaveBeenCalled() + }) +}) diff --git a/packages/helpers/simple/src/modules/messagebox.ts b/packages/helpers/simple/src/modules/messagebox.ts index 31fff6c10..40d6ef700 100644 --- a/packages/helpers/simple/src/modules/messagebox.ts +++ b/packages/helpers/simple/src/modules/messagebox.ts @@ -1,4 +1,5 @@ import { PeerPayClient } from '@bsv/message-box-client' +import { normalizeBRC100ByteArray, stringifyBRC100 } from '@bsv/sdk' import { WalletCore } from '../core/WalletCore' export function createMessageBoxMethods(core: WalletCore): { @@ -49,7 +50,7 @@ export function createMessageBoxMethods(core: WalletCore): { const res = await fetch(`${effectiveRegistry}?action=register`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ tag: handle, identityKey: core.getIdentityKey() }) + body: stringifyBRC100({ tag: handle, identityKey: core.getIdentityKey() }) }) const data = (await res.json()) as { success: boolean; error?: string } if (!data.success) throw new Error(data.error ?? 'Registration failed') @@ -93,7 +94,7 @@ export function createMessageBoxMethods(core: WalletCore): { const res = await fetch(`${effectiveRegistry}?action=revoke`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ tag: t.tag, identityKey: core.getIdentityKey() }) + body: stringifyBRC100({ tag: t.tag, identityKey: core.getIdentityKey() }) }) const data = (await res.json()) as { success: boolean } if (!data.success) throw new Error('Revoke failed') @@ -113,7 +114,7 @@ export function createMessageBoxMethods(core: WalletCore): { await client.sendMessage({ recipient: to, messageBox: 'payment_inbox', - body: JSON.stringify(paymentToken) + body: stringifyBRC100(paymentToken) }) return { @@ -138,6 +139,10 @@ export function createMessageBoxMethods(core: WalletCore): { async acceptIncomingPayment(payment: any, basket?: string): Promise { const pp = getPeerPay() const walletClient = core.getClient() + const transaction = normalizeBRC100ByteArray(payment?.token?.transaction) + if (transaction == null || transaction.length === 0) { + throw new Error('Incoming payment transaction must be a non-empty BRC-100 byte array') + } // Step 1: Internalize the payment. If this fails, do NOT acknowledge the // message — the sender's tx data and derivation info must be preserved so @@ -147,7 +152,7 @@ export function createMessageBoxMethods(core: WalletCore): { // Wallet payment: output goes directly into wallet's spendable balance try { await (walletClient as any).internalizeAction({ - tx: payment.token.transaction, + tx: transaction, outputs: [ { outputIndex: payment.token.outputIndex ?? 0, @@ -171,14 +176,14 @@ export function createMessageBoxMethods(core: WalletCore): { // Basket insertion: output goes into a named basket try { await (walletClient as any).internalizeAction({ - tx: payment.token.transaction, + tx: transaction, outputs: [ { outputIndex: payment.token.outputIndex ?? 0, protocol: 'basket insertion', insertionRemittance: { basket, - customInstructions: JSON.stringify({ + customInstructions: stringifyBRC100({ derivationPrefix: payment.token.customInstructions.derivationPrefix, derivationSuffix: payment.token.customInstructions.derivationSuffix, senderIdentityKey: payment.sender @@ -222,7 +227,7 @@ export function createMessageBoxMethods(core: WalletCore): { const res = await fetch(`${effectiveRegistry}?action=register`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ tag, identityKey: core.getIdentityKey() }) + body: stringifyBRC100({ tag, identityKey: core.getIdentityKey() }) }) const data = (await res.json()) as { success: boolean; error?: string; tag?: string } if (!data.success) throw new Error(data.error ?? 'Registration failed') @@ -283,7 +288,7 @@ export function createMessageBoxMethods(core: WalletCore): { const res = await fetch(`${effectiveRegistry}?action=revoke`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ tag, identityKey: core.getIdentityKey() }) + body: stringifyBRC100({ tag, identityKey: core.getIdentityKey() }) }) const data = (await res.json()) as { success: boolean; error?: string } if (!data.success) throw new Error(data.error ?? 'Revoke failed') diff --git a/packages/helpers/simple/src/modules/tokens.ts b/packages/helpers/simple/src/modules/tokens.ts index b4adca767..bad613d46 100644 --- a/packages/helpers/simple/src/modules/tokens.ts +++ b/packages/helpers/simple/src/modules/tokens.ts @@ -1,4 +1,14 @@ -import { Utils, PushDrop, SecurityLevel, Random, LockingScript, Transaction, Beef } from '@bsv/sdk' +import { + Utils, + PushDrop, + SecurityLevel, + Random, + LockingScript, + Transaction, + Beef, + normalizeBRC100ByteArray, + stringifyBRC100 +} from '@bsv/sdk' import { PeerPayClient } from '@bsv/message-box-client' import { WalletCore } from '../core/WalletCore' import { @@ -84,7 +94,7 @@ export function createTokenMethods(core: WalletCore): { const satoshis = options.satoshis ?? 1 const dataString = - typeof options.data === 'object' ? JSON.stringify(options.data) : String(options.data) + typeof options.data === 'object' ? stringifyBRC100(options.data) : String(options.data) const plaintext = Array.from(Utils.toArray(dataString, 'utf8')) const encryptResult = await client.encrypt({ @@ -113,7 +123,7 @@ export function createTokenMethods(core: WalletCore): { lockingScript: lockingScript.toHex(), satoshis, basket, - customInstructions: JSON.stringify({ protocolID, keyID, counterparty: 'self' }), + customInstructions: stringifyBRC100({ protocolID, keyID, counterparty: 'self' }), tags: ['token'], outputDescription: `Token (${basket})` } @@ -256,7 +266,7 @@ export function createTokenMethods(core: WalletCore): { lockingScript: newLockingScript.toHex(), outputDescription: 'Token for recipient', basket, - customInstructions: JSON.stringify({ + customInstructions: stringifyBRC100({ protocolID, keyID: newKeyID, counterparty: newCounterparty @@ -479,7 +489,7 @@ export function createTokenMethods(core: WalletCore): { await peerPay.sendMessage({ recipient: to, messageBox: TOKEN_MESSAGE_BOX, - body: JSON.stringify({ + body: stringifyBRC100({ transaction: (finalResult as any).tx, protocolID, keyID: newKeyID, @@ -516,10 +526,11 @@ export function createTokenMethods(core: WalletCore): { body = JSON.parse(body) } catch {} } + const transaction = normalizeBRC100ByteArray(body?.transaction) return { messageId: msg.messageId, sender: (body?.sender ?? msg.sender) as string, - transaction: body?.transaction, + transaction: transaction ?? body?.transaction, protocolID: body?.protocolID, keyID: body?.keyID, outputIndex: body?.outputIndex ?? 0, @@ -535,16 +546,20 @@ export function createTokenMethods(core: WalletCore): { try { const client = core.getClient() const effectiveBasket = basket ?? core.defaults.tokenBasket + const transaction = normalizeBRC100ByteArray(token.transaction) + if (transaction == null || transaction.length === 0) { + throw new Error('Incoming token contains an invalid transaction byte payload') + } await client.internalizeAction({ - tx: token.transaction, + tx: transaction, outputs: [ { outputIndex: token.outputIndex ?? 0, protocol: 'basket insertion', insertionRemittance: { basket: effectiveBasket, - customInstructions: JSON.stringify({ + customInstructions: stringifyBRC100({ protocolID: token.protocolID, keyID: token.keyID, counterparty: token.sender diff --git a/packages/helpers/simple/src/server/__tests__/handler-types.test.ts b/packages/helpers/simple/src/server/__tests__/handler-types.test.ts new file mode 100644 index 000000000..54d5fe0eb --- /dev/null +++ b/packages/helpers/simple/src/server/__tests__/handler-types.test.ts @@ -0,0 +1,20 @@ +import { jsonResponse, toNextHandlers } from '../handler-types' + +describe('Next.js handler BRC-100 byte compatibility', () => { + it('preserves arbitrary request objects and emits portable response bytes', async () => { + const post = jest.fn(async (req: { json: () => Promise }) => { + await expect(req.json()).resolves.toEqual({ transaction: { 0: 1, 1: 2, 2: 255 } }) + return jsonResponse({ transaction: new Uint8Array([3, 4, 254]) }, 201) + }) + const handlers = toNextHandlers({ POST: post }) + + const response = await handlers.POST?.({ + url: 'https://example.test/payment', + json: async () => ({ transaction: { 0: 1, 1: 2, 2: 255 } }) + }) + + expect(post).toHaveBeenCalledTimes(1) + expect(response?.status).toBe(201) + await expect(response?.json()).resolves.toEqual({ transaction: [3, 4, 254] }) + }) +}) diff --git a/packages/helpers/simple/src/server/handler-types.ts b/packages/helpers/simple/src/server/handler-types.ts index 40d6b33b8..901f6df4c 100644 --- a/packages/helpers/simple/src/server/handler-types.ts +++ b/packages/helpers/simple/src/server/handler-types.ts @@ -3,6 +3,8 @@ * Avoids importing 'next/server' so the library has no Next.js build dependency. */ +import { stringifyBRC100 } from '@bsv/sdk' + export interface HandlerRequest { url: string method: string @@ -52,11 +54,11 @@ export function toNextHandlers(handler: RouteHandler): { const result = await coreFn({ url: (req.url as string | undefined) ?? req.nextUrl?.toString() ?? '', method, - json: () => req.json() + json: async () => await req.json() }) // Use Web-standard Response (works in Next.js, Deno, Bun, Workers) - return new Response(JSON.stringify(result.body), { + return new Response(stringifyBRC100(result.body), { status: result.status, headers: { 'Content-Type': 'application/json' } }) diff --git a/packages/messaging/authsocket-client/README.md b/packages/messaging/authsocket-client/README.md index e1a7b3905..5934a0e0c 100644 --- a/packages/messaging/authsocket-client/README.md +++ b/packages/messaging/authsocket-client/README.md @@ -64,6 +64,12 @@ socket.on('disconnect', () => { 2. Interact with `.on(...)`, `.emit(...)` as normal. 3. Behind the scenes, each message is signed with your client wallet key and verified by the server. Inbound messages are also verified. +Authenticated event data preserves arbitrary JSON exactly, including plain +numeric-key objects under names such as `data`, `payload`, `transaction`, and +`tx`. Real `Uint8Array` values are serialized as portable number arrays. Code +that owns a typed payment or wallet protocol may recover a historical +numeric-key byte object at that protocol's explicit byte field after receipt. + ### Failure isolation and resource limits Authentication frames and application callbacks are contained inside the diff --git a/packages/messaging/authsocket-client/package.json b/packages/messaging/authsocket-client/package.json index 7c25484d0..0964b55f7 100644 --- a/packages/messaging/authsocket-client/package.json +++ b/packages/messaging/authsocket-client/package.json @@ -1,6 +1,6 @@ { "name": "@bsv/authsocket-client", - "version": "2.1.4", + "version": "2.1.5", "sideEffects": false, "engines": { "node": ">=22" @@ -85,7 +85,7 @@ "webpack-cli": "^7.2.2" }, "peerDependencies": { - "@bsv/sdk": "^2.1.6" + "@bsv/sdk": "^2.4.1" }, "peerDependenciesMeta": { "@bsv/sdk": { diff --git a/packages/messaging/authsocket-client/src/AuthSocketClient.ts b/packages/messaging/authsocket-client/src/AuthSocketClient.ts index 738acdf0f..f50ecd671 100644 --- a/packages/messaging/authsocket-client/src/AuthSocketClient.ts +++ b/packages/messaging/authsocket-client/src/AuthSocketClient.ts @@ -11,7 +11,8 @@ import { Peer, WalletInterface, Utils, - OriginatorDomainNameStringUnder250Bytes + OriginatorDomainNameStringUnder250Bytes, + stringifyBRC100 } from '@bsv/sdk' import { SocketClientTransport } from './SocketClientTransport.js' @@ -167,7 +168,7 @@ class AuthSocketClientImpl { private encodeEventPayload(eventName: string, data: any): number[] { const obj = { eventName, data } - return Utils.toArray(JSON.stringify(obj), 'utf8') + return Utils.toArray(stringifyBRC100(obj), 'utf8') } private decodeEventPayload(payload: number[]): { eventName: string; data: any } { diff --git a/packages/messaging/authsocket-client/src/__tests__/AuthSocketClient.test.ts b/packages/messaging/authsocket-client/src/__tests__/AuthSocketClient.test.ts index cd15f886f..759ce0216 100644 --- a/packages/messaging/authsocket-client/src/__tests__/AuthSocketClient.test.ts +++ b/packages/messaging/authsocket-client/src/__tests__/AuthSocketClient.test.ts @@ -19,6 +19,7 @@ jest.mock('socket.io-client', () => ({ })) jest.mock('@bsv/sdk', () => ({ + ...jest.requireActual('@bsv/sdk'), Peer: mockPeerConstructor, Utils: { toArray: (value: string) => Array.from(Buffer.from(value, 'utf8')), @@ -141,6 +142,49 @@ describe('AuthSocketClient', () => { expect(identityKey).toBe('server-key') }) + it('serializes real typed arrays without rewriting byte-like application objects', () => { + const { client } = createClient() + const received = jest.fn() + const historicalTx = JSON.parse(JSON.stringify(new Uint8Array([4, 5, 6]))) + client.on('payment', received) + + generalMessageListener?.( + 'server-key', + Array.from( + Buffer.from(JSON.stringify({ eventName: 'payment', data: { transaction: historicalTx } })) + ) + ) + client.emit('payment', { transaction: new Uint8Array([1, 2, 3]) }) + + expect(received).toHaveBeenCalledWith({ transaction: historicalTx }) + const [payload] = mockPeer.toPeer.mock.calls[0] + expect(JSON.parse(Buffer.from(payload).toString('utf8'))).toEqual({ + eventName: 'payment', + data: { transaction: [1, 2, 3] } + }) + }) + + it('round-trips numeric-key application event data without silent corruption', () => { + const { client } = createClient() + const received = jest.fn() + const applicationData = { 0: 1, 1: 2 } + client.on('applicationEvent', received) + + generalMessageListener?.( + 'server-key', + Array.from( + Buffer.from(JSON.stringify({ eventName: 'applicationEvent', data: applicationData })) + ) + ) + client.emit('applicationEvent', applicationData) + + expect(received).toHaveBeenCalledWith(applicationData) + const [payload] = mockPeer.toPeer.mock.calls[0] + expect(Buffer.from(payload).toString('utf8')).toBe( + '{"eventName":"applicationEvent","data":{"0":1,"1":2}}' + ) + }) + it('reports asynchronous send failures with the event name', async () => { const error = new Error('send failed') const onError = jest.fn() diff --git a/packages/messaging/authsocket/README.md b/packages/messaging/authsocket/README.md index 32dd9b173..64b59216d 100644 --- a/packages/messaging/authsocket/README.md +++ b/packages/messaging/authsocket/README.md @@ -91,6 +91,12 @@ cross-origin default of its own. `socket.on(...)` and `socket.emit(...)` calls. 3. Messages are signed and verified under the hood. +Authenticated event data preserves arbitrary JSON exactly, including plain +numeric-key objects under names such as `data`, `payload`, `transaction`, and +`tx`. Real `Uint8Array` values are serialized as portable number arrays. Code +that owns a typed payment or wallet protocol may recover a historical +numeric-key byte object at that protocol's explicit byte field after receipt. + Call `await io.close()` during shutdown. It is idempotent and disconnects active Socket.IO clients before closing the attached HTTP server. diff --git a/packages/messaging/authsocket/package.json b/packages/messaging/authsocket/package.json index 86d05af00..4a3d7ecf4 100644 --- a/packages/messaging/authsocket/package.json +++ b/packages/messaging/authsocket/package.json @@ -1,6 +1,6 @@ { "name": "@bsv/authsocket", - "version": "2.1.5", + "version": "2.1.6", "sideEffects": false, "engines": { "node": ">=22" @@ -81,7 +81,7 @@ "typescript": "npm:@typescript/typescript6@6.0.2" }, "peerDependencies": { - "@bsv/sdk": "^2.1.6" + "@bsv/sdk": "^2.4.1" }, "peerDependenciesMeta": { "@bsv/sdk": { diff --git a/packages/messaging/authsocket/src/AuthSocketServer.ts b/packages/messaging/authsocket/src/AuthSocketServer.ts index 8a6cb11b1..d2622f125 100644 --- a/packages/messaging/authsocket/src/AuthSocketServer.ts +++ b/packages/messaging/authsocket/src/AuthSocketServer.ts @@ -1,6 +1,12 @@ import { Server as HttpServer } from 'node:http' import { ServerOptions, Server as IoServer, Socket as IoSocket } from 'socket.io' -import { WalletInterface, Peer, SessionManager, AsyncSessionManager } from '@bsv/sdk' +import { + WalletInterface, + Peer, + SessionManager, + AsyncSessionManager, + stringifyBRC100 +} from '@bsv/sdk' import { SocketServerTransport } from './SocketServerTransport.js' export type AuthSocketErrorPhase = 'authentication' | 'application' | 'connection' | 'send' @@ -251,7 +257,7 @@ export class AuthSocketServer { private encodeEventPayload(eventName: string, data: any): number[] { const obj = { eventName, data } - return Array.from(Buffer.from(JSON.stringify(obj), 'utf8')) + return Array.from(Buffer.from(stringifyBRC100(obj), 'utf8')) } private reportError(error: unknown, context: AuthSocketErrorContext): void { @@ -365,7 +371,7 @@ export class AuthSocket { ///////////////////////////// private encodeEventPayload(eventName: string, data: any): number[] { - const json = JSON.stringify({ eventName, data }) + const json = stringifyBRC100({ eventName, data }) return Array.from(Buffer.from(json, 'utf8')) } diff --git a/packages/messaging/authsocket/src/__tests__/AuthSocket.test.ts b/packages/messaging/authsocket/src/__tests__/AuthSocket.test.ts index a8cc30cfe..64e157783 100644 --- a/packages/messaging/authsocket/src/__tests__/AuthSocket.test.ts +++ b/packages/messaging/authsocket/src/__tests__/AuthSocket.test.ts @@ -65,6 +65,39 @@ describe('AuthSocket', () => { expect(identityKey).toBe('peer-key') }) + it('serializes real typed arrays without rewriting byte-like application objects', async () => { + const { authSocket, generalMessage, peer } = createHarness() + const received = jest.fn() + const historicalTx = JSON.parse(JSON.stringify(new Uint8Array([4, 5, 6]))) + authSocket.on('payment', received) + + generalMessage({ eventName: 'payment', data: { transaction: historicalTx } }) + await authSocket.emit('payment', { transaction: new Uint8Array([1, 2, 3]) }) + + expect(received).toHaveBeenCalledWith({ transaction: historicalTx }) + const [payload] = peer.toPeer.mock.calls[0] + expect(JSON.parse(Buffer.from(payload).toString('utf8'))).toEqual({ + eventName: 'payment', + data: { transaction: [1, 2, 3] } + }) + }) + + it('round-trips numeric-key application event data without silent corruption', async () => { + const { authSocket, generalMessage, peer } = createHarness() + const received = jest.fn() + const applicationData = { 0: 1, 1: 2 } + authSocket.on('applicationEvent', received) + + generalMessage({ eventName: 'applicationEvent', data: applicationData }) + await authSocket.emit('applicationEvent', applicationData) + + expect(received).toHaveBeenCalledWith(applicationData) + const [payload] = peer.toPeer.mock.calls[0] + expect(Buffer.from(payload).toString('utf8')).toBe( + '{"eventName":"applicationEvent","data":{"0":1,"1":2}}' + ) + }) + it('routes malformed payloads to the explicit unknown event', () => { const { authSocket, generalMessage } = createHarness() const unknown = jest.fn() diff --git a/packages/messaging/authsocket/src/__tests__/AuthSocketServer.lifecycle.test.ts b/packages/messaging/authsocket/src/__tests__/AuthSocketServer.lifecycle.test.ts index f1b8ff23d..d0191691b 100644 --- a/packages/messaging/authsocket/src/__tests__/AuthSocketServer.lifecycle.test.ts +++ b/packages/messaging/authsocket/src/__tests__/AuthSocketServer.lifecycle.test.ts @@ -16,6 +16,7 @@ jest.mock('socket.io', () => ({ })) jest.mock('@bsv/sdk', () => ({ + ...jest.requireActual('@bsv/sdk'), Peer: mockPeerConstructor })) diff --git a/packages/messaging/message-box-client/CHANGELOG.md b/packages/messaging/message-box-client/CHANGELOG.md index 62875feeb..ddf8763fb 100644 --- a/packages/messaging/message-box-client/CHANGELOG.md +++ b/packages/messaging/message-box-client/CHANGELOG.md @@ -27,6 +27,18 @@ All notable changes to this project will be documented in this file. The format ### Fixed +- Require the SDK patch that accepts binary Wallet Wire `Uint8Array` + transaction results in BRC-29 payment construction, and rebuild the browser + bundle with that correction. Historical `number[]` wallet results remain + unchanged. PeerPay receipt also recovers the numeric-key object produced when + a typed array crosses JSON transport, preserving pending cross-version + payments. +- Normalize and validate BRC-100 transaction bytes at every Message Box JSON + boundary: PeerPay, token settlements, paid-message delivery, batch delivery, + remittance adaptation, live fallback, and receipt. Current typed-array, + historical number-array, and numeric-key JSON representations now converge + before wallet or adapter dispatch; malformed bytes are never acknowledged. + ### Security --- diff --git a/packages/messaging/message-box-client/README.md b/packages/messaging/message-box-client/README.md index 8bf146fc5..8a190bc2c 100644 --- a/packages/messaging/message-box-client/README.md +++ b/packages/messaging/message-box-client/README.md @@ -192,6 +192,14 @@ for (const payment of incoming) { `PeerPayClient` uses BRC-29 wallet-payment derivation and the same authenticated Message Box transport. It also supports live delivery, payment requests, responses, cancellations, and explicit rejection/refund flows. +Its SDK peer accepts both historical `number[]` and binary Wallet Wire +`Uint8Array` transaction results, while payment messages retain a portable JSON +byte-array representation. Receipt remains compatible with pending messages +whose typed-array bytes were already serialized as contiguous numeric keys. +The same compatibility contract covers paid-message fees, batch delivery, +token settlements, live-message fallback, and generic remittance transport. +Malformed, sparse, or out-of-range byte records are rejected before wallet +internalization and the source message remains available for retry or recovery. ## Token settlement diff --git a/packages/messaging/message-box-client/package.json b/packages/messaging/message-box-client/package.json index 05bfb8f22..c6ed729eb 100644 --- a/packages/messaging/message-box-client/package.json +++ b/packages/messaging/message-box-client/package.json @@ -1,6 +1,6 @@ { "name": "@bsv/message-box-client", - "version": "2.4.0", + "version": "2.4.1", "sideEffects": false, "engines": { "node": ">=22" @@ -88,7 +88,7 @@ "@bsv/authsocket-client": "workspace:^" }, "peerDependencies": { - "@bsv/sdk": "^2.4.0" + "@bsv/sdk": "^2.4.1" }, "peerDependenciesMeta": { "@bsv/sdk": { diff --git a/packages/messaging/message-box-client/src/MessageBoxClient.ts b/packages/messaging/message-box-client/src/MessageBoxClient.ts index 235139d86..cd22ab2d2 100644 --- a/packages/messaging/message-box-client/src/MessageBoxClient.ts +++ b/packages/messaging/message-box-client/src/MessageBoxClient.ts @@ -40,6 +40,9 @@ import { Random, OriginatorDomainNameStringUnder250Bytes, Beef, + normalizeBRC100ByteArray, + toBRC100PortableByteArray, + stringifyBRC100, type LookupNetworkPreset } from '@bsv/sdk' import { AuthSocketClient } from '@bsv/authsocket-client' @@ -449,13 +452,13 @@ export class MessageBoxClient { // Listen for authentication success from the server this.socket.on('authenticationSuccess', data => { - Logger.log(`[MB CLIENT] WebSocket authentication successful: ${JSON.stringify(data)}`) + Logger.log(`[MB CLIENT] WebSocket authentication successful: ${stringifyBRC100(data)}`) this.socketAuthenticated = true }) // Handle authentication failures this.socket.on('authenticationFailed', data => { - Logger.error(`[MB CLIENT ERROR] WebSocket authentication failed: ${JSON.stringify(data)}`) + Logger.error(`[MB CLIENT ERROR] WebSocket authentication failed: ${stringifyBRC100(data)}`) this.socketAuthenticated = false }) @@ -778,7 +781,7 @@ export class MessageBoxClient { ? parsedBody : (() => { try { - return JSON.stringify(parsedBody) + return stringifyBRC100(parsedBody) } catch { return '[Error: Unstringifiable message]' } @@ -853,7 +856,7 @@ export class MessageBoxClient { try { const hmac = await this.walletClient.createHmac( { - data: Array.from(new TextEncoder().encode(JSON.stringify(body))), + data: Array.from(new TextEncoder().encode(stringifyBRC100(body))), protocolID: [1, 'messagebox'], keyID: '1', counterparty: recipient @@ -875,19 +878,19 @@ export class MessageBoxClient { let outgoingBody: string if (skipEncryption === true) { - outgoingBody = typeof body === 'string' ? body : JSON.stringify(body) + outgoingBody = typeof body === 'string' ? body : stringifyBRC100(body) } else { const encryptedMessage = await this.walletClient.encrypt( { protocolID: [1, 'messagebox'], keyID: '1', counterparty: recipient, - plaintext: Utils.toArray(typeof body === 'string' ? body : JSON.stringify(body), 'utf8') + plaintext: Utils.toArray(typeof body === 'string' ? body : stringifyBRC100(body), 'utf8') }, this.originator ) - outgoingBody = JSON.stringify({ + outgoingBody = stringifyBRC100({ encryptedMessage: Utils.toBase64(encryptedMessage.ciphertext) }) } @@ -1078,14 +1081,14 @@ export class MessageBoxClient { const sendUrl = messageBoxEndpoint(finalHost, '/sendMessage') Logger.log('[MB CLIENT] Sending HTTP request to:', sendUrl) - Logger.log('[MB CLIENT] Request Body:', JSON.stringify(requestBody, null, 2)) + Logger.log('[MB CLIENT] Request Body:', stringifyBRC100(requestBody, 2)) await this.ensureIdentityKey() const response = await this.authFetch.fetch(sendUrl, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(requestBody) + body: stringifyBRC100(requestBody) }) if (response.bodyUsed) @@ -1172,7 +1175,7 @@ export class MessageBoxClient { try { const hmac = await this.walletClient.createHmac( { - data: Array.from(new TextEncoder().encode(JSON.stringify(message.body))), + data: Array.from(new TextEncoder().encode(stringifyBRC100(message.body))), protocolID: [1, 'messagebox'], keyID: '1', counterparty: message.recipient @@ -1193,7 +1196,7 @@ export class MessageBoxClient { /** Encode the message body (encrypt unless skipEncryption is set). */ private async encodeMessageBody(message: SendMessageParams): Promise { - const bodyStr = typeof message.body === 'string' ? message.body : JSON.stringify(message.body) + const bodyStr = typeof message.body === 'string' ? message.body : stringifyBRC100(message.body) if (message.skipEncryption === true) return bodyStr const encryptedMessage = await this.walletClient.encrypt( { @@ -1204,7 +1207,7 @@ export class MessageBoxClient { }, this.originator ) - return JSON.stringify({ encryptedMessage: Utils.toBase64(encryptedMessage.ciphertext) }) + return stringifyBRC100({ encryptedMessage: Utils.toBase64(encryptedMessage.ciphertext) }) } /** Ensure myIdentityKey is populated, fetching it if needed. */ @@ -1260,9 +1263,13 @@ export class MessageBoxClient { Logger.log( `[MB CLIENT] Internalizing ${recipientOutputs.length} recipient payment output(s)…` ) + const tx = normalizeBRC100ByteArray(p.paymentData!.tx) + if (tx == null || tx.length === 0) { + throw new Error('Message payment transaction must be a non-empty BRC-100 byte array') + } const result = await this.walletClient.internalizeAction( { - tx: p.paymentData!.tx, + tx, outputs: recipientOutputs, description: p.paymentData!.description ?? 'MessageBox recipient payment' }, @@ -1388,7 +1395,7 @@ export class MessageBoxClient { } // 6) Build per-recipient messageIds (HMAC), same order as allowedRecipients - const bodyBytes = Array.from(new TextEncoder().encode(JSON.stringify(body))) + const bodyBytes = Array.from(new TextEncoder().encode(stringifyBRC100(body))) const messageIds: string[] = await this.mapWithConcurrency(allowedRecipients, 8, async r => { const hmac = await this.walletClient.createHmac( { @@ -1406,7 +1413,7 @@ export class MessageBoxClient { // 7) Body: for batch route the server expects a single shared body. // Per-recipient encryption requires a different server payload shape. - const finalBody = typeof body === 'string' ? body : JSON.stringify(body) + const finalBody = typeof body === 'string' ? body : stringifyBRC100(body) // 8) ONE batch payment with server output at index 0 const paymentData = await this.createMessagePaymentBatch( @@ -1430,14 +1437,14 @@ export class MessageBoxClient { Logger.log('[MB CLIENT] Sending HTTP request to:', sendUrl) Logger.log( '[MB CLIENT] Request Body (batch):', - JSON.stringify({ ...requestBody, payment: { ...paymentData, tx: '' } }, null, 2) + stringifyBRC100({ ...requestBody, payment: { ...paymentData, tx: '' } }, 2) ) try { const response = await this.authFetch.fetch(sendUrl, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(requestBody) + body: stringifyBRC100(requestBody) }) const parsed = await response.json().catch(() => ({}) as any) @@ -2032,7 +2039,7 @@ export class MessageBoxClient { const response = await this.authFetch.fetch(messageBoxEndpoint(host, '/listMessages'), { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(body) + body: stringifyBRC100(body) }) if (!response.ok) throw new Error(`HTTP ${response.status} ${response.statusText}`) @@ -2167,8 +2174,12 @@ export class MessageBoxClient { `[MB CLIENT] Internalizing ${recipientOutputs.length} recipient payment output(s)…` ) + const tx = normalizeBRC100ByteArray(paymentData.tx) + if (tx == null || tx.length === 0) { + throw new Error('Message payment transaction must be a non-empty BRC-100 byte array') + } const internalizeResult = await this.walletClient.internalizeAction({ - tx: paymentData.tx, + tx, outputs: recipientOutputs, description: paymentData.description ?? 'MessageBox recipient payment' }) @@ -2214,7 +2225,7 @@ export class MessageBoxClient { throw new Error('Message IDs array cannot be empty') } - Logger.log(`[MB CLIENT] Acknowledging messages ${JSON.stringify(messageIds)}…`) + Logger.log(`[MB CLIENT] Acknowledging messages ${stringifyBRC100(messageIds)}…`) let hosts: string[] = host != null ? [normalizeMessageBoxHost(host)] : [] if (hosts.length === 0) { @@ -2230,7 +2241,7 @@ export class MessageBoxClient { const res = await this.authFetch.fetch(messageBoxEndpoint(host, '/acknowledgeMessage'), { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ messageIds }) + body: stringifyBRC100({ messageIds }) }) if (!res.ok) throw new Error(`HTTP ${res.status}`) const data = await res.json() @@ -2300,7 +2311,7 @@ export class MessageBoxClient { const response = await this.authFetch.fetch(messageBoxEndpoint(finalHost, '/permissions/set'), { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ + body: stringifyBRC100({ messageBox: params.messageBox, recipientFee: params.recipientFee, ...(params.sender != null && { sender: params.sender }) @@ -2964,7 +2975,7 @@ export class MessageBoxClient { const response = await this.authFetch.fetch(messageBoxEndpoint(finalHost, '/registerDevice'), { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ + body: stringifyBRC100({ fcmToken: params.fcmToken.trim(), deviceId: params.deviceId?.trim() ?? undefined, platform: params.platform ?? undefined @@ -3125,7 +3136,7 @@ export class MessageBoxClient { satoshis: quote.deliveryFee, lockingScript, outputDescription: 'MessageBox server delivery fee', - customInstructions: JSON.stringify({ + customInstructions: stringifyBRC100({ derivationPrefix, derivationSuffix, recipientIdentityKey: quote.deliveryAgentIdentityKey @@ -3169,7 +3180,7 @@ export class MessageBoxClient { satoshis: quote.recipientFee, lockingScript, outputDescription: 'Recipient message fee', - customInstructions: JSON.stringify({ + customInstructions: stringifyBRC100({ derivationPrefix, derivationSuffix, recipientIdentityKey: recipient @@ -3196,12 +3207,13 @@ export class MessageBoxClient { this.originator ) - if (tx == null) { + const portableTx = toBRC100PortableByteArray(tx) + if (portableTx == null || portableTx.length === 0) { throw new Error('Failed to create payment transaction') } return { - tx, + tx: portableTx, outputs, description // labels @@ -3248,7 +3260,7 @@ export class MessageBoxClient { satoshis: deliveryFeeOnce, lockingScript, outputDescription: 'MessageBox server delivery fee (batch)', - customInstructions: JSON.stringify({ + customInstructions: stringifyBRC100({ derivationPrefix, derivationSuffix, recipientIdentityKey: serverIdentityKey @@ -3287,7 +3299,7 @@ export class MessageBoxClient { satoshis: q.recipientFee, lockingScript, outputDescription: `Recipient message fee (${r.slice(0, 8)}…)`, - customInstructions: JSON.stringify({ + customInstructions: stringifyBRC100({ derivationPrefix, derivationSuffix, recipientIdentityKey: r @@ -3314,8 +3326,11 @@ export class MessageBoxClient { this.originator ) - if (tx == null) throw new Error('Failed to create payment transaction') + const portableTx = toBRC100PortableByteArray(tx) + if (portableTx == null || portableTx.length === 0) { + throw new Error('Failed to create payment transaction') + } - return { tx, outputs, description } + return { tx: portableTx, outputs, description } } } diff --git a/packages/messaging/message-box-client/src/PeerPayClient.ts b/packages/messaging/message-box-client/src/PeerPayClient.ts index d8bb26b19..17c61a443 100644 --- a/packages/messaging/message-box-client/src/PeerPayClient.ts +++ b/packages/messaging/message-box-client/src/PeerPayClient.ts @@ -27,15 +27,13 @@ import { Base64String, OriginatorDomainNameStringUnder250Bytes, Brc29RemittanceModule, - createNonce + createNonce, + toBRC100PortableByteArray, + stringifyBRC100 } from '@bsv/sdk' import * as Logger from './Utils/logger.js' -function toNumberArray(tx: AtomicBEEF): number[] { - return Array.isArray(tx) ? tx : Array.from(tx) -} - function hexToBytes(hex: string): number[] { const matches = hex.match(/.{1,2}/g) return (matches ?? []).map(byte => Number.parseInt(byte, 16)) @@ -307,7 +305,7 @@ export class PeerPayClient extends MessageBoxClient { { recipient: payment.recipient, messageBox: this.messageBox, - body: JSON.stringify(paymentToken) + body: stringifyBRC100(paymentToken) }, hostOverride ) @@ -336,7 +334,7 @@ export class PeerPayClient extends MessageBoxClient { { recipient: payment.recipient, messageBox: this.messageBox, - body: JSON.stringify(paymentToken) + body: stringifyBRC100(paymentToken) }, overrideHost ) @@ -348,7 +346,7 @@ export class PeerPayClient extends MessageBoxClient { { recipient: payment.recipient, messageBox: this.messageBox, - body: JSON.stringify(paymentToken) + body: stringifyBRC100(paymentToken) }, overrideHost ) @@ -407,7 +405,12 @@ export class PeerPayClient extends MessageBoxClient { */ async acceptPayment(payment: IncomingPayment): Promise { try { - Logger.log(`[PP CLIENT] Processing payment: ${JSON.stringify(payment, null, 2)}`) + Logger.log(`[PP CLIENT] Processing payment: ${stringifyBRC100(payment, 2)}`) + + const transaction = toBRC100PortableByteArray(payment.token.transaction) + if (transaction == null || transaction.length === 0) { + throw new Error('Payment transaction must be a non-empty BRC-100 byte array') + } const acceptResult = await this.settlementModule.acceptSettlement( { @@ -418,7 +421,7 @@ export class PeerPayClient extends MessageBoxClient { derivationPrefix: payment.token.customInstructions.derivationPrefix, derivationSuffix: payment.token.customInstructions.derivationSuffix }, - transaction: toNumberArray(payment.token.transaction), + transaction, amountSatoshis: payment.token.amount, outputIndex: payment.token.outputIndex ?? STANDARD_PAYMENT_OUTPUT_INDEX } @@ -438,7 +441,7 @@ export class PeerPayClient extends MessageBoxClient { const paymentResult = acceptResult.receiptData?.internalizeResult Logger.log( - `[PP CLIENT] Payment internalized successfully: ${JSON.stringify(paymentResult, null, 2)}` + `[PP CLIENT] Payment internalized successfully: ${stringifyBRC100(paymentResult, 2)}` ) Logger.log(`[PP CLIENT] Acknowledging payment with messageId: ${payment.messageId}`) @@ -462,7 +465,7 @@ export class PeerPayClient extends MessageBoxClient { * @returns {Promise} Resolves when the payment is either acknowledged or refunded. */ async rejectPayment(payment: IncomingPayment): Promise { - Logger.log(`[PP CLIENT] Rejecting payment: ${JSON.stringify(payment, null, 2)}`) + Logger.log(`[PP CLIENT] Rejecting payment: ${stringifyBRC100(payment, 2)}`) if (payment.token.amount - 1000 < 1000) { Logger.log('[PP CLIENT] Payment amount too small after fee, just acknowledging.') @@ -662,7 +665,7 @@ export class PeerPayClient extends MessageBoxClient { { recipient: request.sender, messageBox: PAYMENT_REQUEST_RESPONSES_MESSAGEBOX, - body: JSON.stringify(response) + body: stringifyBRC100(response) }, hostOverride ) @@ -696,7 +699,7 @@ export class PeerPayClient extends MessageBoxClient { { recipient: request.sender, messageBox: PAYMENT_REQUEST_RESPONSES_MESSAGEBOX, - body: JSON.stringify(response) + body: stringifyBRC100(response) }, hostOverride ) @@ -758,7 +761,7 @@ export class PeerPayClient extends MessageBoxClient { { recipient: params.recipient, messageBox: PAYMENT_REQUESTS_MESSAGEBOX, - body: JSON.stringify(body) + body: stringifyBRC100(body) }, hostOverride ) @@ -978,7 +981,7 @@ export class PeerPayClient extends MessageBoxClient { { recipient: params.recipient, messageBox: PAYMENT_REQUESTS_MESSAGEBOX, - body: JSON.stringify(body) + body: stringifyBRC100(body) }, hostOverride ) diff --git a/packages/messaging/message-box-client/src/PeerTokenClient.ts b/packages/messaging/message-box-client/src/PeerTokenClient.ts index 996df0cd9..c71bd7801 100644 --- a/packages/messaging/message-box-client/src/PeerTokenClient.ts +++ b/packages/messaging/message-box-client/src/PeerTokenClient.ts @@ -23,7 +23,14 @@ import { TokenSourceRef, TokenAdapterContext } from './TokenSettlementAdapter.js' -import { WalletInterface, OriginatorDomainNameStringUnder250Bytes, createNonce } from '@bsv/sdk' +import { + WalletInterface, + OriginatorDomainNameStringUnder250Bytes, + createNonce, + normalizeBRC100ByteArray, + toBRC100PortableByteArray, + stringifyBRC100 +} from '@bsv/sdk' import * as Logger from './Utils/logger.js' @@ -128,12 +135,16 @@ export class PeerTokenClient extends MessageBoxClient { throw new Error(result.termination.message) } const { artifact } = result + const transaction = toBRC100PortableByteArray(artifact.transaction) + if (transaction == null || transaction.length === 0) { + throw new Error('Token settlement transaction must be a non-empty BRC-100 byte array') + } return { protocol: artifact.protocol, assetId: artifact.assetId, amount: artifact.amount, customInstructions: artifact.customInstructions, - transaction: artifact.transaction, + transaction, outputIndex: artifact.outputIndex, txid: artifact.txid } @@ -149,7 +160,7 @@ export class PeerTokenClient extends MessageBoxClient { { recipient: params.recipient, messageBox: this.messageBox, - body: JSON.stringify(token) + body: stringifyBRC100(token) }, hostOverride ?? this.tokenHost ) @@ -165,7 +176,7 @@ export class PeerTokenClient extends MessageBoxClient { { recipient: params.recipient, messageBox: this.messageBox, - body: JSON.stringify(token) + body: stringifyBRC100(token) }, host ) @@ -175,7 +186,7 @@ export class PeerTokenClient extends MessageBoxClient { { recipient: params.recipient, messageBox: this.messageBox, - body: JSON.stringify(token) + body: stringifyBRC100(token) }, host ) @@ -209,12 +220,16 @@ export class PeerTokenClient extends MessageBoxClient { async acceptToken(incoming: IncomingToken): Promise { try { const adapter = this.adapterFor(incoming.token.protocol) + const transaction = normalizeBRC100ByteArray(incoming.token.transaction) + if (transaction == null || transaction.length === 0) { + throw new Error('Token settlement transaction must be a non-empty BRC-100 byte array') + } const result = await adapter.acceptTokenSettlement( { sender: incoming.sender, settlement: { customInstructions: incoming.token.customInstructions, - transaction: incoming.token.transaction, + transaction, protocol: incoming.token.protocol, assetId: incoming.token.assetId, amount: incoming.token.amount, @@ -300,7 +315,7 @@ export class PeerTokenClient extends MessageBoxClient { { recipient: params.recipient, messageBox: TOKEN_REQUESTS_MESSAGEBOX, - body: JSON.stringify(body) + body: stringifyBRC100(body) }, hostOverride ?? this.tokenHost ) @@ -369,7 +384,7 @@ export class PeerTokenClient extends MessageBoxClient { { recipient: request.sender, messageBox: TOKEN_REQUEST_RESPONSES_MESSAGEBOX, - body: JSON.stringify(response) + body: stringifyBRC100(response) }, hostOverride ?? this.tokenHost ) @@ -395,7 +410,7 @@ export class PeerTokenClient extends MessageBoxClient { { recipient: request.sender, messageBox: TOKEN_REQUEST_RESPONSES_MESSAGEBOX, - body: JSON.stringify(response) + body: stringifyBRC100(response) }, hostOverride ?? this.tokenHost ) @@ -421,7 +436,7 @@ export class PeerTokenClient extends MessageBoxClient { { recipient: params.recipient, messageBox: TOKEN_REQUESTS_MESSAGEBOX, - body: JSON.stringify(body) + body: stringifyBRC100(body) }, hostOverride ?? this.tokenHost ) diff --git a/packages/messaging/message-box-client/src/RemittanceAdapter.ts b/packages/messaging/message-box-client/src/RemittanceAdapter.ts index 84d064414..5bc77feda 100644 --- a/packages/messaging/message-box-client/src/RemittanceAdapter.ts +++ b/packages/messaging/message-box-client/src/RemittanceAdapter.ts @@ -28,7 +28,7 @@ * ``` */ -import { PubKeyHex } from '@bsv/sdk' +import { PubKeyHex, stringifyBRC100 } from '@bsv/sdk' import type { CommsLayer as SdkCommsLayer, PeerMessage as SdkRemittancePeerMessage } from '@bsv/sdk' import type { MessageBoxClient } from './MessageBoxClient.js' import type { PeerMessage as MessageBoxPeerMessage } from './types.js' @@ -162,7 +162,7 @@ export class RemittanceAdapter implements SdkCommsLayer { private toBodyString(body: unknown): string { if (typeof body === 'string') return body try { - return JSON.stringify(body) ?? '' + return stringifyBRC100(body) } catch { return '' } diff --git a/packages/messaging/message-box-client/src/__tests/MessageBoxClientHardening.test.ts b/packages/messaging/message-box-client/src/__tests/MessageBoxClientHardening.test.ts index 1358b1da7..218d057f0 100644 --- a/packages/messaging/message-box-client/src/__tests/MessageBoxClientHardening.test.ts +++ b/packages/messaging/message-box-client/src/__tests/MessageBoxClientHardening.test.ts @@ -333,6 +333,26 @@ describe('MessageBoxClient hardening branches', () => { ) }) + it('normalizes typed and historical JSON wallet transactions in paid messages', async () => { + wallet.createAction.mockResolvedValueOnce({ tx: new Uint8Array([4, 5, 6]) }) + const typed = await (client as any).createMessagePayment(recipientA, { + recipientFee: 1, + deliveryFee: 0, + deliveryAgentIdentityKey: identityKey + }) + wallet.createAction.mockResolvedValueOnce({ + tx: JSON.parse(JSON.stringify(new Uint8Array([7, 8, 9]))) + } as never) + const historical = await (client as any).createMessagePayment(recipientA, { + recipientFee: 1, + deliveryFee: 0, + deliveryAgentIdentityKey: identityKey + }) + + expect(typed.tx).toEqual([4, 5, 6]) + expect(historical.tx).toEqual([7, 8, 9]) + }) + it('rejects a zero-fee payment and a wallet action without a transaction', async () => { await expect( (client as any).createMessagePayment(recipientA, { @@ -387,7 +407,7 @@ describe('MessageBoxClient hardening branches', () => { it('internalizes payments, decrypts envelopes, and sorts messages newest first', async () => { const payment = { - tx: [1, 2, 3], + tx: JSON.parse(JSON.stringify(new Uint8Array([1, 2, 3]))), outputs: [ { outputIndex: 0, @@ -436,6 +456,10 @@ describe('MessageBoxClient hardening branches', () => { expect(messages[0].body).toEqual({ plain: true }) expect(messages[1].body).toEqual({ secret: true }) expect(wallet.internalizeAction).toHaveBeenCalled() + expect(wallet.internalizeAction).toHaveBeenCalledWith( + expect.objectContaining({ tx: [1, 2, 3] }), + undefined + ) expect(wallet.decrypt).toHaveBeenCalled() }) diff --git a/packages/messaging/message-box-client/src/__tests/PeerPayClientUnit.test.ts b/packages/messaging/message-box-client/src/__tests/PeerPayClientUnit.test.ts index 976af0435..4f7774c7d 100644 --- a/packages/messaging/message-box-client/src/__tests/PeerPayClientUnit.test.ts +++ b/packages/messaging/message-box-client/src/__tests/PeerPayClientUnit.test.ts @@ -200,6 +200,48 @@ describe('PeerPayClient Unit Tests', () => { expect(mockWalletClient.internalizeAction).toHaveBeenCalled() expect(peerPayClient.acknowledgeMessage).toHaveBeenCalledWith({ messageIds: ['123'] }) }) + + it('recovers a Uint8Array transaction after JSON message transport', async () => { + mockWalletClient.internalizeAction.mockResolvedValue({ accepted: true }) + jest.spyOn(peerPayClient, 'acknowledgeMessage').mockResolvedValue('acknowledged') + const transaction = JSON.parse(JSON.stringify(new Uint8Array([1, 2, 3]))) + + await peerPayClient.acceptPayment({ + messageId: 'json-typed-array', + sender: 'senderKey', + token: { + customInstructions: { derivationPrefix: 'prefix', derivationSuffix: 'suffix' }, + transaction, + amount: 6 + } + }) + + expect(mockWalletClient.internalizeAction).toHaveBeenCalledWith( + expect.objectContaining({ tx: [1, 2, 3] }), + undefined + ) + expect(peerPayClient.acknowledgeMessage).toHaveBeenCalledWith({ + messageIds: ['json-typed-array'] + }) + }) + + it('rejects a non-contiguous transaction object without internalizing it', async () => { + const acknowledge = jest.spyOn(peerPayClient, 'acknowledgeMessage') + + const result = await peerPayClient.acceptPayment({ + messageId: 'malformed-object', + sender: 'senderKey', + token: { + customInstructions: { derivationPrefix: 'prefix', derivationSuffix: 'suffix' }, + transaction: { 1: 2 } as any, + amount: 6 + } + }) + + expect(result).toBe('Unable to receive payment!') + expect(mockWalletClient.internalizeAction).not.toHaveBeenCalled() + expect(acknowledge).not.toHaveBeenCalled() + }) }) // Test: rejectPayment diff --git a/packages/messaging/message-box-client/src/__tests/PeerTokenClientUnit.test.ts b/packages/messaging/message-box-client/src/__tests/PeerTokenClientUnit.test.ts index 4fd5d1f5b..917ee1b08 100644 --- a/packages/messaging/message-box-client/src/__tests/PeerTokenClientUnit.test.ts +++ b/packages/messaging/message-box-client/src/__tests/PeerTokenClientUnit.test.ts @@ -116,6 +116,22 @@ describe('PeerTokenClient Unit Tests', () => { } expect(ctx.dryRun).toBe(true) }) + + it('normalizes a current-wallet Uint8Array artifact for transport', async () => { + ;(adapter.buildTokenSettlement as jest.Mock).mockResolvedValue({ + action: 'settle', + artifact: { ...ARTIFACT, transaction: new Uint8Array([4, 5, 6]) } + } as never) + + const token = await client.createTokenToken({ + recipient, + protocol: 'stas', + source: SOURCE, + amount: '1000' + }) + + expect(token.transaction).toEqual([4, 5, 6]) + }) }) describe('sendToken', () => { @@ -166,6 +182,42 @@ describe('PeerTokenClient Unit Tests', () => { expect(ackSpy).toHaveBeenCalledWith(expect.objectContaining({ messageIds: ['msg-1'] })) expect(result).toMatchObject({ receiptData: { internalizeResult: 'ok' } }) }) + + it('recovers a historical numeric-key transaction before adapter dispatch', async () => { + const ackSpy = jest + .spyOn(client, 'acknowledgeMessage' as any) + .mockResolvedValue(undefined as never) + const transaction = JSON.parse(JSON.stringify(new Uint8Array([7, 8, 9]))) + + await client.acceptToken({ + messageId: 'msg-json-bytes', + sender: recipient, + token: { ...ARTIFACT, transaction } + }) + + expect(adapter.acceptTokenSettlement).toHaveBeenCalledWith( + expect.objectContaining({ + settlement: expect.objectContaining({ transaction: [7, 8, 9] }) + }), + expect.any(Object) + ) + expect(ackSpy).toHaveBeenCalled() + }) + + it('does not dispatch or acknowledge malformed transaction records', async () => { + const ackSpy = jest.spyOn(client, 'acknowledgeMessage' as any) + + await expect( + client.acceptToken({ + messageId: 'msg-bad-bytes', + sender: recipient, + token: { ...ARTIFACT, transaction: { 1: 2 } as never } + }) + ).resolves.toBe('Unable to receive token!') + + expect(adapter.acceptTokenSettlement).not.toHaveBeenCalled() + expect(ackSpy).not.toHaveBeenCalled() + }) }) describe('listIncomingTokens', () => { diff --git a/packages/messaging/message-box-client/src/__tests/RemittanceAdapter.test.ts b/packages/messaging/message-box-client/src/__tests/RemittanceAdapter.test.ts index 721493c12..4b58eb895 100644 --- a/packages/messaging/message-box-client/src/__tests/RemittanceAdapter.test.ts +++ b/packages/messaging/message-box-client/src/__tests/RemittanceAdapter.test.ts @@ -120,6 +120,25 @@ describe('RemittanceAdapter', () => { ]) }) + it('serializes typed settlement bytes in object message bodies as arrays', async () => { + const messageBox = { + getIdentityKey: jest.fn<() => Promise>().mockResolvedValue(myIdentityKey), + listMessages: jest.fn<() => Promise>().mockResolvedValue([ + { + messageId: 'm-bytes', + sender: senderKey, + body: { settlement: { transaction: new Uint8Array([1, 2, 3]) } } + } + ]) + } as unknown as MessageBoxClient + + const [result] = await new RemittanceAdapter(messageBox).listMessages({ + messageBox: 'remittance_inbox' + }) + + expect(JSON.parse(result.body)).toEqual({ settlement: { transaction: [1, 2, 3] } }) + }) + it('forwards live listener setup and normalizes inbound message shape', async () => { const onPaymentMessage = jest.fn() let forwardedListener: diff --git a/packages/middleware/auth-express-middleware/README.md b/packages/middleware/auth-express-middleware/README.md index 16a834242..cc740def0 100644 --- a/packages/middleware/auth-express-middleware/README.md +++ b/packages/middleware/auth-express-middleware/README.md @@ -5,6 +5,11 @@ BRC-104 HTTP transport. It handles the public handshake endpoint, verifies authenticated application requests, signs responses, and optionally exchanges verifiable certificates. +Version 2.2.2 preserves BRC-100 byte fields in handshake and buffered JSON +responses across number arrays, `Uint8Array`, and historical numeric-key JSON +objects. Generic signed application-body canonicalization remains unchanged so +old and new peers continue to verify the same bytes. + ## Requirements - Node.js 22 or newer diff --git a/packages/middleware/auth-express-middleware/package.json b/packages/middleware/auth-express-middleware/package.json index 38d9cdb7f..aa6fcde72 100644 --- a/packages/middleware/auth-express-middleware/package.json +++ b/packages/middleware/auth-express-middleware/package.json @@ -1,6 +1,6 @@ { "name": "@bsv/auth-express-middleware", - "version": "2.2.1", + "version": "2.2.2", "sideEffects": false, "engines": { "node": ">=22" @@ -84,7 +84,7 @@ "mime-types": "^3.0.2" }, "peerDependencies": { - "@bsv/sdk": "^2.1.6", + "@bsv/sdk": "^2.4.1", "@types/express": ">=4.17.0 <6", "express": ">=4.18.0 <6" }, diff --git a/packages/middleware/auth-express-middleware/src/__tests/ExpressTransportHardening.test.ts b/packages/middleware/auth-express-middleware/src/__tests/ExpressTransportHardening.test.ts index 80d41acde..b47a42fbe 100644 --- a/packages/middleware/auth-express-middleware/src/__tests/ExpressTransportHardening.test.ts +++ b/packages/middleware/auth-express-middleware/src/__tests/ExpressTransportHardening.test.ts @@ -641,7 +641,7 @@ describe('ExpressTransport hardening', () => { identityKey: IDENTITY_KEY, nonce: 'AQ==', yourNonce: 'Ag==', - signature: [1], + signature: new Uint8Array([1]) as any, payload: responsePayload(201, { 'x-bsv-result': '7' }, Utils.toArray('{"ok":true}', 'utf8')) }) @@ -958,7 +958,8 @@ describe('ExpressTransport hardening', () => { ) expect(originalSend).toHaveBeenCalledWith( expect.objectContaining({ - messageType: 'initialResponse' + messageType: 'initialResponse', + signature: [1] }) ) }) diff --git a/packages/middleware/auth-express-middleware/src/__tests/authMiddlewareHelpers.property.test.ts b/packages/middleware/auth-express-middleware/src/__tests/authMiddlewareHelpers.property.test.ts index d7f396c08..08e839c57 100644 --- a/packages/middleware/auth-express-middleware/src/__tests/authMiddlewareHelpers.property.test.ts +++ b/packages/middleware/auth-express-middleware/src/__tests/authMiddlewareHelpers.property.test.ts @@ -94,4 +94,24 @@ describe('auth transport serialization properties', () => { expect(convertValueToArray(false, {})).toEqual(Utils.toArray('false', 'utf8')) expect(convertValueToArray(1n, {})).toEqual(Utils.toArray('1', 'utf8')) }) + + test('keeps typed bytes portable without rewriting byte-like plain records', () => { + const historical = JSON.parse(JSON.stringify(new Uint8Array([4, 5, 6]))) + const encoded = convertValueToArray( + { + tx: new Uint8Array([1, 2, 3]), + payload: { transaction: historical }, + data: {}, + unrelated: historical + }, + {} + ) + + expect(JSON.parse(Utils.toUTF8(encoded))).toEqual({ + tx: [1, 2, 3], + payload: { transaction: historical }, + data: {}, + unrelated: historical + }) + }) }) diff --git a/packages/middleware/auth-express-middleware/src/authMiddlewareHelpers.ts b/packages/middleware/auth-express-middleware/src/authMiddlewareHelpers.ts index d5c075d65..7ff780038 100644 --- a/packages/middleware/auth-express-middleware/src/authMiddlewareHelpers.ts +++ b/packages/middleware/auth-express-middleware/src/authMiddlewareHelpers.ts @@ -1,5 +1,5 @@ import { Request } from 'express' -import { Utils } from '@bsv/sdk' +import { Utils, stringifyBRC100 } from '@bsv/sdk' export type LogLevel = 'debug' | 'info' | 'warn' | 'error' @@ -204,7 +204,7 @@ export function convertValueToArray( if (!responseHeaders['content-type']) { responseHeaders['content-type'] = 'application/json' } - return Utils.toArray(JSON.stringify(val), 'utf8') + return Utils.toArray(stringifyBRC100(val), 'utf8') } if (typeof val === 'number' || typeof val === 'boolean' || typeof val === 'bigint') { return Utils.toArray(val.toString(), 'utf8') diff --git a/packages/middleware/auth-express-middleware/src/index.ts b/packages/middleware/auth-express-middleware/src/index.ts index 7069ab4d6..aa565ce4b 100644 --- a/packages/middleware/auth-express-middleware/src/index.ts +++ b/packages/middleware/auth-express-middleware/src/index.ts @@ -6,6 +6,8 @@ import { SessionManager, PublicKey, Telemetry, + normalizeBRC100ByteFields, + stringifyBRC100, type AsyncSessionManager, type AuthMessage, type PubKeyHex, @@ -216,7 +218,10 @@ function validateHandshakeMessage(req: Request): { if (req.body === null || typeof req.body !== 'object' || Array.isArray(req.body)) { throw new AuthProtocolError('The BRC-104 handshake body must be an object.') } - const message = req.body as Partial + const message = normalizeBRC100ByteFields(req.body, [ + 'payload', + 'signature' + ]) as Partial if ( typeof message.messageType !== 'string' || message.messageType.length === 0 || @@ -317,7 +322,7 @@ class ResponseWriterWrapper { if (!this.headers['content-type']) { this.headers['content-type'] = 'application/json' } - this.setBody(Utils.toArray(JSON.stringify(data), 'utf8')) + this.setBody(Utils.toArray(stringifyBRC100(data), 'utf8')) return this } @@ -705,7 +710,7 @@ export class ExpressTransport implements Transport { responseHeaderCount: Object.keys(responseHeaders).length, messageType: message.messageType }) - res.send(message) + res.send(JSON.parse(stringifyBRC100(message))) this.removeNonGeneralHandle(message.yourNonce!, handle) } diff --git a/packages/sdk/CHANGELOG.md b/packages/sdk/CHANGELOG.md index 41217f85f..eed4f49c9 100644 --- a/packages/sdk/CHANGELOG.md +++ b/packages/sdk/CHANGELOG.md @@ -216,6 +216,10 @@ All notable changes to this project will be documented in this file. The format ### Added +- Add shared BRC-100 byte-boundary helpers that preserve valid `number[]` and + `Uint8Array` fast paths by identity, recover the numeric-key form emitted by + historical JSON transports, serialize typed arrays portably, and reject + sparse or invalid byte records. - Add the `teratestnet` lookup/SHIP preset, isolated TTN SLAP roots, and explicit identity/registry overlay routing without changing BRC-100's mainnet/testnet wallet-network response. @@ -299,12 +303,32 @@ All notable changes to this project will be documented in this file. The format ### Fixed +- Normalize both supported `CreateActionResult` AtomicBEEF representations, + `number[]` and Wallet Wire `Uint8Array`, into the portable byte-array form + emitted by BRC-29 remittance settlements. Binary wallet substrates no longer + fail payment creation with `brc29.invalid_tx`, and JSON transports receive + the same transaction shape as historical wallets. - Bind the default `HTTPWalletWire` fetch client to its global receiver so browser `WalletClient` auto-discovery can reach Cicada on port 3301 instead of falling through after an `Illegal invocation` error. - Preserve `HTTPWalletJSON` action wire compatibility by serializing - `Uint8Array` request fields as JSON arrays and normalizing the `tx` shape - returned by older wallet HTTP implementations. + `Uint8Array` request fields as JSON arrays and normalizing direct and nested + action transactions, list-output BEEF, cryptographic byte results, and + review-action error BEEF returned by older wallet HTTP implementations. +- Preserve BRC-100 byte fields across React Native JSON bridges in both + directions, including deferred-signing `signableTransaction.tx` results. + `WalletClient` no longer confuses its schemeless BRC-100 originator with a + browser message origin, originless native responses remain compatible, and + every auto-detected substrate has a bounded version probe so discovery cannot + deadlock on an unresponsive candidate. +- Preserve typed arrays portably in authenticated JSON request bodies, + remittance envelopes, overlay lookup queries, and serialized wallet review + errors. Historical numeric-key recovery is limited to typed protocol fields + such as authentication payload/signature and overlay output BEEF/context; + arbitrary JSON objects are never reinterpreted by name or shape. Existing + `number[]` payloads retain their zero-copy fast path. The reviewed + Vite, esbuild, and UMD raw ceilings advance by 2 kB to 740, 559, and 554 kB; + compressed ceilings remain unchanged. - Fix raw `BeefTx` transaction IDs to use canonical display byte order and invalidate cached BEEF serialization when public transaction-list state changes. diff --git a/packages/sdk/README.md b/packages/sdk/README.md index 406b01784..5214700b1 100644 --- a/packages/sdk/README.md +++ b/packages/sdk/README.md @@ -10,6 +10,12 @@ Welcome to the BSV Blockchain Libraries Project, the comprehensive TypeScript SD For application-to-wallet integrations, the SDK exposes the BRC-100 `WalletClient` interface. BSV Desktop and BSV Browser are the BSV Association reference implementations for this interface; vendor distributions such as Babbage's Metanet Desktop / Metanet Explorer and Hudos Browser can implement the same interface with their own branding and service defaults. +The BRC-100 `CreateActionResult` permits AtomicBEEF as either `number[]` or +`Uint8Array`. SDK BRC-29 remittance accepts both wallet representations and +emits a portable `number[]` settlement artifact so HTTP, WebSocket, Message Box, +and JSON transports preserve identical transaction bytes. The same boundary +protects overlay lookup queries and JSON BEEF responses. + ## Table of Contents 1. [Objective](#objective) diff --git a/packages/sdk/browser-budget.json b/packages/sdk/browser-budget.json index 1d7fa9a41..b1c0b6ae3 100644 --- a/packages/sdk/browser-budget.json +++ b/packages/sdk/browser-budget.json @@ -16,12 +16,12 @@ "prohibitedExports": [], "maximumBytes": { "vite": { - "raw": 738000, + "raw": 742000, "gzip": 185000, "brotli": 150000 }, "esbuild": { - "raw": 557000, + "raw": 560000, "gzip": 168000, "brotli": 140000 } @@ -30,7 +30,7 @@ "path": "dist/umd/bundle.js", "global": "bsv", "maximumBytes": { - "raw": 552000, + "raw": 555000, "gzip": 162000, "brotli": 136000 } diff --git a/packages/sdk/package.json b/packages/sdk/package.json index cb0dd9be3..f62c5cce4 100644 --- a/packages/sdk/package.json +++ b/packages/sdk/package.json @@ -1,6 +1,6 @@ { "name": "@bsv/sdk", - "version": "2.4.0", + "version": "2.4.1", "sideEffects": false, "engines": { "node": ">=22" @@ -740,7 +740,7 @@ "test": "pnpm build && jest --watchman=false", "test:watch": "pnpm build && jest --watch", "test:coverage": "pnpm build && jest --coverage --watchman=false", - "test:property": "jest --runInBand --watchman=false --runTestsByPath src/primitives/__tests/utils.property.test.ts src/auth/clients/__tests__/AuthFetch.property.test.ts", + "test:property": "jest --runInBand --watchman=false --runTestsByPath src/primitives/__tests/utils.property.test.ts src/wallet/__tests/BRC100ByteEncoding.property.test.ts src/auth/clients/__tests__/AuthFetch.property.test.ts", "test:resource": "pnpm build && node ../../scripts/run-governed-test.mjs resource src/primitives/__tests/AESGCM.man.test.ts", "format:check": "pnpm --workspace-root exec prettier --check \"packages/sdk/README.md\" \"packages/sdk/package.json\" \"packages/sdk/*.{js,json,ts}\"", "lint:ci": "pnpm lint", diff --git a/packages/sdk/src/__tests__/BRC100OverlayTransport.test.ts b/packages/sdk/src/__tests__/BRC100OverlayTransport.test.ts new file mode 100644 index 000000000..f9f0e1881 --- /dev/null +++ b/packages/sdk/src/__tests__/BRC100OverlayTransport.test.ts @@ -0,0 +1,55 @@ +import { HTTPSOverlayLookupFacilitator } from '../overlay-tools/LookupResolver' + +describe('overlay lookup BRC-100 byte compatibility', () => { + it('keeps typed query bytes portable across the JSON request boundary', async () => { + const mockFetch = jest.fn().mockResolvedValue({ + ok: true, + headers: { get: () => 'application/json' }, + json: async () => ({ type: 'output-list', outputs: [] }) + }) + const facilitator = new HTTPSOverlayLookupFacilitator(mockFetch, true) + + await facilitator.lookup('http://host', { + service: 'ls_test', + query: { payload: new Uint8Array([1, 2, 255]) } + }) + + expect(JSON.parse(mockFetch.mock.calls[0][1].body)).toEqual({ + service: 'ls_test', + query: { payload: [1, 2, 255] } + }) + }) + + it('recovers historical numeric-key BEEF from a JSON response', async () => { + const mockFetch = jest.fn().mockResolvedValue({ + ok: true, + headers: { get: () => 'application/json' }, + json: async () => ({ + type: 'output-list', + outputs: [{ beef: { 0: 1, 1: 2, 2: 255 }, outputIndex: 0 }] + }) + }) + const facilitator = new HTTPSOverlayLookupFacilitator(mockFetch, true) + + await expect( + facilitator.lookup('http://host', { service: 'ls_test', query: {} }) + ).resolves.toEqual({ + type: 'output-list', + outputs: [{ beef: [1, 2, 255], outputIndex: 0 }] + }) + }) + + it('preserves byte-like objects inside freeform lookup results', async () => { + const result = { data: { 0: 1, 1: 2 }, tx: {}, payload: { 0: 3 } } + const mockFetch = jest.fn().mockResolvedValue({ + ok: true, + headers: { get: () => 'application/json' }, + json: async () => ({ type: 'freeform', result }) + }) + const facilitator = new HTTPSOverlayLookupFacilitator(mockFetch, true) + + await expect( + facilitator.lookup('http://host', { service: 'ls_test', query: {} }) + ).resolves.toEqual({ type: 'freeform', result }) + }) +}) diff --git a/packages/sdk/src/auth/clients/AuthFetch.ts b/packages/sdk/src/auth/clients/AuthFetch.ts index 41e588f7f..44ecab310 100644 --- a/packages/sdk/src/auth/clients/AuthFetch.ts +++ b/packages/sdk/src/auth/clients/AuthFetch.ts @@ -7,6 +7,7 @@ import { OriginatorDomainNameStringUnder250Bytes, WalletInterface } from '../../wallet/Wallet.interfaces.js' +import { stringifyBRC100 } from '../../wallet/BRC100ByteEncoding.js' import { createNonce } from '../utils/createNonce.js' import { Peer } from '../Peer.js' import { SimplifiedFetchTransport } from '../transports/SimplifiedFetchTransport.js' @@ -871,7 +872,7 @@ export class AuthFetch { private describeSerializableRequestBody(body: any): RequestBodySummary { try { - const serialized = JSON.stringify(body) + const serialized = stringifyBRC100(body) if (typeof serialized === 'string') { return { type: 'object', byteLength: Utils.toArray(serialized, 'utf8').length } } @@ -1048,7 +1049,7 @@ export class AuthFetch { } // 8. Plain object JSON body - if (typeof body === 'object') return Utils.toArray(JSON.stringify(body), 'utf8') + if (typeof body === 'object') return Utils.toArray(stringifyBRC100(body), 'utf8') // 9. Fallback throw new Error('Unsupported body type in this SimplifiedFetch implementation.') diff --git a/packages/sdk/src/auth/clients/__tests__/AuthFetch.additional.test.ts b/packages/sdk/src/auth/clients/__tests__/AuthFetch.additional.test.ts index 6b600ac2f..753a8e469 100644 --- a/packages/sdk/src/auth/clients/__tests__/AuthFetch.additional.test.ts +++ b/packages/sdk/src/auth/clients/__tests__/AuthFetch.additional.test.ts @@ -734,6 +734,16 @@ describe('AuthFetch.normalizeBodyToNumberArray (private)', () => { const result = await (authFetch as any).normalizeBodyToNumberArray(obj) expect(result.length).toBeGreaterThan(0) }) + + it('preserves nested wallet bytes in authenticated JSON request bodies', async () => { + const result = await (authFetch as any).normalizeBodyToNumberArray({ + signableTransaction: { tx: new Uint8Array([1, 2, 3]) } + }) + + expect(JSON.parse(Utils.toUTF8(result))).toEqual({ + signableTransaction: { tx: [1, 2, 3] } + }) + }) }) // --------------------------------------------------------------------------- diff --git a/packages/sdk/src/auth/transports/SimplifiedFetchTransport.ts b/packages/sdk/src/auth/transports/SimplifiedFetchTransport.ts index 57529b176..dd52535f1 100644 --- a/packages/sdk/src/auth/transports/SimplifiedFetchTransport.ts +++ b/packages/sdk/src/auth/transports/SimplifiedFetchTransport.ts @@ -2,6 +2,7 @@ // @ts-expect-error import { AuthMessage, RequestedCertificateSet, Transport } from '../types.js' import * as Utils from '../../primitives/utils.js' +import { normalizeBRC100ByteFields, stringifyBRC100 } from '../../wallet/BRC100ByteEncoding.js' const defaultFetch: typeof fetch = typeof globalThis !== 'undefined' && typeof globalThis.fetch === 'function' @@ -59,7 +60,7 @@ export class SimplifiedFetchTransport implements Transport { return await this.fetchClient(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(message) + body: stringifyBRC100(message) }) } catch (error) { throw this.createNetworkError(url, error) @@ -79,7 +80,12 @@ export class SimplifiedFetchTransport implements Transport { const body = Array.from(new Uint8Array(await response.arrayBuffer())) throw this.createUnauthenticatedResponseError(url, response, body) } - await this.onDataCallback!((await response.json()) as AuthMessage) + await this.onDataCallback!( + normalizeBRC100ByteFields(await response.json(), [ + 'payload', + 'signature' + ]) as AuthMessage + ) if (message.messageType === 'initialRequest') resolve() } catch (error) { reject(error) diff --git a/packages/sdk/src/auth/transports/__tests__/SimplifiedFetchTransport.additional.test.ts b/packages/sdk/src/auth/transports/__tests__/SimplifiedFetchTransport.additional.test.ts index 016a98755..f602ae1bd 100644 --- a/packages/sdk/src/auth/transports/__tests__/SimplifiedFetchTransport.additional.test.ts +++ b/packages/sdk/src/auth/transports/__tests__/SimplifiedFetchTransport.additional.test.ts @@ -227,6 +227,40 @@ describe('SimplifiedFetchTransport send — non-general auth message', () => { expect(received).toHaveLength(1) }) + test('normalizes typed and historical JSON byte fields in both directions', async () => { + const historicalBytes = (bytes: number[]) => JSON.parse(JSON.stringify(new Uint8Array(bytes))) + const responseBody = JSON.stringify({ + version: '0.1', + messageType: 'initialRequest', + identityKey: 'server-key', + payload: historicalBytes([4, 5]), + signature: historicalBytes([6, 7]) + }) + const mockFetch = jest + .fn<() => any>() + .mockResolvedValue( + new Response(responseBody, { status: 200, headers: { 'Content-Type': 'application/json' } }) + ) as any + const transport = new SimplifiedFetchTransport('https://api.example.com', mockFetch) + const received: AuthMessage[] = [] + await transport.onData(async msg => { + received.push(msg) + }) + + await transport.send( + makeAuthMessage('initialRequest', { + payload: new Uint8Array([1, 2]) as any, + signature: new Uint8Array([3]) as any + }) + ) + + const request = JSON.parse(mockFetch.mock.calls[0][1]?.body as string) + expect(request.payload).toEqual([1, 2]) + expect(request.signature).toEqual([3]) + expect(received[0]?.payload).toEqual([4, 5]) + expect(received[0]?.signature).toEqual([6, 7]) + }) + test('non-ok response on auth endpoint throws unauthenticated error', async () => { const mockFetch = jest .fn<() => any>() diff --git a/packages/sdk/src/overlay-tools/LookupResolver.ts b/packages/sdk/src/overlay-tools/LookupResolver.ts index 3232c54d8..66f44f1bc 100644 --- a/packages/sdk/src/overlay-tools/LookupResolver.ts +++ b/packages/sdk/src/overlay-tools/LookupResolver.ts @@ -4,6 +4,7 @@ import OverlayAdminTokenTemplate from './OverlayAdminTokenTemplate.js' import * as Utils from '../primitives/utils.js' import { getOverlayHostReputationTracker, HostReputationTracker } from './HostReputationTracker.js' import { Telemetry, TelemetryConfig } from '../telemetry/Telemetry.js' +import { normalizeBRC100ByteFields, stringifyBRC100 } from '../wallet/BRC100ByteEncoding.js' const defaultFetch: typeof fetch = typeof globalThis !== 'undefined' && typeof globalThis.fetch === 'function' @@ -407,7 +408,7 @@ export class HTTPSOverlayLookupFacilitator implements OverlayLookupFacilitator { 'Content-Type': 'application/json', 'X-Aggregation': 'yes' }, - body: JSON.stringify({ service: question.service, query: question.query }), + body: stringifyBRC100({ service: question.service, query: question.query }), signal } const response: Response = await this.fetchClient(`${url}/lookup`, fco) @@ -428,7 +429,19 @@ export class HTTPSOverlayLookupFacilitator implements OverlayLookupFacilitator { if (isOctetStream(response.headers.get('content-type'))) { return await this.parseOctetStreamLookup(response) } - return await response.json() + const answer = await response.json() + if ( + answer != null && + typeof answer === 'object' && + !Array.isArray(answer) && + answer.type === 'output-list' && + Array.isArray(answer.outputs) + ) { + for (const output of answer.outputs) { + normalizeBRC100ByteFields(output, ['beef', 'context']) + } + } + return answer } /** Parse the aggregated octet-stream lookup response into an output-list LookupAnswer. */ diff --git a/packages/sdk/src/remittance/RemittanceManager.ts b/packages/sdk/src/remittance/RemittanceManager.ts index 29ac37f08..20417d0ff 100644 --- a/packages/sdk/src/remittance/RemittanceManager.ts +++ b/packages/sdk/src/remittance/RemittanceManager.ts @@ -21,6 +21,7 @@ import type { CommsLayer } from './CommsLayer.js' import type { IdentityLayer } from './IdentityLayer.js' import type { RemittanceModule } from './RemittanceModule.js' import { OriginatorDomainNameStringUnder250Bytes, PubKeyHex, WalletInterface } from '../wallet/Wallet.interfaces.js' +import { stringifyBRC100 } from '../wallet/BRC100ByteEncoding.js' import { toBase64, toSafeString } from '../primitives/utils.js' import Random from '../primitives/Random.js' @@ -942,7 +943,7 @@ export class RemittanceManager { } private async sendEnvelope (recipient: PubKeyHex, env: RemittanceEnvelope, hostOverride?: string): Promise { - const body = JSON.stringify(env) + const body = stringifyBRC100(env) // Prefer live if available. if (typeof this.comms.sendLiveMessage === 'function') { diff --git a/packages/sdk/src/remittance/__tests/BasicBRC29.test.ts b/packages/sdk/src/remittance/__tests/BasicBRC29.test.ts index cac39458d..e39e3cb2f 100644 --- a/packages/sdk/src/remittance/__tests/BasicBRC29.test.ts +++ b/packages/sdk/src/remittance/__tests/BasicBRC29.test.ts @@ -69,6 +69,39 @@ describe('Brc29RemittanceModule', () => { expect(createArgs.outputs[0].outputDescription).toBe('Test output') }) + it.each([ + ['direct tx', { tx: new Uint8Array([1, 2, 3]) }], + [ + 'signable transaction', + { signableTransaction: { tx: new Uint8Array([4, 5, 6]), reference: 'cmVm' } } + ] + ])('normalizes a Wallet Wire Uint8Array from %s into a portable settlement', async (_case, action) => { + const wallet = { + getPublicKey: jest.fn(async () => ({ publicKey: '02deadbeef' })), + createAction: jest.fn(async () => action) + } as unknown as WalletInterface + + const module = new Brc29RemittanceModule({ + nonceProvider: { + createNonce: jest.fn().mockResolvedValueOnce('prefix').mockResolvedValueOnce('suffix') + }, + lockingScriptProvider: { + pubKeyToP2PKHLockingScript: jest.fn(async () => '76a914deadbeef88ac') + } + }) + + const result = await module.buildSettlement( + { threadId: 'thread-1', option: { amountSatoshis: 1000, payee: 'payee-key' } }, + makeContext(wallet) + ) + + expect(result.action).toBe('settle') + if (result.action !== 'settle') return + expect(Array.isArray(result.artifact.transaction)).toBe(true) + expect(result.artifact.transaction).toEqual(Array.from(action.tx ?? action.signableTransaction!.tx)) + expect(JSON.parse(JSON.stringify(result.artifact.transaction))).toEqual(result.artifact.transaction) + }) + it('terminates on invalid amounts for unsolicited settlements', async () => { const wallet = { getPublicKey: jest.fn(async () => ({ publicKey: '02deadbeef' })), diff --git a/packages/sdk/src/remittance/__tests/RemittanceManager.test.ts b/packages/sdk/src/remittance/__tests/RemittanceManager.test.ts index b7b74b58b..a561c1059 100644 --- a/packages/sdk/src/remittance/__tests/RemittanceManager.test.ts +++ b/packages/sdk/src/remittance/__tests/RemittanceManager.test.ts @@ -133,6 +133,46 @@ const waitForKind = async ( } describe('RemittanceManager base flows', () => { + it('serializes typed module bytes while preserving byte-like opaque module data', async () => { + const bus = new MessageBus() + const applicationData = { data: { 0: 1, 1: 2 }, tx: {}, signature: { 0: 3 } } + const module: RemittanceModule< + { transaction: Uint8Array; applicationData: typeof applicationData }, + {}, + {} + > = { + id: 'byte-module', + name: 'Byte Module', + allowUnsolicitedSettlements: false, + createOption: async () => ({ transaction: new Uint8Array([1, 2, 3]), applicationData }), + buildSettlement: async () => ({ action: 'settle', artifact: {} }), + acceptSettlement: async () => ({ action: 'accept', receiptData: {} }) + } + const maker = new RemittanceManager( + { remittanceModules: [module], threadIdFactory: makeThreadIdFactory() }, + makeWallet('maker-key'), + new TestComms('maker-key', bus) + ) + + await maker.sendInvoice('taker-key', makeInvoiceInput()) + + const envelope = parseEnvelope(bus.list('taker-key', 'remittance_inbox')[0]) + expect((envelope.payload as any).options['byte-module'].transaction).toEqual([1, 2, 3]) + expect((envelope.payload as any).options['byte-module'].applicationData).toEqual( + applicationData + ) + + const taker = new RemittanceManager( + { remittanceModules: [module] }, + makeWallet('taker-key'), + new TestComms('taker-key', bus) + ) + await taker.syncThreads() + const receivedOption = taker.getThreadOrThrow(envelope.threadId).invoice?.options['byte-module'] + expect(receivedOption).toBeDefined() + expect((receivedOption as any).applicationData).toEqual(applicationData) + }) + it('processes an invoice, settlement, and receipt end-to-end', async () => { const bus = new MessageBus() const moduleProcessReceipt = jest.fn() diff --git a/packages/sdk/src/remittance/modules/BasicBRC29.ts b/packages/sdk/src/remittance/modules/BasicBRC29.ts index cc5dff236..68710eabf 100644 --- a/packages/sdk/src/remittance/modules/BasicBRC29.ts +++ b/packages/sdk/src/remittance/modules/BasicBRC29.ts @@ -10,6 +10,7 @@ import type { import { createNonce } from '../../auth/utils/createNonce.js' import P2PKH from '../../script/templates/P2PKH.js' import PublicKey from '../../primitives/PublicKey.js' +import { toBRC100PortableByteArray } from '../../wallet/BRC100ByteEncoding.js' /** * BRC-29-like payment option terms. @@ -62,7 +63,11 @@ export interface Brc29ReceiptData { } export interface NonceProvider { - createNonce: (wallet: WalletInterface, scope: WalletCounterparty, originator?: unknown) => Promise + createNonce: ( + wallet: WalletInterface, + scope: WalletCounterparty, + originator?: unknown + ) => Promise } export interface LockingScriptProvider { @@ -132,8 +137,11 @@ export interface Brc29RemittanceModuleConfig { * - payee internalizes the tx output using wallet.internalizeAction * - optional rejection can include a refund token embedded in the termination details */ -export class Brc29RemittanceModule - implements RemittanceModule { +export class Brc29RemittanceModule implements RemittanceModule< + Brc29OptionTerms, + Brc29SettlementArtifact, + Brc29ReceiptData +> { readonly id: RemittanceOptionId = 'brc29.p2pkh' readonly name = 'BSV (BRC-29 derived P2PKH)' readonly allowUnsolicitedSettlements = true @@ -162,7 +170,7 @@ export class Brc29RemittanceModule if (legacyInternalizeProtocol === 'basket insertion') { throw new TypeError( 'BRC-29 settlements cannot be internalized as basket insertions. ' + - 'Use wallet payment for spendable wallet balance, or implement a separate custom-output protocol with insertionRemittance.' + 'Use wallet payment for spendable wallet balance, or implement a separate custom-output protocol with insertionRemittance.' ) } this.internalizeProtocol = 'wallet payment' @@ -173,7 +181,10 @@ export class Brc29RemittanceModule async buildSettlement( args: { threadId: string; option: Brc29OptionTerms; note?: string }, ctx: ModuleContext - ): Promise<{ action: 'settle'; artifact: Brc29SettlementArtifact } | { action: 'terminate'; termination: Termination }> { + ): Promise< + | { action: 'settle'; artifact: Brc29SettlementArtifact } + | { action: 'terminate'; termination: Termination } + > { const { wallet, originator } = ctx let option: Brc29OptionTerms @@ -206,7 +217,10 @@ export class Brc29RemittanceModule ) if (typeof publicKey !== 'string' || publicKey.trim() === '') { - return terminate('brc29.public_key_missing', 'Failed to derive payee public key for BRC-29 settlement.') + return terminate( + 'brc29.public_key_missing', + 'Failed to derive payee public key for BRC-29 settlement.' + ) } const lockingScript = await this.lockingScriptProvider.pubKeyToP2PKHLockingScript(publicKey) @@ -243,15 +257,19 @@ export class Brc29RemittanceModule if (tx == null) { return terminate('brc29.missing_tx', 'wallet.createAction did not return a transaction.') } - if (!isAtomicBeef(tx)) { - return terminate('brc29.invalid_tx', 'wallet.createAction returned an invalid transaction payload.') + const transaction = toPortableTransaction(tx) + if (transaction == null) { + return terminate( + 'brc29.invalid_tx', + 'wallet.createAction returned an invalid transaction payload.' + ) } return { action: 'settle', artifact: { customInstructions: { derivationPrefix, derivationSuffix }, - transaction: tx, + transaction, amountSatoshis: option.amountSatoshis, outputIndex: option.outputIndex ?? 0 } @@ -265,7 +283,10 @@ export class Brc29RemittanceModule async acceptSettlement( args: { threadId: string; settlement: Brc29SettlementArtifact; sender: PubKeyHex }, ctx: ModuleContext - ): Promise<{ action: 'accept'; receiptData?: Brc29ReceiptData } | { action: 'terminate'; termination: Termination }> { + ): Promise< + | { action: 'accept'; receiptData?: Brc29ReceiptData } + | { action: 'terminate'; termination: Termination } + > { const { wallet, originator } = ctx const origin = originator as OriginatorDomainNameStringUnder250Bytes | undefined try { @@ -294,12 +315,19 @@ export class Brc29RemittanceModule return { action: 'accept', receiptData: { internalizeResult } } } catch (error) { const message = error instanceof Error ? error.message : String(error) - return terminate('brc29.internalize_failed', `Failed to internalize BRC-29 settlement: ${message}`) + return terminate( + 'brc29.internalize_failed', + `Failed to internalize BRC-29 settlement: ${message}` + ) } } } -function terminate(code: string, message: string, details?: unknown): { action: 'terminate'; termination: Termination } { +function terminate( + code: string, + message: string, + details?: unknown +): { action: 'terminate'; termination: Termination } { return { action: 'terminate', termination: { code, message, details } } } @@ -321,12 +349,19 @@ function ensureValidOption(option: Brc29OptionTerms): Brc29OptionTerms { throw new Error('BRC-29 option protocolID must be a tuple [number, string]') } const [protocolNumber, protocolString] = protocolID - if (!Number.isInteger(protocolNumber) || protocolNumber < 0 || !isNonEmptyString(protocolString)) { + if ( + !Number.isInteger(protocolNumber) || + protocolNumber < 0 || + !isNonEmptyString(protocolString) + ) { throw new Error('BRC-29 option protocolID must be a tuple [number, string]') } } const labels = (option as Brc29OptionTerms).labels - if (labels != null && (!Array.isArray(labels) || labels.some((label) => !isNonEmptyString(label)))) { + if ( + labels != null && + (!Array.isArray(labels) || labels.some(label => !isNonEmptyString(label))) + ) { throw new Error('BRC-29 option labels must be a list of non-empty strings') } const description = (option as Brc29OptionTerms).description @@ -344,7 +379,10 @@ function ensureValidSettlement(settlement: Brc29SettlementArtifact): Brc29Settle if (instructions == null || typeof instructions !== 'object') { throw new Error('BRC-29 settlement requires customInstructions') } - if (!isNonEmptyString(instructions.derivationPrefix) || !isNonEmptyString(instructions.derivationSuffix)) { + if ( + !isNonEmptyString(instructions.derivationPrefix) || + !isNonEmptyString(instructions.derivationSuffix) + ) { throw new Error('BRC-29 settlement derivation values are required') } const amountSatoshis = settlement.amountSatoshis @@ -355,15 +393,16 @@ function ensureValidSettlement(settlement: Brc29SettlementArtifact): Brc29Settle if (outputIndex != null && (!Number.isInteger(outputIndex) || outputIndex < 0)) { throw new Error('BRC-29 settlement outputIndex must be a non-negative integer') } - if (!isAtomicBeef(settlement.transaction)) { + const transaction = toPortableTransaction(settlement.transaction) + if (transaction == null) { throw new Error('BRC-29 settlement transaction must be a non-empty byte array') } - return settlement + return transaction === settlement.transaction ? settlement : { ...settlement, transaction } } -function isAtomicBeef(tx: unknown): tx is number[] { - if (!Array.isArray(tx) || tx.length === 0) return false - return tx.every((byte) => Number.isInteger(byte) && byte >= 0 && byte <= 255) +function toPortableTransaction(tx: unknown): number[] | undefined { + const bytes = toBRC100PortableByteArray(tx) + return bytes != null && bytes.length > 0 ? bytes : undefined } function isNonEmptyString(value: unknown): value is string { diff --git a/packages/sdk/src/wallet/BRC100ByteEncoding.ts b/packages/sdk/src/wallet/BRC100ByteEncoding.ts new file mode 100644 index 000000000..6777945b3 --- /dev/null +++ b/packages/sdk/src/wallet/BRC100ByteEncoding.ts @@ -0,0 +1,188 @@ +import type { AtomicBEEF } from './Wallet.interfaces.js' + +const hasOwn = Object.prototype.hasOwnProperty + +const walletByteFieldNames = new Set([ + 'BEEF', + 'atomicBEEF', + 'beef', + 'ciphertext', + 'competingBeef', + 'data', + 'encryptedLinkage', + 'encryptedLinkageProof', + 'hashToDirectlySign', + 'hashToDirectlyVerify', + 'hmac', + 'inputBEEF', + 'payload', + 'plaintext', + 'signature', + 'transaction', + 'tx' +]) + +function isUint8Array(value: unknown): value is Uint8Array { + if (value == null || typeof value !== 'object' || typeof ArrayBuffer === 'undefined') return false + return ( + ArrayBuffer.isView(value) && Object.prototype.toString.call(value) === '[object Uint8Array]' + ) +} + +function isByte(value: unknown): value is number { + return Number.isInteger(value) && (value as number) >= 0 && (value as number) <= 255 +} + +function isUnsupportedBinaryView(value: unknown): boolean { + return ( + value != null && + typeof value === 'object' && + typeof ArrayBuffer !== 'undefined' && + (ArrayBuffer.isView(value) || Object.prototype.toString.call(value) === '[object ArrayBuffer]') + ) +} + +function normalizeByteNumberArray(value: unknown[]): number[] | undefined { + for (let i = 0; i < value.length; i++) { + if (!hasOwn.call(value, i) || !isByte(value[i])) return undefined + } + return value as number[] +} + +function normalizeHistoricalByteObject(value: unknown): number[] | undefined { + if (value == null || typeof value !== 'object') return undefined + try { + const keys = Object.keys(value) + // JSON.stringify(new Uint8Array()) and a legitimate empty JSON object are + // indistinguishable. Require at least one byte for historical recovery; + // callers can represent an intentional empty byte sequence as [] instead. + if (keys.length === 0) return undefined + const bytes: number[] = [] + for (let i = 0; i < keys.length; i++) { + if (keys[i] !== String(i)) return undefined + const byte = (value as Record)[keys[i]] + if (!isByte(byte)) return undefined + bytes.push(byte) + } + return bytes + } catch { + return undefined + } +} + +/** + * Normalizes the runtime representations used for BRC-100 byte arrays. + * + * Healthy `number[]` and `Uint8Array` values are returned by identity so the + * common path does not allocate. The fallback recovers the contiguous + * numeric-key object produced by `JSON.stringify(new Uint8Array(...))` in + * historical JSON transports. Invalid, sparse, or non-byte input is rejected. + */ +export function normalizeBRC100ByteArray(value: unknown): AtomicBEEF | undefined { + if (isUint8Array(value)) return value + if (isUnsupportedBinaryView(value)) return undefined + if (Array.isArray(value)) return normalizeByteNumberArray(value) + return normalizeHistoricalByteObject(value) +} + +/** Convert a valid BRC-100 byte array to the portable JSON `number[]` form. */ +export function toBRC100PortableByteArray(value: unknown): number[] | undefined { + const bytes = normalizeBRC100ByteArray(value) + if (bytes == null) return undefined + return Array.isArray(bytes) ? bytes : Array.from(bytes) +} + +/** JSON replacer that preserves `Uint8Array` values as portable JSON arrays. */ +export function brc100JsonReplacer( + this: Record, + key: string, + value: unknown +): unknown { + // Buffer.toJSON runs before a replacer. Inspect the holder so Node Buffers + // retain the same portable array contract as browser Uint8Array values. + const original = this == null ? undefined : this[key] + // Serialization may safely identify real typed arrays, but it must never + // reinterpret a plain numeric-key object. Such objects are valid arbitrary + // application JSON and are indistinguishable from historical Uint8Array + // damage once they have been parsed. Recovery belongs at typed boundaries. + if (isUint8Array(original)) return Array.from(original) + return isUint8Array(value) ? Array.from(value) : value +} + +/** Serialize a BRC-100 payload without allowing typed byte arrays to become objects. */ +export function stringifyBRC100(value: unknown, space?: string | number): string { + const serialized = JSON.stringify(value, brc100JsonReplacer, space) + if (serialized === undefined) { + throw new TypeError('BRC-100 JSON payload is not serializable') + } + return serialized +} + +/** + * Repairs byte arrays only in explicitly selected own fields of one protocol + * object. This non-recursive helper is for envelopes that also contain opaque + * application data, where field-name-based traversal would be destructive. + */ +export function normalizeBRC100ByteFields(value: T, fieldNames: readonly string[]): T { + if (value == null || typeof value !== 'object' || Array.isArray(value)) return value + const record = value as Record + for (const fieldName of fieldNames) { + if (!hasOwn.call(record, fieldName)) continue + const fieldValue = record[fieldName] + // At a schema-owned byte field, an empty JSON object can only be the + // historical encoding of an empty Uint8Array. Generic and recursive + // normalization deliberately leave the same ambiguous shape untouched. + const bytes = + fieldValue != null && + typeof fieldValue === 'object' && + !Array.isArray(fieldValue) && + !isUint8Array(fieldValue) && + Object.keys(fieldValue).length === 0 + ? [] + : normalizeBRC100ByteArray(fieldValue) + if (bytes != null) record[fieldName] = bytes + } + return value +} + +function visitBRC100WalletByteField( + candidate: Record, + key: string, + fieldValue: unknown, + visit: (value: unknown) => void +): void { + const bytes = normalizeBRC100ByteArray(fieldValue) + if (bytes != null) candidate[key] = bytes + else visit(fieldValue) +} + +/** + * Repairs known byte fields in a wallet request, result, or serialized wallet + * error. This is intentionally field-aware: unrelated numeric-key objects are + * left untouched. Parsed JSON objects are normalized in place. + */ +export function normalizeBRC100WalletByteFields(value: T): T { + const seen = new WeakSet() + + const visit = (candidate: unknown): void => { + if (candidate == null || typeof candidate !== 'object' || isUint8Array(candidate)) return + if (seen.has(candidate)) return + seen.add(candidate) + + if (Array.isArray(candidate)) { + for (const item of candidate) visit(item) + return + } + + for (const [key, fieldValue] of Object.entries(candidate)) { + if (walletByteFieldNames.has(key)) { + visitBRC100WalletByteField(candidate as Record, key, fieldValue, visit) + } else { + visit(fieldValue) + } + } + } + + visit(value) + return value +} diff --git a/packages/sdk/src/wallet/WalletClient.ts b/packages/sdk/src/wallet/WalletClient.ts index a69165c00..f6db0ca03 100644 --- a/packages/sdk/src/wallet/WalletClient.ts +++ b/packages/sdk/src/wallet/WalletClient.ts @@ -59,6 +59,7 @@ import { } from './validationHelpers.js' import { WERR_INVALID_PARAMETER } from './WERR_INVALID_PARAMETER.js' +const MAX_FAST_SUBSTRATE_RESPONSE_WAIT = 1000 const MAX_XDM_RESPONSE_WAIT = 200 /** @@ -85,7 +86,9 @@ export default class WalletClient implements WalletInterface { if (substrate === 'window.CWI') substrate = new WindowCWISubstrate() if (substrate === 'XDM') substrate = new XDMSubstrate() if (substrate === 'json-api') substrate = new HTTPWalletJSON(originator) - if (substrate === 'react-native') substrate = new ReactNativeWebView(originator) + // The BRC-100 originator identifies the calling app; it is not a browser + // MessageEvent origin and must not be used as an RN bridge filter. + if (substrate === 'react-native') substrate = new ReactNativeWebView() if (substrate === 'secure-json-api') substrate = new HTTPWalletJSON(originator, 'https://localhost:2121') this.substrate = substrate @@ -104,13 +107,18 @@ export default class WalletClient implements WalletInterface { try { const sub = factory() let result + let timeoutHandle: ReturnType | undefined if (typeof timeout === 'number') { - result = await Promise.race([ - sub.getVersion({}), - new Promise((_resolve, reject) => - setTimeout(() => reject(new Error('Timed out.')), timeout) - ) - ]) + try { + result = await Promise.race([ + sub.getVersion({}), + new Promise((_resolve, reject) => { + timeoutHandle = setTimeout(() => reject(new Error('Timed out.')), timeout) + }) + ]) + } finally { + if (timeoutHandle !== undefined) clearTimeout(timeoutHandle) + } } else { result = await sub.getVersion({}) } @@ -125,11 +133,23 @@ export default class WalletClient implements WalletInterface { // Try all substrates concurrently, select first available by priority order const fastAttempts = [ - attemptSubstrate(() => new WindowCWISubstrate()), - attemptSubstrate(() => new WalletWireTransceiver(new HTTPWalletWire(this.originator))), - attemptSubstrate(() => new HTTPWalletJSON(this.originator, 'https://localhost:2121')), - attemptSubstrate(() => new HTTPWalletJSON(this.originator)), - attemptSubstrate(() => new ReactNativeWebView(this.originator)) + attemptSubstrate(() => new WindowCWISubstrate(), MAX_FAST_SUBSTRATE_RESPONSE_WAIT), + attemptSubstrate( + () => new WalletWireTransceiver(new HTTPWalletWire(this.originator)), + MAX_FAST_SUBSTRATE_RESPONSE_WAIT + ), + attemptSubstrate( + () => new HTTPWalletJSON(this.originator, 'https://localhost:2121'), + MAX_FAST_SUBSTRATE_RESPONSE_WAIT + ), + attemptSubstrate( + () => new HTTPWalletJSON(this.originator), + MAX_FAST_SUBSTRATE_RESPONSE_WAIT + ), + attemptSubstrate( + () => new ReactNativeWebView('*', MAX_FAST_SUBSTRATE_RESPONSE_WAIT), + MAX_FAST_SUBSTRATE_RESPONSE_WAIT + ) ] const fastResults = await Promise.allSettled(fastAttempts) diff --git a/packages/sdk/src/wallet/WalletError.ts b/packages/sdk/src/wallet/WalletError.ts index 2f47430a1..2985dc63a 100644 --- a/packages/sdk/src/wallet/WalletError.ts +++ b/packages/sdk/src/wallet/WalletError.ts @@ -1,3 +1,5 @@ +import { stringifyBRC100 } from './BRC100ByteEncoding.js' + export class WalletError extends Error { code: number isError: boolean = true @@ -60,7 +62,7 @@ export class WalletError extends Error { isError: true } } - const json = JSON.stringify(e) + const json = stringifyBRC100(e) return json } } diff --git a/packages/sdk/src/wallet/__tests/BRC100ByteEncoding.property.test.ts b/packages/sdk/src/wallet/__tests/BRC100ByteEncoding.property.test.ts new file mode 100644 index 000000000..fddfa3502 --- /dev/null +++ b/packages/sdk/src/wallet/__tests/BRC100ByteEncoding.property.test.ts @@ -0,0 +1,39 @@ +import fc from 'fast-check' +import { stringifyBRC100 } from '../BRC100ByteEncoding.js' + +const MIN_PROPERTY_RUNS = 300 +const requestedRuns = Number.parseInt(process.env.FAST_CHECK_NUM_RUNS ?? '', 10) +const requestedSeed = Number.parseInt(process.env.FAST_CHECK_SEED ?? '', 10) +const replayPath = process.env.FAST_CHECK_PATH + +fc.configureGlobal({ + numRuns: Number.isSafeInteger(requestedRuns) + ? Math.max(MIN_PROPERTY_RUNS, requestedRuns) + : MIN_PROPERTY_RUNS, + ...(Number.isSafeInteger(requestedSeed) ? { seed: requestedSeed } : {}), + ...(replayPath !== undefined && replayPath !== '' ? { path: replayPath } : {}) +}) + +describe('BRC-100 JSON compatibility properties', () => { + it('preserves every ordinary JSON value exactly', () => { + fc.assert( + fc.property(fc.jsonValue(), value => { + expect(stringifyBRC100(value)).toBe(JSON.stringify(value)) + }) + ) + }) + + it('serializes actual typed arrays portably without reinterpreting adjacent JSON', () => { + fc.assert( + fc.property( + fc.uint8Array({ maxLength: 128 }), + fc.jsonValue(), + (bytes, applicationData) => { + expect(stringifyBRC100({ bytes, applicationData })).toBe( + JSON.stringify({ bytes: Array.from(bytes), applicationData }) + ) + } + ) + ) + }) +}) diff --git a/packages/sdk/src/wallet/__tests/BRC100ByteEncoding.test.ts b/packages/sdk/src/wallet/__tests/BRC100ByteEncoding.test.ts new file mode 100644 index 000000000..2d4fa5482 --- /dev/null +++ b/packages/sdk/src/wallet/__tests/BRC100ByteEncoding.test.ts @@ -0,0 +1,194 @@ +import vm from 'node:vm' +import { + brc100JsonReplacer, + normalizeBRC100ByteArray, + normalizeBRC100ByteFields, + normalizeBRC100WalletByteFields, + stringifyBRC100, + toBRC100PortableByteArray +} from '../BRC100ByteEncoding' + +const sparseBytes = [0, 1, 2] +delete sparseBytes[1] + +describe('BRC-100 byte encoding', () => { + it('preserves validated number[] and Uint8Array fast paths by identity', () => { + const array = [0, 1, 254, 255] + const typed = new Uint8Array(array) + + expect(normalizeBRC100ByteArray(array)).toBe(array) + expect(normalizeBRC100ByteArray(typed)).toBe(typed) + }) + + it('accepts cross-realm Uint8Array without copying', () => { + const typed = vm.runInNewContext('new Uint8Array([1, 2, 3])') as Uint8Array + expect(normalizeBRC100ByteArray(typed)).toBe(typed) + }) + + it('accepts Uint8Array subclasses without copying', () => { + class WalletBytes extends Uint8Array {} + const typed = new WalletBytes([1, 2, 3]) + expect(normalizeBRC100ByteArray(typed)).toBe(typed) + }) + + it('recovers contiguous numeric-key JSON objects independent of insertion order', () => { + const numericObject = JSON.parse('{"2":3,"0":1,"1":2}') + expect(normalizeBRC100ByteArray(numericObject)).toEqual([1, 2, 3]) + expect(normalizeBRC100ByteArray({})).toBeUndefined() + }) + + it.each([ + [sparseBytes, 'sparse array'], + [[-1], 'negative byte'], + [[256], 'oversized byte'], + [[1.5], 'fractional byte'], + [{ 0: 1, 2: 3 }, 'non-contiguous object'], + [{ 0: '1' }, 'non-numeric object'], + ['1,2,3', 'string'] + ])('rejects %s (%s)', value => { + expect(normalizeBRC100ByteArray(value)).toBeUndefined() + }) + + it('rejects non-Uint8 typed arrays', () => { + expect(normalizeBRC100ByteArray(new Int8Array([1]))).toBeUndefined() + expect(normalizeBRC100ByteArray(new Uint16Array([1]))).toBeUndefined() + expect(normalizeBRC100ByteArray(new DataView(new ArrayBuffer(1)))).toBeUndefined() + expect(normalizeBRC100ByteArray(new ArrayBuffer(1))).toBeUndefined() + }) + + it('rejects nullish, primitive, empty, and hostile records without throwing', () => { + for (const value of [null, undefined, true, false, 0, 1, 'bytes', Symbol('bytes')]) { + expect(normalizeBRC100ByteArray(value)).toBeUndefined() + expect(toBRC100PortableByteArray(value)).toBeUndefined() + } + const hostile = new Proxy( + {}, + { + ownKeys: () => { + throw new Error('hostile ownKeys') + } + } + ) + expect(normalizeBRC100ByteArray(hostile)).toBeUndefined() + }) + + it('only allocates when a portable array is required', () => { + const array = [1, 2, 3] + const typed = new Uint8Array(array) + + expect(toBRC100PortableByteArray(array)).toBe(array) + expect(toBRC100PortableByteArray(typed)).toEqual(array) + expect(toBRC100PortableByteArray(typed)).not.toBe(typed) + }) + + it('serializes nested typed arrays as JSON arrays', () => { + const payload = { result: { signableTransaction: { tx: new Uint8Array([1, 2]) } } } + const expected = '{"result":{"signableTransaction":{"tx":[1,2]}}}' + + expect(JSON.stringify(payload, brc100JsonReplacer)).toBe(expected) + expect(stringifyBRC100(payload)).toBe(expected) + }) + + it('serializes Node Buffer values as portable arrays despite Buffer.toJSON', () => { + const payload = { tx: Buffer.from([1, 2, 3]) } + expect(stringifyBRC100(payload)).toBe('{"tx":[1,2,3]}') + }) + + it('rejects top-level values that native JSON cannot serialize', () => { + expect(() => stringifyBRC100(undefined)).toThrow('BRC-100 JSON payload is not serializable') + expect(() => stringifyBRC100(Symbol('payload'))).toThrow( + 'BRC-100 JSON payload is not serializable' + ) + }) + + it('keeps the public replacer safe when called without a JSON holder', () => { + const plain = { 0: 1, 1: 2 } + expect( + brc100JsonReplacer.call(null as unknown as Record, 'data', plain) + ).toBe(plain) + }) + + it('preserves plain numeric-key objects during serialization, including byte-like field names', () => { + const tx = JSON.parse(JSON.stringify(new Uint8Array([1, 2, 3]))) + const data = JSON.parse(JSON.stringify(new Uint8Array([4, 5]))) + + expect(stringifyBRC100({ tx, data })).toBe('{"tx":{"0":1,"1":2,"2":3},"data":{"0":4,"1":5}}') + }) + + it('normalizes only explicitly selected fields at opaque protocol boundaries', () => { + const payload = { + payload: { 0: 1, 1: 2 }, + signature: { 0: 3 }, + hmac: {}, + data: { 0: 4, 1: 5 }, + tx: {} + } + + expect(normalizeBRC100ByteFields(payload, ['payload', 'signature', 'hmac'])).toBe(payload) + expect(payload).toEqual({ + payload: [1, 2], + signature: [3], + hmac: [], + data: { 0: 4, 1: 5 }, + tx: {} + }) + }) + + it('does not traverse absent, inherited, invalid, array, or primitive protocol fields', () => { + const inherited = Object.create({ payload: { 0: 1 } }) as Record + inherited.signature = { nope: 1 } + const array = [{ payload: { 0: 2 } }] + + expect(normalizeBRC100ByteFields(null, ['payload'])).toBeNull() + expect(normalizeBRC100ByteFields('payload', ['payload'])).toBe('payload') + expect(normalizeBRC100ByteFields(array, ['payload'])).toBe(array) + expect(normalizeBRC100ByteFields(inherited, ['payload', 'signature', 'missing'])).toBe(inherited) + expect(inherited.payload).toEqual({ 0: 1 }) + expect(inherited.signature).toEqual({ nope: 1 }) + }) + + it('repairs all known nested wallet byte fields and leaves unrelated records alone', () => { + const mangled = (bytes: number[]): Record => + JSON.parse(JSON.stringify(new Uint8Array(bytes))) + const unrelated = mangled([9, 8]) + const result = { + tx: mangled([1]), + signableTransaction: { tx: mangled([2]) }, + page: { BEEF: mangled([3]) }, + reviewActionResults: [{ competingBeef: mangled([4]) }], + crypto: { ciphertext: mangled([5]), signature: mangled([6]) }, + message: { transaction: mangled([7]), beef: mangled([8]), atomicBEEF: mangled([9]) }, + unrelated + } + + expect(normalizeBRC100WalletByteFields(result)).toBe(result) + expect(result.tx).toEqual([1]) + expect(result.signableTransaction.tx).toEqual([2]) + expect(result.page.BEEF).toEqual([3]) + expect(result.reviewActionResults[0].competingBeef).toEqual([4]) + expect(result.crypto).toEqual({ ciphertext: [5], signature: [6] }) + expect(result.message).toEqual({ transaction: [7], beef: [8], atomicBEEF: [9] }) + expect(result.unrelated).toBe(unrelated) + }) + + it('preserves every empty object under a byte-like name while visiting nested byte fields', () => { + const mangled = (bytes: number[]): Record => + JSON.parse(JSON.stringify(new Uint8Array(bytes))) + const result = { + data: {}, + tx: {}, + signature: {}, + payload: { transaction: mangled([1, 2, 3]) } + } + + expect(normalizeBRC100WalletByteFields(result)).toEqual({ + data: {}, + tx: {}, + signature: {}, + payload: { transaction: [1, 2, 3] } + }) + expect(stringifyBRC100(result)).toBe( + '{"data":{},"tx":{},"signature":{},"payload":{"transaction":[1,2,3]}}' + ) + }) +}) diff --git a/packages/sdk/src/wallet/__tests/WalletClient.additional.test.ts b/packages/sdk/src/wallet/__tests/WalletClient.additional.test.ts index eacd282f9..220443fdd 100644 --- a/packages/sdk/src/wallet/__tests/WalletClient.additional.test.ts +++ b/packages/sdk/src/wallet/__tests/WalletClient.additional.test.ts @@ -10,6 +10,7 @@ import WalletClient from '../WalletClient' import HTTPWalletJSON from '../substrates/HTTPWalletJSON' +import ReactNativeWebView from '../substrates/ReactNativeWebView' import WalletWireTransceiver from '../substrates/WalletWireTransceiver' import { WERR_INVALID_PARAMETER } from '../WERR_INVALID_PARAMETER' import type { WalletInterface } from '../Wallet.interfaces' @@ -58,6 +59,39 @@ describe('WalletClient – constructor substrate aliases', () => { expect(client.substrate).toBeInstanceOf(HTTPWalletJSON) }) + it('does not treat a BRC-100 originator as a React Native MessageEvent origin', async () => { + const originalWindow = global.window + let listener: ((event: MessageEvent) => void) | undefined + const postMessage = jest.fn() + global.window = { + ReactNativeWebView: { postMessage }, + addEventListener: jest.fn( + (_name: string, callback: (event: MessageEvent) => void) => { listener = callback } + ), + removeEventListener: jest.fn() + } as unknown as Window & typeof globalThis + try { + const client = new WalletClient('react-native', 'myapp.com') + const promise = (client.substrate as ReactNativeWebView).getVersion({}) + const invocation = JSON.parse(postMessage.mock.calls[0][0]) + listener?.({ + origin: 'https://wallet.vendor.example', + data: JSON.stringify({ + type: 'CWI', + isInvocation: false, + id: invocation.id, + status: 'success', + result: { version: '1.0.0' } + }) + } as MessageEvent) + + await expect(promise).resolves.toEqual({ version: '1.0.0' }) + expect(client.originator).toBe('myapp.com') + } finally { + global.window = originalWindow + } + }) + it('accepts a pre-built WalletInterface object as substrate', () => { const mockWallet: WalletInterface = { createAction: jest.fn(), diff --git a/packages/sdk/src/wallet/__tests/WalletClient.substrate.test.ts b/packages/sdk/src/wallet/__tests/WalletClient.substrate.test.ts index f6de99d97..0f460e0f7 100644 --- a/packages/sdk/src/wallet/__tests/WalletClient.substrate.test.ts +++ b/packages/sdk/src/wallet/__tests/WalletClient.substrate.test.ts @@ -765,4 +765,27 @@ describe('WalletClient.connectToSubstrate – error when no substrate available' fetchSpy.mockRestore() } }, 10000) + + it('times out an unresponsive React Native candidate during auto-detection', async () => { + const originalWindow = global.window + const fetchSpy = jest + .spyOn(globalThis, 'fetch') + .mockRejectedValue(new Error('No test wallet available')) + global.window = { + ReactNativeWebView: { postMessage: jest.fn() }, + addEventListener: jest.fn(), + removeEventListener: jest.fn(), + postMessage: jest.fn() + } as unknown as Window & typeof globalThis + + try { + const client = new WalletClient('auto', 'myapp.com') + await expect(client.connectToSubstrate()).rejects.toThrow( + 'No wallet available over any communication substrate' + ) + } finally { + fetchSpy.mockRestore() + global.window = originalWindow + } + }, 3000) }) diff --git a/packages/sdk/src/wallet/__tests/WalletError.test.ts b/packages/sdk/src/wallet/__tests/WalletError.test.ts index 9c5e2e6bf..d4b7a3d66 100644 --- a/packages/sdk/src/wallet/__tests/WalletError.test.ts +++ b/packages/sdk/src/wallet/__tests/WalletError.test.ts @@ -145,6 +145,18 @@ describe('WalletError', () => { expect(parsed.tx).toEqual(tx) }) + it('serializes typed transaction bytes portably', () => { + const tx = new Uint8Array([1, 2, 3]) + const err = new WERR_REVIEW_ACTIONS( + reviewActionResults, + sendWithResults, + undefined, + tx as unknown as number[] + ) + const parsed = JSON.parse(WalletError.unknownToJson(err)) + expect(parsed.tx).toEqual([1, 2, 3]) + }) + it('includes optional noSendChange when provided', () => { const noSendChange = ['outpoint1', 'outpoint2'] const err = new WERR_REVIEW_ACTIONS( @@ -253,7 +265,7 @@ describe('WalletError', () => { it('uses the constructor name, not the name property', () => { class CustomError extends Error { - constructor (msg: string) { + constructor(msg: string) { super(msg) this.name = 'CustomError' } diff --git a/packages/sdk/src/wallet/index.ts b/packages/sdk/src/wallet/index.ts index 5e4d7b34d..b4342835a 100644 --- a/packages/sdk/src/wallet/index.ts +++ b/packages/sdk/src/wallet/index.ts @@ -1,6 +1,7 @@ import * as Validation from './validationHelpers.js' export * from './Wallet.interfaces.js' +export * from './BRC100ByteEncoding.js' export * from './KeyDeriver.js' export { default as CachedKeyDeriver } from './CachedKeyDeriver.js' export { default as ProtoWallet } from './ProtoWallet.js' diff --git a/packages/sdk/src/wallet/substrates/HTTPWalletJSON.ts b/packages/sdk/src/wallet/substrates/HTTPWalletJSON.ts index fe68c3f73..ff5d138b8 100644 --- a/packages/sdk/src/wallet/substrates/HTTPWalletJSON.ts +++ b/packages/sdk/src/wallet/substrates/HTTPWalletJSON.ts @@ -38,7 +38,7 @@ import { import { WERR_REVIEW_ACTIONS } from '../WERR_REVIEW_ACTIONS.js' import { WERR_INVALID_PARAMETER } from '../WERR_INVALID_PARAMETER.js' import { toOriginHeader } from './utils/toOriginHeader.js' -import { normalizeWalletJsonTx, walletJsonReplacer } from './utils/jsonByteEncoding.js' +import { normalizeBRC100WalletByteFields, stringifyBRC100 } from '../BRC100ByteEncoding.js' import WERR_INSUFFICIENT_FUNDS from '../WERR_INSUFFICIENT_FUNDS.js' function deserializeWalletError(data: any): Error | undefined { @@ -102,10 +102,10 @@ export default class HTTPWalletJSON implements WalletInterface { 'Content-Type': 'application/json', ...(origin ? { Origin: origin, Originator: origin } : {}) }, - body: JSON.stringify(args, walletJsonReplacer) + body: stringifyBRC100(args) }) - const data = normalizeWalletJsonTx(await res.json()) + const data = normalizeBRC100WalletByteFields(await res.json()) // Check the HTTP status on the original response if (!res.ok) { @@ -118,7 +118,7 @@ export default class HTTPWalletJSON implements WalletInterface { args, message: data.message ?? `HTTP Client error ${res.status}` } - throw new Error(JSON.stringify(err)) + throw new Error(stringifyBRC100(err)) } return data } diff --git a/packages/sdk/src/wallet/substrates/ReactNativeWebView.ts b/packages/sdk/src/wallet/substrates/ReactNativeWebView.ts index 5fe9e0f83..20313e2ea 100644 --- a/packages/sdk/src/wallet/substrates/ReactNativeWebView.ts +++ b/packages/sdk/src/wallet/substrates/ReactNativeWebView.ts @@ -3,6 +3,7 @@ import * as Utils from '../../primitives/utils.js' import { WalletError } from '../WalletError.js' import { CallType } from './WalletWireCalls.js' import { InvokableWalletBase } from './InvokableWalletBase.js' +import { normalizeBRC100WalletByteFields, stringifyBRC100 } from '../BRC100ByteEncoding.js' type ReactNativeWindow = Window & { ReactNativeWebView: { @@ -15,41 +16,64 @@ type ReactNativeWindow = Window & { */ export default class ReactNativeWebView extends InvokableWalletBase { private readonly domain: string + private readonly responseTimeout?: number - - constructor(domain: string = '*') { + constructor(domain: string = '*', responseTimeout?: number) { super() if (typeof globalThis.window !== 'object') { throw new TypeError('The XDM substrate requires a global window object.') } - if (!(globalThis.window as unknown as ReactNativeWindow).hasOwnProperty("ReactNativeWebView")) { - throw new Error( - 'The window object does not have a ReactNativeWebView property.' - ) + if (!(globalThis.window as unknown as ReactNativeWindow).hasOwnProperty('ReactNativeWebView')) { + throw new Error('The window object does not have a ReactNativeWebView property.') } - if (typeof (globalThis.window as unknown as ReactNativeWindow).ReactNativeWebView.postMessage !== 'function') { + if ( + typeof (globalThis.window as unknown as ReactNativeWindow).ReactNativeWebView.postMessage !== + 'function' + ) { throw new TypeError( 'The window.ReactNativeWebView property does not seem to support postMessage calls.' ) } - this.domain = domain + this.domain = normalizeOrigin(domain) + this.responseTimeout = responseTimeout } async invoke(call: CallType, args: any): Promise { return await new Promise((resolve, reject) => { const id = Utils.toBase64(Random(12)) + let timeoutHandle: ReturnType | undefined + const cleanup = (): void => { + if (timeoutHandle !== undefined) clearTimeout(timeoutHandle) + if (typeof globalThis.window.removeEventListener === 'function') { + globalThis.window.removeEventListener('message', listener) + } + } const listener = (e: MessageEvent): void => { - const data = JSON.parse(e.data) + let data: any + try { + data = JSON.parse(e.data) + } catch { + return + } + if (data?.type !== 'CWI' || data.id !== id || data.isInvocation === true) { + return + } if ( - data.type !== 'CWI' || - data.id !== id || - data.isInvocation === true + this.domain !== '*' && + e.origin != null && + e.origin !== '' && + e.origin !== this.domain ) { + cleanup() + reject( + new Error( + `React Native wallet response origin ${e.origin} did not match ${this.domain}.` + ) + ) return } - if (typeof globalThis.window.removeEventListener === 'function') { - globalThis.window.removeEventListener('message', listener) - } + cleanup() + normalizeBRC100WalletByteFields(data.result) if (data.status === 'error') { const err = new WalletError(data.description, data.code) reject(err) @@ -58,15 +82,40 @@ export default class ReactNativeWebView extends InvokableWalletBase { } } globalThis.window.addEventListener('message', listener) - ;(globalThis.window as unknown as ReactNativeWindow).ReactNativeWebView.postMessage( - JSON.stringify({ + if (this.responseTimeout !== undefined) { + timeoutHandle = setTimeout(() => { + cleanup() + reject(new Error('React Native wallet response timed out.')) + }, this.responseTimeout) + } + try { + const message = stringifyBRC100({ type: 'CWI', isInvocation: true, id, call, args }) - ) + ;(globalThis.window as unknown as ReactNativeWindow).ReactNativeWebView.postMessage(message) + } catch (error) { + cleanup() + reject(error) + } }) } } + +function normalizeOrigin(domain: string): string { + if (domain === '*') return domain + try { + if (/^[a-z][a-z\d+.-]*:\/\//i.test(domain) && !/^https?:\/\//i.test(domain)) { + throw new TypeError('Only HTTP(S) origins are supported.') + } + const candidate = /^https?:\/\//i.test(domain) ? domain : `https://${domain}` + const origin = new URL(candidate).origin + if (origin === 'null') throw new TypeError('The origin could not be normalized.') + return origin + } catch { + throw new TypeError('ReactNativeWebView domain must be an HTTP(S) origin or domain name.') + } +} diff --git a/packages/sdk/src/wallet/substrates/__tests/HTTPWalletJSON.test.ts b/packages/sdk/src/wallet/substrates/__tests/HTTPWalletJSON.test.ts index d48f7e71c..2f3af5659 100644 --- a/packages/sdk/src/wallet/substrates/__tests/HTTPWalletJSON.test.ts +++ b/packages/sdk/src/wallet/substrates/__tests/HTTPWalletJSON.test.ts @@ -19,7 +19,7 @@ function makeFetch( return jest.fn().mockResolvedValue({ ok, status, - json: () => Promise.resolve(body), + json: () => Promise.resolve(body) } as unknown as Response) } @@ -54,7 +54,11 @@ describe('HTTPWalletJSON – constructor', () => { it('stores the custom httpClient', () => { const mockFetch = jest.fn() - const client = new HTTPWalletJSON(TEST_ORIGINATOR, BASE_URL, mockFetch as unknown as typeof fetch) + const client = new HTTPWalletJSON( + TEST_ORIGINATOR, + BASE_URL, + mockFetch as unknown as typeof fetch + ) expect(client.httpClient).toBe(mockFetch) }) }) @@ -121,7 +125,7 @@ describe('HTTPWalletJSON – api() error responses', () => { isError: true, code: 6, parameter: 'description', - message: 'The description parameter must be at least 5 length.', + message: 'The description parameter must be at least 5 length.' } const mockFetch = makeFetch(errorBody, { ok: false, status: 400 }) const client = makeClient(mockFetch) @@ -143,7 +147,7 @@ describe('HTTPWalletJSON – api() error responses', () => { isError: true, code: 6, parameter: 'lockingScript', - message: 'Custom server message for lockingScript.', + message: 'Custom server message for lockingScript.' } const mockFetch = makeFetch(errorBody, { ok: false, status: 400 }) const client = makeClient(mockFetch) @@ -161,12 +165,14 @@ describe('HTTPWalletJSON – api() error responses', () => { isError: true, code: 7, totalSatoshisNeeded: 5000, - moreSatoshisNeeded: 2000, + moreSatoshisNeeded: 2000 } const mockFetch = makeFetch(errorBody, { ok: false, status: 400 }) const client = makeClient(mockFetch) - await expect(client.createAction({ description: 'hello world' })).rejects.toThrow(WERR_INSUFFICIENT_FUNDS) + await expect(client.createAction({ description: 'hello world' })).rejects.toThrow( + WERR_INSUFFICIENT_FUNDS + ) try { await client.createAction({ description: 'hello world' }) @@ -184,12 +190,14 @@ describe('HTTPWalletJSON – api() error responses', () => { code: 5, reviewActionResults: [{ txid: 'abc', status: 'failed' }], sendWithResults: [], - txid: 'abc123', + txid: 'abc123' } const mockFetch = makeFetch(errorBody, { ok: false, status: 400 }) const client = makeClient(mockFetch) - await expect(client.createAction({ description: 'hello world' })).rejects.toThrow(WERR_REVIEW_ACTIONS) + await expect(client.createAction({ description: 'hello world' })).rejects.toThrow( + WERR_REVIEW_ACTIONS + ) try { await client.createAction({ description: 'hello world' }) @@ -265,37 +273,27 @@ describe('HTTPWalletJSON – method routing', () => { }) const expectCallName = async (method: () => Promise, expectedPath: string) => { - await method().catch(() => { /* ignore deserialization quirks */ }) + await method().catch(() => { + /* ignore deserialization quirks */ + }) const url: string = mockFetch.mock.calls[mockFetch.mock.calls.length - 1][0] expect(url).toContain(expectedPath) } it('createAction calls /createAction', async () => { - await expectCallName( - () => client.createAction({ description: 'hello world' }), - '/createAction' - ) + await expectCallName(() => client.createAction({ description: 'hello world' }), '/createAction') }) it('signAction calls /signAction', async () => { - await expectCallName( - () => client.signAction({ spends: {}, reference: 'cmVm' }), - '/signAction' - ) + await expectCallName(() => client.signAction({ spends: {}, reference: 'cmVm' }), '/signAction') }) it('abortAction calls /abortAction', async () => { - await expectCallName( - () => client.abortAction({ reference: 'cmVm' }), - '/abortAction' - ) + await expectCallName(() => client.abortAction({ reference: 'cmVm' }), '/abortAction') }) it('listActions calls /listActions', async () => { - await expectCallName( - () => client.listActions({ labels: [] }), - '/listActions' - ) + await expectCallName(() => client.listActions({ labels: [] }), '/listActions') }) it('internalizeAction calls /internalizeAction', async () => { @@ -306,10 +304,7 @@ describe('HTTPWalletJSON – method routing', () => { }) it('listOutputs calls /listOutputs', async () => { - await expectCallName( - () => client.listOutputs({ basket: 'default' }), - '/listOutputs' - ) + await expectCallName(() => client.listOutputs({ basket: 'default' }), '/listOutputs') }) it('relinquishOutput calls /relinquishOutput', async () => { @@ -320,10 +315,7 @@ describe('HTTPWalletJSON – method routing', () => { }) it('getPublicKey calls /getPublicKey', async () => { - await expectCallName( - () => client.getPublicKey({ identityKey: true }), - '/getPublicKey' - ) + await expectCallName(() => client.getPublicKey({ identityKey: true }), '/getPublicKey') }) it('revealCounterpartyKeyLinkage calls /revealCounterpartyKeyLinkage', async () => { @@ -335,10 +327,13 @@ describe('HTTPWalletJSON – method routing', () => { it('revealSpecificKeyLinkage calls /revealSpecificKeyLinkage', async () => { await expectCallName( - () => client.revealSpecificKeyLinkage({ - counterparty: 'aa', verifier: 'bb', - protocolID: [0, 'proto'], keyID: 'k1', - }), + () => + client.revealSpecificKeyLinkage({ + counterparty: 'aa', + verifier: 'bb', + protocolID: [0, 'proto'], + keyID: 'k1' + }), '/revealSpecificKeyLinkage' ) }) @@ -380,17 +375,27 @@ describe('HTTPWalletJSON – method routing', () => { it('verifySignature calls /verifySignature', async () => { await expectCallName( - () => client.verifySignature({ data: [1], signature: [2], protocolID: [0, 'proto'], keyID: 'k1' }), + () => + client.verifySignature({ + data: [1], + signature: [2], + protocolID: [0, 'proto'], + keyID: 'k1' + }), '/verifySignature' ) }) it('acquireCertificate calls /acquireCertificate', async () => { await expectCallName( - () => client.acquireCertificate({ - type: 'dHlwZQ==', certifier: 'aa', acquisitionProtocol: 'issuance', - fields: {}, certifierUrl: 'https://certifier.example.com', - } as any), + () => + client.acquireCertificate({ + type: 'dHlwZQ==', + certifier: 'aa', + acquisitionProtocol: 'issuance', + fields: {}, + certifierUrl: 'https://certifier.example.com' + } as any), '/acquireCertificate' ) }) @@ -404,16 +409,20 @@ describe('HTTPWalletJSON – method routing', () => { it('proveCertificate calls /proveCertificate', async () => { await expectCallName( - () => client.proveCertificate({ - certificate: {} as any, fieldsToReveal: [], verifier: 'vv', - }), + () => + client.proveCertificate({ + certificate: {} as any, + fieldsToReveal: [], + verifier: 'vv' + }), '/proveCertificate' ) }) it('relinquishCertificate calls /relinquishCertificate', async () => { await expectCallName( - () => client.relinquishCertificate({ type: 'dHlwZQ==', serialNumber: 'c2Vy', certifier: 'aa' }), + () => + client.relinquishCertificate({ type: 'dHlwZQ==', serialNumber: 'c2Vy', certifier: 'aa' }), '/relinquishCertificate' ) }) @@ -433,45 +442,27 @@ describe('HTTPWalletJSON – method routing', () => { }) it('isAuthenticated calls /isAuthenticated', async () => { - await expectCallName( - () => client.isAuthenticated({}), - '/isAuthenticated' - ) + await expectCallName(() => client.isAuthenticated({}), '/isAuthenticated') }) it('waitForAuthentication calls /waitForAuthentication', async () => { - await expectCallName( - () => client.waitForAuthentication({}), - '/waitForAuthentication' - ) + await expectCallName(() => client.waitForAuthentication({}), '/waitForAuthentication') }) it('getHeight calls /getHeight', async () => { - await expectCallName( - () => client.getHeight({}), - '/getHeight' - ) + await expectCallName(() => client.getHeight({}), '/getHeight') }) it('getHeaderForHeight calls /getHeaderForHeight', async () => { - await expectCallName( - () => client.getHeaderForHeight({ height: 1 }), - '/getHeaderForHeight' - ) + await expectCallName(() => client.getHeaderForHeight({ height: 1 }), '/getHeaderForHeight') }) it('getNetwork calls /getNetwork', async () => { - await expectCallName( - () => client.getNetwork({}), - '/getNetwork' - ) + await expectCallName(() => client.getNetwork({}), '/getNetwork') }) it('getVersion calls /getVersion', async () => { - await expectCallName( - () => client.getVersion({}), - '/getVersion' - ) + await expectCallName(() => client.getVersion({}), '/getVersion') }) }) @@ -533,6 +524,37 @@ describe('HTTPWalletJSON – byte-field wire compatibility', () => { expect(result.tx).toEqual([1, 1, 1, 1, 128, 73]) }) + it('repairs a nested createAction signableTransaction tx', async () => { + const mangledTx = JSON.parse(JSON.stringify(new Uint8Array([1, 1, 1, 1, 128, 74]))) + const mockFetch = makeFetch({ + signableTransaction: { tx: mangledTx, reference: 'cmVm' } + }) + const client = makeClient(mockFetch) + + const result = await client.createAction({ description: 'test action' }) + expect(result.signableTransaction?.tx).toEqual([1, 1, 1, 1, 128, 74]) + }) + + it('repairs listOutputs BEEF and cryptographic byte results', async () => { + const mangled = (bytes: number[]): Record => + JSON.parse(JSON.stringify(new Uint8Array(bytes))) + const listClient = makeClient( + makeFetch({ totalOutputs: 0, outputs: [], BEEF: mangled([2, 3]) }) + ) + const cryptoClient = makeClient(makeFetch({ hmac: mangled([4, 5]) })) + + await expect(listClient.listOutputs({ basket: 'test' })).resolves.toMatchObject({ + BEEF: [2, 3] + }) + await expect( + cryptoClient.createHmac({ + data: [], + protocolID: [1, 'test protocol'], + keyID: '1' + }) + ).resolves.toEqual({ hmac: [4, 5] }) + }) + it('leaves a healthy number[] tx untouched', async () => { const tx = [1, 1, 1, 1, 42] const mockFetch = makeFetch({ txid: 'abc', tx }) diff --git a/packages/sdk/src/wallet/substrates/__tests/ReactNativeWebView.test.ts b/packages/sdk/src/wallet/substrates/__tests/ReactNativeWebView.test.ts index 7dd5b470a..e2c69edf1 100644 --- a/packages/sdk/src/wallet/substrates/__tests/ReactNativeWebView.test.ts +++ b/packages/sdk/src/wallet/substrates/__tests/ReactNativeWebView.test.ts @@ -28,16 +28,16 @@ describe('ReactNativeWebView', () => { jest.restoreAllMocks() }) - const getMessageListener = (): ((event: { data: string }) => void) => { + const getMessageListener = (): ((event: { data: string; origin?: string }) => void) => { const call = addEventListenerMock.mock.calls.at(-1) if (call == null) { throw new Error('No message listener registered.') } - return call[1] as (event: { data: string }) => void + return call[1] as (event: { data: string; origin?: string }) => void } - const dispatchMessage = (data: unknown): void => { - getMessageListener()({ data: JSON.stringify(data) }) + const dispatchMessage = (data: unknown, origin?: string): void => { + getMessageListener()({ data: JSON.stringify(data), origin }) } describe('constructor', () => { @@ -64,6 +64,12 @@ describe('ReactNativeWebView', () => { 'The window.ReactNativeWebView property does not seem to support postMessage calls.' ) }) + + it('rejects a non-HTTP domain filter', () => { + expect(() => new ReactNativeWebView('file:///wallet.html')).toThrow( + 'ReactNativeWebView domain must be an HTTP(S) origin or domain name.' + ) + }) }) describe('invoke', () => { @@ -85,6 +91,41 @@ describe('ReactNativeWebView', () => { ) }) + it('removes its listener and rejects when serialization fails', async () => { + jest.spyOn(Utils, 'toBase64').mockReturnValue('request-id') + const substrate = new ReactNativeWebView() + const circular: Record = {} + circular.self = circular + + await expect(substrate.invoke('createAction', circular)).rejects.toThrow(TypeError) + expect(removeEventListenerMock).toHaveBeenCalledWith('message', expect.any(Function)) + expect(postMessageMock).not.toHaveBeenCalled() + }) + + it('times out and removes its listener when configured for discovery', async () => { + jest.spyOn(Utils, 'toBase64').mockReturnValue('request-id') + const substrate = new ReactNativeWebView('*', 5) + + await expect(substrate.invoke('getVersion', {})).rejects.toThrow( + 'React Native wallet response timed out.' + ) + expect(removeEventListenerMock).toHaveBeenCalledWith('message', expect.any(Function)) + }) + + it('serializes typed wallet args as portable arrays', () => { + jest.spyOn(Utils, 'toBase64').mockReturnValue('request-id') + const substrate = new ReactNativeWebView() + + void substrate.invoke('createAction', { + description: 'Test action', + inputBEEF: new Uint8Array([1, 2, 3]) + }) + + expect(JSON.parse(postMessageMock.mock.calls[0][0])).toMatchObject({ + args: { inputBEEF: [1, 2, 3] } + }) + }) + it('resolves the result from a matching response', async () => { jest.spyOn(Utils, 'toBase64').mockReturnValue('request-id') const substrate = new ReactNativeWebView() @@ -102,6 +143,105 @@ describe('ReactNativeWebView', () => { expect(removeEventListenerMock).toHaveBeenCalledWith('message', expect.any(Function)) }) + it('repairs numeric-key byte objects in nested wallet responses', async () => { + jest.spyOn(Utils, 'toBase64').mockReturnValue('request-id') + const substrate = new ReactNativeWebView() + + const promise = substrate.invoke('createAction', {}) + dispatchMessage({ + type: 'CWI', + isInvocation: false, + id: 'request-id', + status: 'success', + result: { + signableTransaction: { + tx: JSON.parse(JSON.stringify(new Uint8Array([1, 2, 3]))), + reference: 'cmVm' + } + } + }) + + await expect(promise).resolves.toEqual({ + signableTransaction: { tx: [1, 2, 3], reference: 'cmVm' } + }) + }) + + it('normalizes a schemeless configured domain and accepts its full origin', async () => { + jest.spyOn(Utils, 'toBase64').mockReturnValue('request-id') + const substrate = new ReactNativeWebView('trusted.example') + const promise = substrate.invoke('getVersion', {}) + const response = { + type: 'CWI', + isInvocation: false, + id: 'request-id', + status: 'success', + result: { version: '1.0.0' } + } + + dispatchMessage(response, 'https://trusted.example') + await expect(promise).resolves.toEqual({ version: '1.0.0' }) + }) + + it('normalizes a schemeless configured host with a port', async () => { + jest.spyOn(Utils, 'toBase64').mockReturnValue('request-id') + const substrate = new ReactNativeWebView('localhost:3000') + const promise = substrate.invoke('getVersion', {}) + + dispatchMessage( + { + type: 'CWI', + isInvocation: false, + id: 'request-id', + status: 'success', + result: { version: '1.0.0' } + }, + 'https://localhost:3000' + ) + + await expect(promise).resolves.toEqual({ version: '1.0.0' }) + }) + + it('accepts originless native-to-web responses with an explicit domain', async () => { + jest.spyOn(Utils, 'toBase64').mockReturnValue('request-id') + const substrate = new ReactNativeWebView('trusted.example') + const promise = substrate.invoke('getVersion', {}) + + dispatchMessage( + { + type: 'CWI', + isInvocation: false, + id: 'request-id', + status: 'success', + result: { version: '1.0.0' } + }, + '' + ) + + await expect(promise).resolves.toEqual({ version: '1.0.0' }) + }) + + it('rejects a matching response from a mismatched non-empty origin', async () => { + jest.spyOn(Utils, 'toBase64').mockReturnValue('request-id') + const substrate = new ReactNativeWebView('trusted.example') + const promise = substrate.invoke('getVersion', {}) + + dispatchMessage( + { + type: 'CWI', + isInvocation: false, + id: 'request-id', + status: 'success', + result: { version: '1.0.0' } + }, + 'https://hostile.example' + ) + + await expect(promise).rejects.toThrow( + 'React Native wallet response origin https://hostile.example did not match https://trusted.example.' + ) + expect(removeEventListenerMock).toHaveBeenCalledWith('message', expect.any(Function)) + }) + it('rejects matching error responses as WalletError', async () => { jest.spyOn(Utils, 'toBase64').mockReturnValue('request-id') const substrate = new ReactNativeWebView() @@ -118,7 +258,7 @@ describe('ReactNativeWebView', () => { await expect(promise).rejects.toThrow(WalletError) await expect(promise).rejects.toThrow('Action was rejected') - await promise.catch((err) => { + await promise.catch(err => { expect(err.code).toBe(123) }) expect(removeEventListenerMock).toHaveBeenCalledWith('message', expect.any(Function)) @@ -130,8 +270,12 @@ describe('ReactNativeWebView', () => { const promise = substrate.invoke('getVersion', {}) let settled = false promise.then( - () => { settled = true }, - () => { settled = true } + () => { + settled = true + }, + () => { + settled = true + } ) dispatchMessage({ @@ -141,6 +285,7 @@ describe('ReactNativeWebView', () => { status: 'success', result: {} }) + getMessageListener()({ data: '{not-json', origin: 'https://hostile.example' }) dispatchMessage({ type: 'CWI', isInvocation: false, @@ -156,7 +301,7 @@ describe('ReactNativeWebView', () => { result: {} }) - await new Promise((resolve) => setTimeout(resolve, 1)) + await new Promise(resolve => setTimeout(resolve, 1)) expect(settled).toBe(false) expect(removeEventListenerMock).not.toHaveBeenCalled() diff --git a/packages/sdk/src/wallet/substrates/__tests/jsonByteEncoding.test.ts b/packages/sdk/src/wallet/substrates/__tests/jsonByteEncoding.test.ts index bc28f98a5..2b75c6d45 100644 --- a/packages/sdk/src/wallet/substrates/__tests/jsonByteEncoding.test.ts +++ b/packages/sdk/src/wallet/substrates/__tests/jsonByteEncoding.test.ts @@ -24,13 +24,32 @@ describe('normalizeWalletJsonTx', () => { expect(normalizeWalletJsonTx(result).tx).toEqual([1, 1, 1, 1, 128, 7]) }) - it('does not rewrite unrelated numeric-keyed fields', () => { + it('preserves non-tx top-level wallet byte fields', () => { const result = { signature: mangled([48, 68]), BEEF: mangled([2, 0, 190, 239]), ciphertext: mangled([1]) } - expect(normalizeWalletJsonTx(result)).toEqual(result) + expect(normalizeWalletJsonTx(result)).toBe(result) + expect(result.signature).toEqual(mangled([48, 68])) + expect(result.BEEF).toEqual(mangled([2, 0, 190, 239])) + expect(result.ciphertext).toEqual(mangled([1])) + }) + + it('preserves nested fields for the historical narrow export contract', () => { + const result = { + signableTransaction: { tx: mangled([1, 2]), reference: 'cmVm' }, + reviewActionResults: [{ competingBeef: mangled([3, 4]) }] + } + expect(normalizeWalletJsonTx(result)).toBe(result) + expect(result.signableTransaction.tx).toEqual(mangled([1, 2])) + expect(result.reviewActionResults[0].competingBeef).toEqual(mangled([3, 4])) + }) + + it('does not rewrite unrelated numeric-keyed fields', () => { + const unrelated = mangled([48, 68]) + const result = { unrelated } + expect(normalizeWalletJsonTx(result).unrelated).toBe(unrelated) }) it('leaves healthy number[] byte fields untouched', () => { diff --git a/packages/sdk/src/wallet/substrates/utils/jsonByteEncoding.ts b/packages/sdk/src/wallet/substrates/utils/jsonByteEncoding.ts index 433062d20..8fdcaf988 100644 --- a/packages/sdk/src/wallet/substrates/utils/jsonByteEncoding.ts +++ b/packages/sdk/src/wallet/substrates/utils/jsonByteEncoding.ts @@ -11,32 +11,30 @@ * corrupt requests. * * `walletJsonReplacer` makes serialization safe. `normalizeWalletJsonTx` - * repairs already-corrupted action results produced by affected deployed - * wallets. + * repairs the historical top-level action-result `tx` shape produced by + * affected deployed wallets. Broader recovery is performed internally by the + * HTTP substrate without changing this exported helper's narrow contract. */ +import { brc100JsonReplacer, normalizeBRC100ByteArray } from '../../BRC100ByteEncoding.js' + /** JSON.stringify replacer that encodes Uint8Array values as plain number arrays. */ -export function walletJsonReplacer(_key: string, value: unknown): unknown { - if (value instanceof Uint8Array) return [...value] - return value +export function walletJsonReplacer( + this: Record, + key: string, + value: unknown +): unknown { + return brc100JsonReplacer.call(this, key, value) } /** - * Repair the top-level action-result `tx` regression introduced by Uint8Array - * action serialization. Healthy arrays and all other fields are returned - * untouched. + * Repair the historical top-level action-result `tx` regression. This narrow + * compatibility export intentionally leaves every other field untouched. */ export function normalizeWalletJsonTx(value: T): T { - const tx = (value as { tx?: unknown } | null)?.tx - if (!tx || typeof tx !== 'object' || Array.isArray(tx)) { - return value - } - const entries = Object.entries(tx) - if ( - entries.length > 0 && - entries.every(([key, byte], index) => key === String(index) && typeof byte === 'number') - ) { - Object.assign(value as object, { tx: entries.map(([, byte]) => byte) }) - } + if (value == null || typeof value !== 'object' || Array.isArray(value)) return value + const record = value as Record + const tx = normalizeBRC100ByteArray(record.tx) + if (tx != null) record.tx = tx return value } diff --git a/packages/wallet/btms/README.md b/packages/wallet/btms/README.md index a1dc54c42..b49b30ae1 100644 --- a/packages/wallet/btms/README.md +++ b/packages/wallet/btms/README.md @@ -33,6 +33,11 @@ Node.js 22 or newer is required. The package provides typed ESM and CommonJS entry points. Browser applications should bundle the ESM entry point and provide a compatible wallet implementation. +BTMS normalizes transaction bundles at its messaging boundary. Historical +`number[]`, current Wallet Wire `Uint8Array`, and already-persisted numeric-key +JSON representations interoperate; invalid or sparse BEEF is rejected before +adapter, overlay, or wallet processing. + ## Quick Start ```typescript diff --git a/packages/wallet/btms/package.json b/packages/wallet/btms/package.json index aeaeb0fde..d6ea4ed76 100644 --- a/packages/wallet/btms/package.json +++ b/packages/wallet/btms/package.json @@ -1,6 +1,6 @@ { "name": "@bsv/btms", - "version": "1.2.0", + "version": "1.2.1", "sideEffects": false, "engines": { "node": ">=22" @@ -73,7 +73,7 @@ "typescript": "npm:@typescript/typescript6@6.0.2" }, "peerDependencies": { - "@bsv/sdk": "^2.4.0" + "@bsv/sdk": "^2.4.1" }, "bugs": { "url": "https://github.com/bsv-blockchain/ts-stack/issues" diff --git a/packages/wallet/btms/src/BTMS.ts b/packages/wallet/btms/src/BTMS.ts index 55702441c..f7e09f866 100644 --- a/packages/wallet/btms/src/BTMS.ts +++ b/packages/wallet/btms/src/BTMS.ts @@ -19,7 +19,9 @@ import { Random, WalletInterface, CommsLayer, - AtomicBEEF + AtomicBEEF, + normalizeBRC100ByteArray, + stringifyBRC100 } from '@bsv/sdk' import { BTMSToken } from './BTMSToken.js' @@ -74,6 +76,15 @@ type UtxoVerificationResult = { beef?: Beef } +function normalizeIncomingTokenBeef(value: unknown): AtomicBEEF | undefined { + if (value === undefined) return undefined + const beef = normalizeBRC100ByteArray(value) + if (beef == null || beef.length === 0) { + throw new Error('Incoming token BEEF must be a non-empty BRC-100 byte array') + } + return beef +} + /** * BTMS - Basic Token Management System * @@ -185,7 +196,7 @@ export class BTMS { { satoshis: DEFAULT_TOKEN_SATOSHIS, lockingScript: lockingScriptHex, - customInstructions: JSON.stringify({ + customInstructions: stringifyBRC100({ derivationPrefix, derivationSuffix }), @@ -232,7 +243,7 @@ export class BTMS { protocol: 'basket insertion', insertionRemittance: { basket: BTMS_BASKET, - customInstructions: JSON.stringify({ + customInstructions: stringifyBRC100({ derivationPrefix, derivationSuffix }), @@ -351,7 +362,7 @@ export class BTMS { outputs.push({ satoshis: DEFAULT_TOKEN_SATOSHIS, lockingScript: recipientScriptHex, - customInstructions: JSON.stringify({ + customInstructions: stringifyBRC100({ derivationPrefix: transferDerivationPrefix, derivationSuffix: recipientDerivationSuffix }), @@ -434,7 +445,7 @@ export class BTMS { amount, satoshis: DEFAULT_TOKEN_SATOSHIS, beef: signedTx, - customInstructions: JSON.stringify({ + customInstructions: stringifyBRC100({ derivationPrefix: transferDerivationPrefix, derivationSuffix: recipientDerivationSuffix }), @@ -447,7 +458,7 @@ export class BTMS { await this.comms.sendMessage({ recipient, messageBox: BTMS_MESSAGE_BOX, - body: JSON.stringify(tokenForRecipient) + body: stringifyBRC100(tokenForRecipient) }) } @@ -644,17 +655,9 @@ export class BTMS { const payments: IncomingToken[] = [] for (const msg of messages) { - try { - const payment = JSON.parse(msg.body) as IncomingToken - payment.messageId = msg.messageId - payment.sender = msg.sender - - // Filter by assetId if provided - if (!assetId || payment.assetId === assetId) { - payments.push(payment) - } - } catch { - // Skip invalid messages + const payment = parseIncomingMessage(msg) + if (payment != null && (!assetId || payment.assetId === assetId)) { + payments.push(payment) } } @@ -672,6 +675,7 @@ export class BTMS { */ async accept(token: IncomingToken): Promise { try { + const beef = normalizeIncomingTokenBeef(token.beef) // Decode and validate the token const decoded = BTMSToken.decode(token.lockingScript) if (!decoded.valid) { @@ -682,8 +686,8 @@ export class BTMS { const { found: isOnOverlay } = await this.lookupTokenOnOverlay(token.txid, token.outputIndex) // Re-broadcast if token is not on overlay - if (!isOnOverlay && token.beef) { - const tx = Transaction.fromBEEF(token.beef) + if (!isOnOverlay && beef != null) { + const tx = Transaction.fromBEEF(beef) try { await this.broadcastWithRetry(tx, `accept rebroadcast ${token.txid}`) } catch { @@ -718,7 +722,7 @@ export class BTMS { // Internalize the token into the wallet // Augment customInstructions with senderIdentityKey so we can unlock later const originalInstructions = JSON.parse(token.customInstructions) - const augmentedInstructions = JSON.stringify({ + const augmentedInstructions = stringifyBRC100({ ...originalInstructions, senderIdentityKey: token.sender }) @@ -728,7 +732,7 @@ export class BTMS { const timestampTag = this.getTimestampTag() await this.wallet.internalizeAction({ - tx: token.beef, + tx: beef as AtomicBEEF, labels: [ `${BTMS_LABEL_PREFIX}type receive`, `${BTMS_LABEL_PREFIX}direction incoming`, @@ -796,6 +800,7 @@ export class BTMS { if (!this.comms) { throw new Error('Comms layer is required to refund incoming tokens') } + const beef = normalizeIncomingTokenBeef(token.beef) // Decode and validate the token const decoded = BTMSToken.decode(token.lockingScript) @@ -807,8 +812,8 @@ export class BTMS { const overlayLookup = await this.lookupTokenOnOverlay(token.txid, token.outputIndex, true) // Re-broadcast if token is not on overlay - if (!overlayLookup.found && token.beef) { - const tx = Transaction.fromBEEF(token.beef) + if (!overlayLookup.found && beef != null) { + const tx = Transaction.fromBEEF(beef) try { await this.broadcastWithRetry(tx, `refund rebroadcast ${token.txid}`) } catch { @@ -816,8 +821,7 @@ export class BTMS { } } - const inputBeef = - overlayLookup.beef ?? (token.beef ? Beef.fromBinary(Utils.toArray(token.beef)) : undefined) + const inputBeef = overlayLookup.beef ?? (beef != null ? Beef.fromBinary(beef) : undefined) if (!inputBeef) { throw new Error('Missing BEEF data required to refund token') @@ -875,7 +879,7 @@ export class BTMS { { satoshis: DEFAULT_TOKEN_SATOSHIS, lockingScript: refundScript.toHex(), - customInstructions: JSON.stringify({ + customInstructions: stringifyBRC100({ derivationPrefix: refundDerivationPrefix, derivationSuffix: refundDerivationSuffix }), @@ -925,7 +929,7 @@ export class BTMS { } const originalInstructions = JSON.parse(token.customInstructions) - const refundInstructions = JSON.stringify({ + const refundInstructions = stringifyBRC100({ ...originalInstructions, senderIdentityKey: token.sender }) @@ -939,7 +943,7 @@ export class BTMS { customInstructions: refundInstructions, token: decoded, spendable: true, - beef: token.beef + beef } const spends = await this.buildSpendsForInputs([selectedUtxo], signableTransaction.tx) @@ -955,7 +959,7 @@ export class BTMS { amount: token.amount, satoshis: DEFAULT_TOKEN_SATOSHIS, beef: signedTx, - customInstructions: JSON.stringify({ + customInstructions: stringifyBRC100({ derivationPrefix: refundDerivationPrefix, derivationSuffix: refundDerivationSuffix }), @@ -966,7 +970,7 @@ export class BTMS { await this.comms.sendMessage({ recipient: token.sender, messageBox: BTMS_MESSAGE_BOX, - body: JSON.stringify(tokenForRecipient) + body: stringifyBRC100(tokenForRecipient) }) if (token.messageId) { @@ -1719,9 +1723,9 @@ export class BTMS { selected: Array<{ token: { metadata?: unknown } }>, metadata: unknown ): void { - const metadataJson = JSON.stringify(metadata ?? null) + const metadataJson = stringifyBRC100(metadata ?? null) for (const utxo of selected) { - if (JSON.stringify(utxo.token.metadata ?? null) !== metadataJson) { + if (stringifyBRC100(utxo.token.metadata ?? null) !== metadataJson) { throw new Error('Metadata mismatch across selected tokens') } } @@ -1751,7 +1755,7 @@ export class BTMS { result.push({ satoshis: DEFAULT_TOKEN_SATOSHIS, lockingScript: changeScript.toHex(), - customInstructions: JSON.stringify({ + customInstructions: stringifyBRC100({ derivationPrefix, derivationSuffix: changeDerivationSuffix }), diff --git a/packages/wallet/btms/src/BTMSHelpers.ts b/packages/wallet/btms/src/BTMSHelpers.ts index 6f6846c26..afab35154 100644 --- a/packages/wallet/btms/src/BTMSHelpers.ts +++ b/packages/wallet/btms/src/BTMSHelpers.ts @@ -13,7 +13,12 @@ import type { IncomingToken, GetTransactionsResult } from './types.js' -import type { ListActionsResult, PubKeyHex, TXIDHexString } from '@bsv/sdk' +import { + normalizeBRC100ByteArray, + type ListActionsResult, + type PubKeyHex, + type TXIDHexString +} from '@bsv/sdk' import { BTMS_LABEL_PREFIX, ISSUE_MARKER, DEFAULT_TOKEN_SATOSHIS } from './constants.js' // --------------------------------------------------------------------------- @@ -242,6 +247,12 @@ export function parseIncomingMessage(msg: { }): IncomingToken | null { try { const payment = JSON.parse(msg.body) as IncomingToken + if (payment.beef !== undefined) { + const beef = normalizeBRC100ByteArray(payment.beef) + if (beef == null) return null + if (beef.length === 0) return null + payment.beef = beef + } if (msg.messageId !== undefined) payment.messageId = msg.messageId if (msg.sender !== undefined) payment.sender = msg.sender return payment diff --git a/packages/wallet/btms/src/__tests/BTMSHelpers.property.test.ts b/packages/wallet/btms/src/__tests/BTMSHelpers.property.test.ts index c3955516c..f489f5b49 100644 --- a/packages/wallet/btms/src/__tests/BTMSHelpers.property.test.ts +++ b/packages/wallet/btms/src/__tests/BTMSHelpers.property.test.ts @@ -82,6 +82,22 @@ describe('BTMS untrusted data properties', () => { expect(() => parseCustomInstructions(undefined, 'txid', 7)).toThrow('txid.7') }) + test('recovers JSON-serialized Uint8Array BEEF and rejects sparse byte records', () => { + const beef = JSON.parse(JSON.stringify(new Uint8Array([1, 2, 3]))) + expect(parseIncomingMessage({ body: JSON.stringify({ beef }) })).toMatchObject({ + beef: [1, 2, 3] + }) + expect(parseIncomingMessage({ body: JSON.stringify({ beef: { 0: 1, 2: 3 } }) })).toBeNull() + expect(parseIncomingMessage({ body: JSON.stringify({ beef: {} }) })).toBeNull() + + const withoutEnvelopeMetadata = parseIncomingMessage({ + body: JSON.stringify({ beef: [1, 2, 3] }) + }) + expect(withoutEnvelopeMetadata).not.toBeNull() + expect(Object.hasOwn(withoutEnvelopeMetadata as object, 'messageId')).toBe(false) + expect(Object.hasOwn(withoutEnvelopeMetadata as object, 'sender')).toBe(false) + }) + test('strips exactly one governed label prefix and is total for arbitrary message text', () => { fc.assert( fc.property(fc.array(fc.string({ maxLength: 200 }), { maxLength: 100 }), labels => { diff --git a/packages/wallet/btms/src/__tests/BTMSHelpers.test.ts b/packages/wallet/btms/src/__tests/BTMSHelpers.test.ts index f152d3b11..2341e86cb 100644 --- a/packages/wallet/btms/src/__tests/BTMSHelpers.test.ts +++ b/packages/wallet/btms/src/__tests/BTMSHelpers.test.ts @@ -1,11 +1,13 @@ import type { ListActionsResult } from '@bsv/sdk' import { jest } from '@jest/globals' import { + accumulateOutputIntoBalances, calcActionAmount, mapActionToTransaction, stripLabelPrefix, verifyProvenTokenAssetId } from '../BTMSHelpers.js' +import { BTMSToken } from '../BTMSToken.js' import { BTMS_LABEL_PREFIX, ISSUE_MARKER } from '../constants.js' import { parseCustomInstructions } from '../utils.js' @@ -61,6 +63,33 @@ describe('BTMS helper edge cases', () => { ).toThrow('Token asset ID does not match proof asset ID') }) + it('keeps the first decoded metadata while accumulating an asset balance', () => { + jest.spyOn(BTMSToken, 'decode').mockReturnValueOnce({ + valid: true, + assetId: ASSET_ID, + amount: 7, + metadata: JSON.stringify({ name: 'replacement' }), + lockingPublicKey: '' + }) + const originalMetadata = { name: 'original' } + const balances = new Map([[ASSET_ID, { balance: 5, metadata: originalMetadata }]]) + + accumulateOutputIntoBalances( + { + spendable: true, + satoshis: 1, + outpoint: `${TXID}.1`, + lockingScript: 'mocked' + }, + balances + ) + + expect(balances.get(ASSET_ID)).toEqual({ + balance: 12, + metadata: originalMetadata + }) + }) + it('reports unknown non-Error JSON failures without losing UTXO context', () => { jest.spyOn(JSON, 'parse').mockImplementationOnce(() => { throw 'malformed' diff --git a/packages/wallet/ts-wallet-relay/README.md b/packages/wallet/ts-wallet-relay/README.md index d677b2f70..62d024a7a 100644 --- a/packages/wallet/ts-wallet-relay/README.md +++ b/packages/wallet/ts-wallet-relay/README.md @@ -33,6 +33,11 @@ application, so relay routes share one Express 4 or 5 type graph with the host. `@bsv/sdk` is used throughout — backend wallet crypto, frontend local wallet detection, and mobile pairing. Install it in every layer of your project. +Wallet RPC byte fields are normalized and validated in both directions. The +relay preserves historical `number[]`, current `Uint8Array`, and numeric-key +JSON wallet representations for direct and deferred-signing actions without +changing non-byte RPC fields. + > **TypeScript:** your `tsconfig.json` needs `"moduleResolution": "bundler"` (or `"node16"` / `"nodenext"`) to resolve the `@bsv/wallet-relay/react` and `@bsv/wallet-relay/client` subpath exports. ### 2. Generate a stable backend key diff --git a/packages/wallet/ts-wallet-relay/package.json b/packages/wallet/ts-wallet-relay/package.json index 8c1f0e6f3..effd51149 100644 --- a/packages/wallet/ts-wallet-relay/package.json +++ b/packages/wallet/ts-wallet-relay/package.json @@ -1,6 +1,6 @@ { "name": "@bsv/wallet-relay", - "version": "0.3.4", + "version": "0.3.5", "sideEffects": false, "engines": { "node": ">=22" @@ -78,7 +78,7 @@ "prepublishOnly": "pnpm build" }, "peerDependencies": { - "@bsv/sdk": "^2.1.6", + "@bsv/sdk": "^2.4.1", "@types/express": ">=4.17.0 <6", "express": ">=4.18.0 <6", "qrcode": ">=1.5.0", diff --git a/packages/wallet/ts-wallet-relay/src/client/WalletPairingSession.ts b/packages/wallet/ts-wallet-relay/src/client/WalletPairingSession.ts index b77a48528..4842af5ad 100644 --- a/packages/wallet/ts-wallet-relay/src/client/WalletPairingSession.ts +++ b/packages/wallet/ts-wallet-relay/src/client/WalletPairingSession.ts @@ -1,4 +1,4 @@ -import type { WalletProtocol } from '@bsv/sdk' +import { normalizeBRC100WalletByteFields, stringifyBRC100, type WalletProtocol } from '@bsv/sdk' import type { WalletLike, PairingParams, @@ -243,7 +243,7 @@ export class WalletPairingSession { ws.onopen = async () => { try { - const payload = JSON.stringify({ + const payload = stringifyBRC100({ id: crypto.randomUUID(), seq: this._lastSeq + 1, method: 'pairing_approved', @@ -255,7 +255,7 @@ export class WalletPairingSession { }) const ciphertext = await encryptEnvelope(this.wallet, cryptoParams, payload) const envelope: WireEnvelope = { topic, mobileIdentityKey: publicKey, ciphertext } - ws.send(JSON.stringify(envelope)) + ws.send(stringifyBRC100(envelope)) } catch (err) { this.emitError(err instanceof Error ? err.message : 'Failed to send pairing message') } @@ -339,6 +339,7 @@ export class WalletPairingSession { private async handleRpc(request: RpcRequest): Promise { const { topic, backendIdentityKey } = this.params + normalizeBRC100WalletByteFields(request.params) const cryptoParams: CryptoParams = { protocolID: this.protocolID, keyID: topic, @@ -346,8 +347,8 @@ export class WalletPairingSession { } const sendResponse = async (response: RpcResponse): Promise => { - const ciphertext = await encryptEnvelope(this.wallet, cryptoParams, JSON.stringify(response)) - this.ws?.send(JSON.stringify({ topic, ciphertext } satisfies WireEnvelope)) + const ciphertext = await encryptEnvelope(this.wallet, cryptoParams, stringifyBRC100(response)) + this.ws?.send(stringifyBRC100({ topic, ciphertext } satisfies WireEnvelope)) } // Unknown method — reject immediately without showing approval UI diff --git a/packages/wallet/ts-wallet-relay/src/client/WalletRelayClient.ts b/packages/wallet/ts-wallet-relay/src/client/WalletRelayClient.ts index 827536923..98afe85a9 100644 --- a/packages/wallet/ts-wallet-relay/src/client/WalletRelayClient.ts +++ b/packages/wallet/ts-wallet-relay/src/client/WalletRelayClient.ts @@ -1,4 +1,4 @@ -import type { WalletInterface } from '@bsv/sdk' +import { normalizeBRC100WalletByteFields, stringifyBRC100, type WalletInterface } from '@bsv/sdk' import type { SessionInfo, WalletRequest, @@ -251,7 +251,7 @@ export class WalletRelayClient { const res = await fetch(`${this._apiUrl}/request/${this._session.sessionId}`, { method: 'POST', headers, - body: JSON.stringify({ method, params }) + body: stringifyBRC100({ method, params }) }) if (!res.ok) { @@ -280,6 +280,7 @@ export class WalletRelayClient { result?: unknown error?: { code: number; message: string } } + normalizeBRC100WalletByteFields(rpc.result) const response: WalletResponse = { requestId, result: rpc.result, @@ -393,7 +394,7 @@ export class WalletRelayClient { status: this._session.status, savedAt: Date.now() } - sessionStorage.setItem(this._storageKey, JSON.stringify(entry)) + sessionStorage.setItem(this._storageKey, stringifyBRC100(entry)) } catch { /* SSR or storage unavailable */ } diff --git a/packages/wallet/ts-wallet-relay/src/server/WalletRelayService.ts b/packages/wallet/ts-wallet-relay/src/server/WalletRelayService.ts index ed3cbfc7c..91401356d 100644 --- a/packages/wallet/ts-wallet-relay/src/server/WalletRelayService.ts +++ b/packages/wallet/ts-wallet-relay/src/server/WalletRelayService.ts @@ -22,6 +22,7 @@ import { buildPairingUri } from '../shared/pairingUri.js' import { encryptEnvelope, decryptEnvelope } from '../shared/crypto.js' import { bytesToBase64url } from '../shared/encoding.js' import { compileOriginMatcher, type AllowedOrigins } from '../shared/originMatcher.js' +import { normalizeBRC100WalletByteFields, stringifyBRC100 } from '@bsv/sdk' export interface WalletRelayServiceOptions { /** @@ -281,7 +282,7 @@ export class WalletRelayService { const uri = buildPairingUri({ sessionId: session.id, backendIdentityKey, - protocolID: JSON.stringify(PROTOCOL_ID), + protocolID: stringifyBRC100(PROTOCOL_ID), origin, expiry, sig, @@ -336,7 +337,7 @@ export class WalletRelayService { const ciphertext = await encryptEnvelope( this.wallet, { protocolID: PROTOCOL_ID, keyID: sessionId, counterparty: session.mobileIdentityKey }, - JSON.stringify(rpc) + stringifyBRC100(rpc) ) this.relay.sendToMobile(sessionId, { topic: sessionId, ciphertext }) @@ -499,6 +500,7 @@ export class WalletRelayService { const msg = this.handler.parseMessage(plaintext) if (this.handler.isResponse(msg)) { + normalizeBRC100WalletByteFields(msg.result) const pending = this.pending.get(msg.id) if (pending) { clearTimeout(pending.timer) @@ -546,7 +548,7 @@ export class WalletRelayService { const ciphertext = await encryptEnvelope( this.wallet, { protocolID: PROTOCOL_ID, keyID: topic, counterparty: mobileIdentityKey }, - JSON.stringify(ack) + stringifyBRC100(ack) ) this.relay.sendToMobile(topic, { topic, ciphertext }) } diff --git a/packages/wallet/ts-wallet-relay/src/server/WebSocketRelay.ts b/packages/wallet/ts-wallet-relay/src/server/WebSocketRelay.ts index 293a57e27..b7f0791c2 100644 --- a/packages/wallet/ts-wallet-relay/src/server/WebSocketRelay.ts +++ b/packages/wallet/ts-wallet-relay/src/server/WebSocketRelay.ts @@ -2,6 +2,7 @@ import { WebSocketServer, WebSocket } from 'ws' import type { IncomingMessage, Server } from 'node:http' import type { Duplex } from 'node:stream' import type { WireEnvelope } from '../types.js' +import { stringifyBRC100 } from '@bsv/sdk' import { compileOriginMatcher, type AllowedOrigins } from '../shared/originMatcher.js' const HEARTBEAT_INTERVAL_MS = 30_000 @@ -158,7 +159,7 @@ export class WebSocketRelay { sendToMobile(topic: string, envelope: WireEnvelope): void { const entry = this.topics.get(topic) if (entry?.mobile?.readyState === WebSocket.OPEN) { - entry.mobile.send(JSON.stringify(envelope)) + entry.mobile.send(stringifyBRC100(envelope)) } else { this.buffer(topic, envelope) } @@ -168,7 +169,7 @@ export class WebSocketRelay { sendToDesktop(topic: string, envelope: WireEnvelope): void { const entry = this.topics.get(topic) if (entry?.desktop?.readyState === WebSocket.OPEN) { - entry.desktop.send(JSON.stringify(envelope)) + entry.desktop.send(stringifyBRC100(envelope)) } else { this.buffer(topic, envelope) } @@ -231,7 +232,7 @@ export class WebSocketRelay { const toFlush = entry.buffer.filter(m => m.expiresAt > now) entry.buffer = [] for (const { envelope } of toFlush) { - ws.send(JSON.stringify(envelope)) + ws.send(stringifyBRC100(envelope)) } ;(ws as WebSocket & { isAlive: boolean }).isAlive = true @@ -247,7 +248,7 @@ export class WebSocketRelay { // Route to the other side const other = role === 'mobile' ? entry.desktop : entry.mobile if (other?.readyState === WebSocket.OPEN) { - other.send(JSON.stringify(envelope)) + other.send(stringifyBRC100(envelope)) } else if (role === 'desktop') { // Only buffer desktop→mobile messages. Mobile→desktop messages are // handled by the onMessage callback; buffering them here would cause diff --git a/packages/wallet/ts-wallet-relay/src/shared/crypto.ts b/packages/wallet/ts-wallet-relay/src/shared/crypto.ts index b2a8d4639..7b91c24f6 100644 --- a/packages/wallet/ts-wallet-relay/src/shared/crypto.ts +++ b/packages/wallet/ts-wallet-relay/src/shared/crypto.ts @@ -1,4 +1,4 @@ -import type { WalletProtocol } from '@bsv/sdk' +import { toBRC100PortableByteArray, type WalletProtocol } from '@bsv/sdk' import type { WalletLike } from '../types.js' import { bytesToBase64url, base64urlToBytes } from './encoding.js' @@ -43,5 +43,7 @@ export async function decryptEnvelope( counterparty: params.counterparty, ciphertext }) - return new TextDecoder().decode(new Uint8Array(plaintext)) + const bytes = toBRC100PortableByteArray(plaintext) + if (bytes == null) throw new TypeError('Wallet returned an invalid plaintext byte payload') + return new TextDecoder().decode(new Uint8Array(bytes)) } diff --git a/packages/wallet/ts-wallet-relay/src/shared/encoding.ts b/packages/wallet/ts-wallet-relay/src/shared/encoding.ts index e8131fe12..bc0e813ae 100644 --- a/packages/wallet/ts-wallet-relay/src/shared/encoding.ts +++ b/packages/wallet/ts-wallet-relay/src/shared/encoding.ts @@ -1,7 +1,9 @@ -import { Utils } from '@bsv/sdk' +import { Utils, toBRC100PortableByteArray } from '@bsv/sdk' /** Convert a byte array to a base64url string using @bsv/sdk Utils. */ -export function bytesToBase64url(bytes: number[]): string { +export function bytesToBase64url(value: unknown): string { + const bytes = toBRC100PortableByteArray(value) + if (bytes == null) throw new TypeError('Invalid BRC-100 byte payload') return Utils.toBase64(bytes).replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '') } diff --git a/packages/wallet/ts-wallet-relay/tests/WalletRelayClient.test.ts b/packages/wallet/ts-wallet-relay/tests/WalletRelayClient.test.ts index 262e130be..f6823419e 100644 --- a/packages/wallet/ts-wallet-relay/tests/WalletRelayClient.test.ts +++ b/packages/wallet/ts-wallet-relay/tests/WalletRelayClient.test.ts @@ -268,6 +268,30 @@ describe('WalletRelayClient requests', () => { client.destroy() }) + it('keeps request bytes portable and repairs responses from historical relay JSON', async () => { + const client = await connectedClient() + const mangledTx = JSON.parse(JSON.stringify(new Uint8Array([4, 5, 6]))) + fetchMock.mockResolvedValueOnce( + response({ + result: { signableTransaction: { tx: mangledTx, reference: 'cmVm' } } + }) + ) + + const result = await client.sendRequest('createAction', { + description: 'test action', + inputBEEF: new Uint8Array([1, 2, 3]) + }) + + const lastCall = fetchMock.mock.calls[fetchMock.mock.calls.length - 1] + expect(JSON.parse(String(lastCall?.[1]?.body))).toMatchObject({ + params: { inputBEEF: [1, 2, 3] } + }) + expect(result.result).toEqual({ + signableTransaction: { tx: [4, 5, 6], reference: 'cmVm' } + }) + client.destroy() + }) + it.each([ [401, 'bad token', 'INVALID_TOKEN'], [400, 'not connected', 'SESSION_NOT_CONNECTED'], diff --git a/packages/wallet/ts-wallet-relay/tests/crypto.test.ts b/packages/wallet/ts-wallet-relay/tests/crypto.test.ts index 53081996c..0f3c3c828 100644 --- a/packages/wallet/ts-wallet-relay/tests/crypto.test.ts +++ b/packages/wallet/ts-wallet-relay/tests/crypto.test.ts @@ -8,6 +8,25 @@ function makeWallet() { return new ProtoWallet(PrivateKey.fromRandom()) } +function withHistoricalJsonBytes(wallet: ProtoWallet): ProtoWallet { + return new Proxy(wallet, { + get(target, property) { + const value = Reflect.get(target, property, target) + if (property === 'encrypt' || property === 'decrypt') { + return async (...args: unknown[]) => { + const result = await value.apply(target, args) + const field = property === 'encrypt' ? 'ciphertext' : 'plaintext' + return { + ...result, + [field]: JSON.parse(JSON.stringify(new Uint8Array(result[field]))) + } + } + } + return typeof value === 'function' ? value.bind(target) : value + } + }) +} + async function identityKey(wallet: ProtoWallet): Promise { const { publicKey } = await wallet.getPublicKey({ identityKey: true }) return publicKey @@ -76,6 +95,26 @@ describe('encryptEnvelope / decryptEnvelope', () => { expect(recovered).toBe(rpcRequest) }) + it('accepts ciphertext and plaintext bytes mangled by historical JSON wallet bridges', async () => { + const mobile = makeWallet() + const backend = makeWallet() + const mobilePub = await identityKey(mobile) + const backendPub = await identityKey(backend) + + const ciphertext = await encryptEnvelope( + withHistoricalJsonBytes(mobile), + { protocolID: PROTOCOL_ID, keyID: SESSION_ID, counterparty: backendPub }, + 'historical bridge payload' + ) + const recovered = await decryptEnvelope( + withHistoricalJsonBytes(backend), + { protocolID: PROTOCOL_ID, keyID: SESSION_ID, counterparty: mobilePub }, + ciphertext + ) + + expect(recovered).toBe('historical bridge payload') + }) + it('ciphertext is base64url (no +, /, or = characters)', async () => { const mobile = makeWallet() const backend = makeWallet() diff --git a/packages/wallet/ts-wallet-relay/tests/relay.e2e.test.ts b/packages/wallet/ts-wallet-relay/tests/relay.e2e.test.ts index 74c6c0ec3..aaaa631d3 100644 --- a/packages/wallet/ts-wallet-relay/tests/relay.e2e.test.ts +++ b/packages/wallet/ts-wallet-relay/tests/relay.e2e.test.ts @@ -327,6 +327,31 @@ describe('WalletRelayService E2E', () => { expect(rpc.error?.message).toBe('wallet unavailable') mobile.disconnect() }, 10_000) + + it('preserves BRC-100 byte fields across the encrypted relay in both directions', async () => { + const mobileWallet = new ProtoWallet(PrivateKey.fromRandom()) + const created = await service.createSession() + let receivedParams: unknown + const mobile = await pairMobile(created.pairingUri, mobileWallet, (_method, params) => { + receivedParams = params + return Promise.resolve({ + signableTransaction: { tx: new Uint8Array([4, 5, 6]), reference: 'cmVm' } + }) + }) + + const rpc = await service.sendRequest( + created.sessionId, + 'createAction', + { description: 'test action', inputBEEF: new Uint8Array([1, 2, 3]) }, + created.desktopToken + ) + + expect(receivedParams).toMatchObject({ inputBEEF: [1, 2, 3] }) + expect(rpc.result).toEqual({ + signableTransaction: { tx: [4, 5, 6], reference: 'cmVm' } + }) + mobile.disconnect() + }, 10_000) }) // ── QR signing ─────────────────────────────────────────────────────────────── diff --git a/packages/wallet/wallet-toolbox/CHANGELOG.md b/packages/wallet/wallet-toolbox/CHANGELOG.md index 6b6109b2f..c1416b71c 100644 --- a/packages/wallet/wallet-toolbox/CHANGELOG.md +++ b/packages/wallet/wallet-toolbox/CHANGELOG.md @@ -6,6 +6,12 @@ attention to changes that materially alter behavior or extend functionality. ## wallet-toolbox (unreleased) +- Serialize typed AtomicBEEF and competing BEEF in wallet review errors as + portable JSON arrays, keeping HTTP and relay error recovery compatible with + both historical array wallets and current binary Wallet Wire wallets. +- Serialize real typed arrays portably in outbound WAB requests while + preserving arbitrary inbound WAB JSON objects exactly. + - Accept legacy BRC-95 payment envelopes that include unrelated BEEF branches by reducing them to the declared transaction and its dependency closure before strict proof and BRC-29 validation. Malformed transactions, invalid diff --git a/packages/wallet/wallet-toolbox/client/package.json b/packages/wallet/wallet-toolbox/client/package.json index 2dfbb6f61..e4463788f 100644 --- a/packages/wallet/wallet-toolbox/client/package.json +++ b/packages/wallet/wallet-toolbox/client/package.json @@ -1,6 +1,6 @@ { "name": "@bsv/wallet-toolbox-client", - "version": "2.10.1", + "version": "2.10.2", "type": "module", "sideEffects": false, "engines": { @@ -69,7 +69,7 @@ "idb": "^8.0.3" }, "peerDependencies": { - "@bsv/sdk": "^2.4.0" + "@bsv/sdk": "^2.4.1" }, "peerDependenciesMeta": { "@bsv/sdk": { diff --git a/packages/wallet/wallet-toolbox/client/platform-budget.json b/packages/wallet/wallet-toolbox/client/platform-budget.json index 7516f1550..5dd65a8f6 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": 1603400, - "gzip": 378000, - "brotli": 296200 + "raw": 1607000, + "gzip": 378800, + "brotli": 297000 }, "esbuild": { - "raw": 1250600, - "gzip": 344100, - "brotli": 276500 + "raw": 1252500, + "gzip": 345000, + "brotli": 277300 } } } diff --git a/packages/wallet/wallet-toolbox/mobile/package.json b/packages/wallet/wallet-toolbox/mobile/package.json index fb7099058..08d60cc2e 100644 --- a/packages/wallet/wallet-toolbox/mobile/package.json +++ b/packages/wallet/wallet-toolbox/mobile/package.json @@ -1,6 +1,6 @@ { "name": "@bsv/wallet-toolbox-mobile", - "version": "2.10.1", + "version": "2.10.2", "type": "module", "sideEffects": false, "engines": { @@ -70,7 +70,7 @@ "idb": "^8.0.3" }, "peerDependencies": { - "@bsv/sdk": "^2.4.0" + "@bsv/sdk": "^2.4.1" }, "peerDependenciesMeta": { "@bsv/sdk": { diff --git a/packages/wallet/wallet-toolbox/mobile/platform-budget.json b/packages/wallet/wallet-toolbox/mobile/platform-budget.json index 7fa8087b9..ed26d710e 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": 3361000, - "gzip": 1362000, + "raw": 3367000, + "gzip": 1366000, "brotli": 1070000 } } diff --git a/packages/wallet/wallet-toolbox/package.json b/packages/wallet/wallet-toolbox/package.json index 4bad249b8..42e564efe 100644 --- a/packages/wallet/wallet-toolbox/package.json +++ b/packages/wallet/wallet-toolbox/package.json @@ -1,6 +1,6 @@ { "name": "@bsv/wallet-toolbox", - "version": "2.10.1", + "version": "2.10.2", "sideEffects": false, "type": "commonjs", "engines": { @@ -114,7 +114,7 @@ "typescript": "npm:@typescript/typescript6@6.0.2" }, "peerDependencies": { - "@bsv/sdk": "^2.4.0" + "@bsv/sdk": "^2.4.1" }, "peerDependenciesMeta": { "@bsv/sdk": { diff --git a/packages/wallet/wallet-toolbox/src/sdk/WERR_errors.ts b/packages/wallet/wallet-toolbox/src/sdk/WERR_errors.ts index ad7a819c0..dde240bec 100644 --- a/packages/wallet/wallet-toolbox/src/sdk/WERR_errors.ts +++ b/packages/wallet/wallet-toolbox/src/sdk/WERR_errors.ts @@ -1,4 +1,4 @@ -import { AtomicBEEF, OutpointString, SendWithResult, TXIDHexString, WalletNetwork } from '@bsv/sdk' +import { AtomicBEEF, OutpointString, SendWithResult, TXIDHexString, WalletNetwork, stringifyBRC100 } from '@bsv/sdk' import { WalletError } from './WalletError' import { ReviewActionResult } from './WalletStorage.interfaces' @@ -55,7 +55,7 @@ export class WERR_UTXO_REVIEW_INCONCLUSIVE extends WalletError { obj.checked = this.checked obj.confirmedSpent = this.confirmedSpent obj.unknown = this.unknown - return JSON.stringify(obj) + return stringifyBRC100(obj) } } @@ -77,7 +77,7 @@ class WERRActionBatchState extends WalletError { const obj = JSON.parse(super.toJson()) obj.state = this.state obj.batchId = this.batchId - return JSON.stringify(obj) + return stringifyBRC100(obj) } } @@ -109,7 +109,7 @@ export class WERR_INVALID_PARAMETER extends WalletError { const obj = JSON.parse(super.toJson()) obj.code = 6 // Must match HTTPWalletJSON.ts code obj.parameter = this.parameter - return JSON.stringify(obj) + return stringifyBRC100(obj) } } @@ -138,7 +138,7 @@ export class WERR_INVALID_MERKLE_ROOT extends WalletError { obj.blockHeight = this.blockHeight obj.merkleRoot = this.merkleRoot obj.txid = this.txid - return JSON.stringify(obj) + return stringifyBRC100(obj) } } @@ -155,7 +155,7 @@ export class WERR_MISSING_PARAMETER extends WalletError { override toJson(): string { const obj = JSON.parse(super.toJson()) obj.parameter = this.parameter - return JSON.stringify(obj) + return stringifyBRC100(obj) } } @@ -225,7 +225,7 @@ export class WERR_INSUFFICIENT_FUNDS extends WalletError { obj.code = 7 // Must match HTTPWalletJSON.ts code obj.totalSatoshisNeeded = this.totalSatoshisNeeded obj.moreSatoshisNeeded = this.moreSatoshisNeeded - return JSON.stringify(obj) + return stringifyBRC100(obj) } } @@ -248,7 +248,7 @@ export class WERR_INVALID_PUBLIC_KEY extends WalletError { protected override toJson(): string { const obj = JSON.parse(super.toJson()) obj.key = this.key - return JSON.stringify(obj) + return stringifyBRC100(obj) } } @@ -281,7 +281,7 @@ export class WERR_REVIEW_ACTIONS extends WalletError { obj.txid = this.txid obj.tx = this.tx obj.noSendChange = this.noSendChange - return JSON.stringify(obj) + return stringifyBRC100(obj) } } diff --git a/packages/wallet/wallet-toolbox/src/sdk/__test/WalletError.test.ts b/packages/wallet/wallet-toolbox/src/sdk/__test/WalletError.test.ts index 9f2f0ed1b..434608ded 100644 --- a/packages/wallet/wallet-toolbox/src/sdk/__test/WalletError.test.ts +++ b/packages/wallet/wallet-toolbox/src/sdk/__test/WalletError.test.ts @@ -22,10 +22,17 @@ import { const mockWalletStorage = { createAction: jest.fn().mockImplementation((_args: any) => { throw new WERR_REVIEW_ACTIONS( - [{ txid: 'txid123', status: 'doubleSpend', competingTxs: ['txid456'], competingBeef: [0, 1, 2, 3] }], + [ + { + txid: 'txid123', + status: 'doubleSpend', + competingTxs: ['txid456'], + competingBeef: new Uint8Array([0, 1, 2, 3]) + } + ], [{ txid: 'txid123', status: 'failed' }], 'txid123', - [5, 6, 7, 8], + new Uint8Array([5, 6, 7, 8]), ['00'.repeat(32) + '.0'] ) }) @@ -51,6 +58,7 @@ describe('WalletError tests', () => { { txid: 'txid123', status: 'doubleSpend', competingTxs: ['txid456'], competingBeef: [0, 1, 2, 3] } ]) expect(werr3.sendWithResults).toEqual([{ txid: 'txid123', status: 'failed' }]) + expect(werr3.tx).toEqual([5, 6, 7, 8]) expect(werr3.noSendChange).toEqual(['00'.repeat(32) + '.0']) } }) diff --git a/packages/wallet/wallet-toolbox/src/wab-client/WABTransport.ts b/packages/wallet/wallet-toolbox/src/wab-client/WABTransport.ts index 5dd08f3d0..fbe8bc658 100644 --- a/packages/wallet/wallet-toolbox/src/wab-client/WABTransport.ts +++ b/packages/wallet/wallet-toolbox/src/wab-client/WABTransport.ts @@ -1,4 +1,4 @@ -import { Telemetry, TelemetryConfig } from '@bsv/sdk' +import { stringifyBRC100, Telemetry, TelemetryConfig } from '@bsv/sdk' const DEFAULT_TIMEOUT_MS = 10_000 const DEFAULT_MAX_REQUEST_BYTES = 1024 * 1024 @@ -281,7 +281,7 @@ export class WABTransport { private bodyFor(options: WABRequestOptions, metadata: WABRequestMetadata): string | undefined { let body: string | undefined try { - body = options.body === undefined ? undefined : JSON.stringify(options.body) + body = options.body === undefined ? undefined : stringifyBRC100(options.body) } catch (cause) { const error = new WABClientError('WAB_INVALID_REQUEST', 'WAB request encoding failed.', false, undefined, { ...metadata.errorContext, diff --git a/packages/wallet/wallet-toolbox/src/wab-client/__tests/WABTransport.test.ts b/packages/wallet/wallet-toolbox/src/wab-client/__tests/WABTransport.test.ts index eba90fbc3..f1458d577 100644 --- a/packages/wallet/wallet-toolbox/src/wab-client/__tests/WABTransport.test.ts +++ b/packages/wallet/wallet-toolbox/src/wab-client/__tests/WABTransport.test.ts @@ -232,6 +232,20 @@ describe('WAB transport hardening', () => { }) }) + it('serializes typed request bytes without rewriting opaque response objects', async () => { + const historicalTx = JSON.parse(JSON.stringify(new Uint8Array([4, 5, 6]))) + const fetchClient = jest.fn(async () => jsonResponse({ tx: historicalTx })) as typeof fetch + const client = new WABClient('https://wab.example', { fetch: fetchClient }) + + const result = await client.transport.request<{ tx: Record }>('/test', { + operation: 'byte-compatibility', + body: { transaction: new Uint8Array([1, 2, 3]) } + }) + + expect(JSON.parse(String(fetchClient.mock.calls[0][1]?.body))).toEqual({ transaction: [1, 2, 3] }) + expect(result.tx).toEqual(historicalTx) + }) + it('sends correlation IDs without telemetry and preserves request context on errors', async () => { const fetchClient = jest.fn(async () => jsonResponse({ message: 'not found' }, 404)) as typeof fetch const client = new WABClient('https://wab.example/customer', { fetch: fetchClient }) diff --git a/scripts/brc100-byte-boundary.test.mjs b/scripts/brc100-byte-boundary.test.mjs new file mode 100644 index 000000000..1cec7271b --- /dev/null +++ b/scripts/brc100-byte-boundary.test.mjs @@ -0,0 +1,123 @@ +import assert from 'node:assert/strict' +import { readFile } from 'node:fs/promises' +import test from 'node:test' + +const walletBoundaryFiles = [ + 'packages/sdk/src/wallet/substrates/HTTPWalletJSON.ts', + 'packages/sdk/src/wallet/substrates/ReactNativeWebView.ts', + 'packages/sdk/src/overlay-tools/LookupResolver.ts', + 'packages/sdk/src/auth/transports/SimplifiedFetchTransport.ts', + 'packages/messaging/authsocket-client/src/AuthSocketClient.ts', + 'packages/messaging/authsocket/src/AuthSocketServer.ts', + 'packages/messaging/message-box-client/src/MessageBoxClient.ts', + 'packages/messaging/message-box-client/src/PeerPayClient.ts', + 'packages/messaging/message-box-client/src/PeerTokenClient.ts', + 'packages/messaging/message-box-client/src/RemittanceAdapter.ts', + 'packages/helpers/simple/src/modules/messagebox.ts', + 'packages/helpers/simple/src/modules/tokens.ts', + 'packages/helpers/simple/src/server/handler-types.ts', + 'packages/wallet/btms/src/BTMS.ts', + 'packages/wallet/ts-wallet-relay/src/client/WalletPairingSession.ts', + 'packages/wallet/ts-wallet-relay/src/client/WalletRelayClient.ts', + 'packages/wallet/ts-wallet-relay/src/server/WalletRelayService.ts', + 'packages/wallet/ts-wallet-relay/src/server/WebSocketRelay.ts', + 'packages/wallet/ts-wallet-relay/src/shared/crypto.ts', + 'packages/wallet/ts-wallet-relay/src/shared/encoding.ts', + 'packages/wallet/wallet-toolbox/src/sdk/WERR_errors.ts' +] + +const byteSafeHelper = /(?:normalizeBRC100|toBRC100Portable|stringifyBRC100)/ + +for (const file of walletBoundaryFiles) { + test(`${file} keeps BRC-100 bytes out of raw JSON boundaries`, async () => { + const source = await readFile(file, 'utf8') + assert.doesNotMatch( + source, + /JSON\.stringify\s*\(/, + `${file} must use stringifyBRC100 so Uint8Array cannot become a numeric-key object` + ) + assert.match( + source, + byteSafeHelper, + `${file} must normalize or safely serialize BRC-100 byte fields at its boundary` + ) + }) +} + +const specializedBoundaries = [ + { + file: 'packages/middleware/auth-express-middleware/src/authMiddlewareHelpers.ts', + forbidden: /Utils\.toArray\(JSON\.stringify\(val\), 'utf8'\)/ + }, + { + file: 'packages/middleware/auth-express-middleware/src/index.ts', + forbidden: /(?:this\.setBody\(Utils\.toArray\(JSON\.stringify\(data\)|res\.send\(message\))/ + }, + { + file: 'packages/sdk/src/auth/clients/AuthFetch.ts', + forbidden: + /(?:const serialized = JSON\.stringify\(body\)|Utils\.toArray\(JSON\.stringify\(body\))/ + }, + { + file: 'packages/sdk/src/remittance/RemittanceManager.ts', + forbidden: /const body = JSON\.stringify\(env\)/ + }, + { + file: 'packages/sdk/src/wallet/WalletError.ts', + forbidden: /const json = JSON\.stringify\(e\)/ + }, + { + file: 'packages/wallet/wallet-toolbox/src/wab-client/WABTransport.ts', + forbidden: /JSON\.stringify\(options\.body\)/ + } +] + +for (const { file, forbidden } of specializedBoundaries) { + test(`${file} normalizes its wallet-byte-bearing JSON boundary`, async () => { + const source = await readFile(file, 'utf8') + assert.doesNotMatch(source, forbidden) + assert.match(source, byteSafeHelper) + }) +} + +const opaqueJsonBoundaries = [ + 'packages/helpers/simple/src/server/handler-types.ts', + 'packages/messaging/authsocket-client/src/AuthSocketClient.ts', + 'packages/messaging/authsocket/src/AuthSocketServer.ts', + 'packages/sdk/src/remittance/RemittanceManager.ts', + 'packages/wallet/ts-wallet-relay/src/server/WalletRequestHandler.ts', + 'packages/wallet/wallet-toolbox/src/wab-client/WABTransport.ts' +] + +for (const file of opaqueJsonBoundaries) { + test(`${file} never recursively reinterprets arbitrary JSON as wallet bytes`, async () => { + const source = await readFile(file, 'utf8') + assert.doesNotMatch(source, /normalizeBRC100WalletByteFields/) + }) +} + +test('the byte compatibility contract remains part of the public SDK wallet API', async () => { + const source = await readFile('packages/sdk/src/wallet/index.ts', 'utf8') + assert.match(source, /export \* from ['"]\.\/BRC100ByteEncoding\.js['"]/) +}) + +const dependentPackages = [ + 'packages/middleware/auth-express-middleware/package.json', + 'packages/messaging/authsocket-client/package.json', + 'packages/messaging/authsocket/package.json', + 'packages/messaging/message-box-client/package.json', + 'packages/helpers/simple/package.json', + 'packages/wallet/btms/package.json', + 'packages/wallet/ts-wallet-relay/package.json', + 'packages/wallet/wallet-toolbox/package.json', + 'packages/wallet/wallet-toolbox/client/package.json', + 'packages/wallet/wallet-toolbox/mobile/package.json' +] + +for (const file of dependentPackages) { + test(`${file} cannot resolve an SDK without the shared byte contract`, async () => { + const pkg = JSON.parse(await readFile(file, 'utf8')) + const sdkRange = pkg.dependencies?.['@bsv/sdk'] ?? pkg.peerDependencies?.['@bsv/sdk'] + assert.equal(sdkRange, '^2.4.1') + }) +} diff --git a/scripts/check-auth-express-consumers.mjs b/scripts/check-auth-express-consumers.mjs index 995d9e11b..32be3971a 100644 --- a/scripts/check-auth-express-consumers.mjs +++ b/scripts/check-auth-express-consumers.mjs @@ -111,7 +111,14 @@ async function pack(packageDirectory, temporaryDirectory) { return path.resolve(JSON.parse(stdout).filename) } -async function verifyProfile(packageName, source, tarball, profile, temporaryDirectory) { +async function verifyProfile( + packageName, + source, + tarball, + sdkTarball, + profile, + temporaryDirectory +) { const consumerDirectory = path.join( temporaryDirectory, `express-${profile.express.split('.')[0]}-consumer` @@ -137,7 +144,7 @@ async function verifyProfile(packageName, source, tarball, profile, temporaryDir '--no-fund', '--package-lock=false', tarball, - '@bsv/sdk@^2.1.6', + sdkTarball, `express@${profile.express}`, `@types/express@${profile.types}`, 'typescript@5.9.3' @@ -174,8 +181,16 @@ const packageDirectory = path.join(repositoryRoot, contract.relativeDirectory) const temporaryDirectory = await fs.mkdtemp(path.join(os.tmpdir(), 'auth-express-consumers-')) try { const tarball = await pack(packageDirectory, temporaryDirectory) + const sdkTarball = await pack(path.join(repositoryRoot, 'packages/sdk'), temporaryDirectory) for (const profile of profiles) { - await verifyProfile(packageName, contract.source, tarball, profile, temporaryDirectory) + await verifyProfile( + packageName, + contract.source, + tarball, + sdkTarball, + profile, + temporaryDirectory + ) } console.log(`Verified ${packageName} clean TypeScript consumers on Express 4 and 5.`) } finally { diff --git a/scripts/mutation-testing.mjs b/scripts/mutation-testing.mjs index 3b9a89dfb..bd3941062 100644 --- a/scripts/mutation-testing.mjs +++ b/scripts/mutation-testing.mjs @@ -134,12 +134,24 @@ async function changedConfiguredTargets(base, currentTargets) { const module = await import( `data:text/javascript;base64,${Buffer.from(source).toString('base64')}` ) - const baseTargets = module.buildMutationTargets(REPOSITORY_ROOT) + let baseTargets + try { + baseTargets = module.buildMutationTargets(REPOSITORY_ROOT) + } catch (error) { + return targetsForUnresolvedMutationRange(currentTargets, error) + } return [...new Set([...Object.keys(currentTargets), ...Object.keys(baseTargets)])].filter( id => JSON.stringify(currentTargets[id]) !== JSON.stringify(baseTargets[id]) ) } +export function targetsForUnresolvedMutationRange(currentTargets, _error) { + // If the base configuration cannot be evaluated, its full target diff is + // unknowable. Run every current target so a second config-only change cannot + // be silently omitted merely because the first stale marker named one file. + return Object.keys(currentTargets) +} + function readPolicy() { if (!fs.existsSync(POLICY_PATH)) return undefined return JSON.parse(fs.readFileSync(POLICY_PATH, 'utf8')) diff --git a/scripts/mutation-testing.test.mjs b/scripts/mutation-testing.test.mjs index 037ee07af..1adf31a8c 100644 --- a/scripts/mutation-testing.test.mjs +++ b/scripts/mutation-testing.test.mjs @@ -5,7 +5,8 @@ import { calculateMutationMetrics, evaluateMutationReport, parseArguments, - selectAffectedMutationTargets + selectAffectedMutationTargets, + targetsForUnresolvedMutationRange } from './mutation-testing.mjs' const targets = { @@ -89,6 +90,36 @@ test('affected mutation selection follows exact target inputs and changed govern assert.deepEqual(selectAffectedMutationTargets(targets, ['scripts/mutation-testing.mjs']), []) }) +test('mutation selection survives source edits that invalidate previous range markers', () => { + const rangedTargets = { + auth: { + mutate: ['src/auth/Transport.ts:10-20'] + }, + wallet: { + mutate: ['src/wallet/Wallet.ts:30-40'] + } + } + + assert.deepEqual( + targetsForUnresolvedMutationRange( + rangedTargets, + new Error('Unable to resolve mutation range in src/auth/Transport.ts: old .. new') + ), + ['auth', 'wallet'] + ) + assert.deepEqual( + targetsForUnresolvedMutationRange( + rangedTargets, + new Error('Unable to resolve mutation range in src/removed/File.ts: old .. new') + ), + ['auth', 'wallet'] + ) + assert.deepEqual(targetsForUnresolvedMutationRange(rangedTargets, new Error('unexpected')), [ + 'auth', + 'wallet' + ]) +}) + test('mutation report evaluation ratchets score, coverage, and invalid outcomes', () => { const policy = { targets: [ diff --git a/scripts/test-governance.test.mjs b/scripts/test-governance.test.mjs index 81aed36ef..df9027b3a 100644 --- a/scripts/test-governance.test.mjs +++ b/scripts/test-governance.test.mjs @@ -32,11 +32,11 @@ test('current required, manual, live, resource, and conformance tests are govern assert.deepEqual(result.errors, []) assert.equal(result.summary.requiredDirectSkips, 2) - assert.equal(result.summary.propertySuites, 29) + assert.equal(result.summary.propertySuites, 30) assert.equal(result.summary.propertyPackages, 28) assert.equal(result.summary.propertyExcludedPackages, 6) assert.equal(result.summary.propertyClassifiedPackages, 34) - assert.equal(result.summary.mutationTargets, 29) + assert.equal(result.summary.mutationTargets, 30) assert.equal(result.summary.manualAndLiveFiles, 32) assert.equal(result.summary.walletManualSuites, 30) assert.equal(result.summary.conformanceSkipFiles, 19)