From 08f8c976abdbefbdcd5edd460418855b8506e8dc Mon Sep 17 00:00:00 2001 From: Ty J Everett Date: Fri, 14 Aug 2026 15:18:05 -0700 Subject: [PATCH 01/14] fix(sdk): normalize BRC-29 wallet transaction bytes --- docs/packages/messaging/message-box-client.md | 8 +++-- docs/packages/sdk/bsv-sdk.md | 10 ++++-- docs/reference/package-api-migrations.md | 12 +++---- docs/reference/stack-facts.md | 4 +-- docs/specs/brc-29-peer-payment.md | 9 +++-- governance/package-release-notes.json | 16 ++++----- governance/repository-health/baselines.json | 4 +-- .../messaging/message-box-client/CHANGELOG.md | 5 +++ .../messaging/message-box-client/README.md | 3 ++ .../messaging/message-box-client/package.json | 4 +-- packages/sdk/CHANGELOG.md | 5 +++ packages/sdk/README.md | 5 +++ packages/sdk/package.json | 2 +- .../src/remittance/__tests/BasicBRC29.test.ts | 33 +++++++++++++++++++ .../sdk/src/remittance/modules/BasicBRC29.ts | 19 +++++++---- 15 files changed, 103 insertions(+), 36 deletions(-) diff --git a/docs/packages/messaging/message-box-client.md b/docs/packages/messaging/message-box-client.md index c2bf0f1d2..7db551799 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,8 @@ 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. ## Quick start 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/reference/package-api-migrations.md b/docs/reference/package-api-migrations.md index f216bf8db..f3f98ef80 100644 --- a/docs/reference/package-api-migrations.md +++ b/docs/reference/package-api-migrations.md @@ -38,14 +38,14 @@ and clean-consumer tests remain the executable type authority. | `@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 and current Uint8Array Wallet Wire substrates produce the same JSON-safe payment token. | | `@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/sdk` | `2.4.0` | `2.4.1` | patch | [API and usage](../packages/sdk/bsv-sdk.md) | No API migration is required. Historical number-array wallets remain unchanged, while binary Wallet Wire wallets now work with the same BRC-29 and Message Box payment primitives. | | `@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/templates` | `1.9.1` | `1.9.6` | patch | [API and usage](../packages/helpers/templates.md) | No consumer migration is required; template APIs, supported imports, and generated script semantics are unchanged. | | `@bsv/teranode-listener` | `1.1.1` | `1.1.4` | patch | [API and usage](../packages/network/teranode-listener.md) | No consumer migration is required; listener APIs, topics, and network configuration are unchanged. | @@ -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: Rebuilds BRC-29 payment transport against the SDK correction for binary Wallet Wire transaction results and requires the corrected SDK peer. +- Migration: No API migration is required. Upgrade @bsv/sdk and @bsv/message-box-client together; historical number-array wallets and current Uint8Array Wallet Wire substrates produce the same JSON-safe payment token. | 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: Restores BRC-29 payment construction for binary Wallet Wire substrates by accepting Uint8Array CreateActionResult transactions and emitting portable number-array settlements. +- Migration: No API migration is required. Historical number-array wallets remain unchanged, while binary Wallet Wire wallets now work with the same BRC-29 and Message Box payment primitives. | Public subpath | Runtime target(s) | Declaration target(s) | | ---------------------------------- | ---------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------- | diff --git a/docs/reference/stack-facts.md b/docs/reference/stack-facts.md index 685361356..cceddc884 100644 --- a/docs/reference/stack-facts.md +++ b/docs/reference/stack-facts.md @@ -48,7 +48,7 @@ authorized release action. | 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/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) | @@ -60,7 +60,7 @@ 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-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) | 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/package-release-notes.json b/governance/package-release-notes.json index 88a31ee47..91739501d 100644 --- a/governance/package-release-notes.json +++ b/governance/package-release-notes.json @@ -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": "Rebuilds BRC-29 payment transport against the SDK correction for binary Wallet Wire transaction results and requires the corrected SDK peer.", + "migration": "No API migration is required. Upgrade @bsv/sdk and @bsv/message-box-client together; historical number-array wallets and current Uint8Array Wallet Wire substrates produce the same JSON-safe payment token." }, { "name": "@bsv/overlay", @@ -145,10 +145,10 @@ }, { "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": "Restores BRC-29 payment construction for binary Wallet Wire substrates by accepting Uint8Array CreateActionResult transactions and emitting portable number-array settlements.", + "migration": "No API migration is required. Historical number-array wallets remain unchanged, while binary Wallet Wire wallets now work with the same BRC-29 and Message Box payment primitives." }, { "name": "@bsv/simple", diff --git a/governance/repository-health/baselines.json b/governance/repository-health/baselines.json index d65928b65..50c1fe602 100644 --- a/governance/repository-health/baselines.json +++ b/governance/repository-health/baselines.json @@ -308,7 +308,7 @@ "@bsv/templates": "1.9.6", "@bsv/authsocket": "2.1.5", "@bsv/authsocket-client": "2.1.4", - "@bsv/message-box-client": "2.4.0", + "@bsv/message-box-client": "2.4.1", "@bsv/paymail": "2.4.6", "@bsv/402-pay": "0.2.4", "@bsv/auth": "0.1.3", @@ -320,7 +320,7 @@ "@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-permission-module": "1.1.3", diff --git a/packages/messaging/message-box-client/CHANGELOG.md b/packages/messaging/message-box-client/CHANGELOG.md index 62875feeb..339ac1427 100644 --- a/packages/messaging/message-box-client/CHANGELOG.md +++ b/packages/messaging/message-box-client/CHANGELOG.md @@ -27,6 +27,11 @@ 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. + ### Security --- diff --git a/packages/messaging/message-box-client/README.md b/packages/messaging/message-box-client/README.md index 8bf146fc5..66dfaea36 100644 --- a/packages/messaging/message-box-client/README.md +++ b/packages/messaging/message-box-client/README.md @@ -192,6 +192,9 @@ 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. ## 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/sdk/CHANGELOG.md b/packages/sdk/CHANGELOG.md index 41217f85f..c60707b71 100644 --- a/packages/sdk/CHANGELOG.md +++ b/packages/sdk/CHANGELOG.md @@ -299,6 +299,11 @@ 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. diff --git a/packages/sdk/README.md b/packages/sdk/README.md index 406b01784..de365360b 100644 --- a/packages/sdk/README.md +++ b/packages/sdk/README.md @@ -10,6 +10,11 @@ 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. + ## Table of Contents 1. [Objective](#objective) diff --git a/packages/sdk/package.json b/packages/sdk/package.json index cb0dd9be3..78df45594 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" 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/modules/BasicBRC29.ts b/packages/sdk/src/remittance/modules/BasicBRC29.ts index cc5dff236..7d7f0eaa3 100644 --- a/packages/sdk/src/remittance/modules/BasicBRC29.ts +++ b/packages/sdk/src/remittance/modules/BasicBRC29.ts @@ -243,7 +243,8 @@ export class Brc29RemittanceModule if (tx == null) { return terminate('brc29.missing_tx', 'wallet.createAction did not return a transaction.') } - if (!isAtomicBeef(tx)) { + const transaction = toPortableTransaction(tx) + if (transaction == null) { return terminate('brc29.invalid_tx', 'wallet.createAction returned an invalid transaction payload.') } @@ -251,7 +252,7 @@ export class Brc29RemittanceModule action: 'settle', artifact: { customInstructions: { derivationPrefix, derivationSuffix }, - transaction: tx, + transaction, amountSatoshis: option.amountSatoshis, outputIndex: option.outputIndex ?? 0 } @@ -355,15 +356,19 @@ 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 = tx instanceof Uint8Array ? [...tx] : tx + return Array.isArray(bytes) && bytes.length > 0 && + bytes.every(byte => Number.isInteger(byte) && byte >= 0 && byte <= 255) + ? bytes + : undefined } function isNonEmptyString(value: unknown): value is string { From 42acb2538c839e768c1e8d6d54bcca73201c3b2e Mon Sep 17 00:00:00 2001 From: Ty J Everett Date: Fri, 14 Aug 2026 15:24:56 -0700 Subject: [PATCH 02/14] fix(message-box): recover JSON typed-array payments --- docs/packages/messaging/message-box-client.md | 4 +- docs/reference/package-api-migrations.md | 6 +-- governance/package-release-notes.json | 4 +- .../messaging/message-box-client/CHANGELOG.md | 4 +- .../messaging/message-box-client/README.md | 3 +- .../message-box-client/src/PeerPayClient.ts | 14 ++++++- .../src/__tests/PeerPayClientUnit.test.ts | 42 +++++++++++++++++++ 7 files changed, 67 insertions(+), 10 deletions(-) diff --git a/docs/packages/messaging/message-box-client.md b/docs/packages/messaging/message-box-client.md index 7db551799..412b48b35 100644 --- a/docs/packages/messaging/message-box-client.md +++ b/docs/packages/messaging/message-box-client.md @@ -28,7 +28,9 @@ 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. +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/reference/package-api-migrations.md b/docs/reference/package-api-migrations.md index f3f98ef80..359cc4e5f 100644 --- a/docs/reference/package-api-migrations.md +++ b/docs/reference/package-api-migrations.md @@ -38,7 +38,7 @@ and clean-consumer tests remain the executable type authority. | `@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.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 and current Uint8Array Wallet Wire substrates produce the same JSON-safe payment token. | +| `@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 and current Uint8Array Wallet Wire substrates produce the same JSON-safe payment token, while pending numeric-key tokens remain receivable. | | `@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. | @@ -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: Rebuilds BRC-29 payment transport against the SDK correction for binary Wallet Wire transaction results and requires the corrected SDK peer. -- Migration: No API migration is required. Upgrade @bsv/sdk and @bsv/message-box-client together; historical number-array wallets and current Uint8Array Wallet Wire substrates produce the same JSON-safe payment token. +- Release note: Rebuilds BRC-29 payment transport against the SDK correction for binary Wallet Wire transaction results and recovers pending typed-array tokens serialized through JSON. +- Migration: No API migration is required. Upgrade @bsv/sdk and @bsv/message-box-client together; historical number-array wallets and current Uint8Array Wallet Wire substrates produce the same JSON-safe payment token, while pending numeric-key tokens remain receivable. | Public subpath | Runtime target(s) | Declaration target(s) | | ---------------- | ----------------------------------- | --------------------------------------- | diff --git a/governance/package-release-notes.json b/governance/package-release-notes.json index 91739501d..47d3798a5 100644 --- a/governance/package-release-notes.json +++ b/governance/package-release-notes.json @@ -98,8 +98,8 @@ "name": "@bsv/message-box-client", "publishedVersion": "2.4.0", "releaseType": "patch", - "summary": "Rebuilds BRC-29 payment transport against the SDK correction for binary Wallet Wire transaction results and requires the corrected SDK peer.", - "migration": "No API migration is required. Upgrade @bsv/sdk and @bsv/message-box-client together; historical number-array wallets and current Uint8Array Wallet Wire substrates produce the same JSON-safe payment token." + "summary": "Rebuilds BRC-29 payment transport against the SDK correction for binary Wallet Wire transaction results and recovers pending typed-array tokens serialized through JSON.", + "migration": "No API migration is required. Upgrade @bsv/sdk and @bsv/message-box-client together; historical number-array wallets and current Uint8Array Wallet Wire substrates produce the same JSON-safe payment token, while pending numeric-key tokens remain receivable." }, { "name": "@bsv/overlay", diff --git a/packages/messaging/message-box-client/CHANGELOG.md b/packages/messaging/message-box-client/CHANGELOG.md index 339ac1427..db0830440 100644 --- a/packages/messaging/message-box-client/CHANGELOG.md +++ b/packages/messaging/message-box-client/CHANGELOG.md @@ -30,7 +30,9 @@ All notable changes to this project will be documented in this file. The format - 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. + unchanged. PeerPay receipt also recovers the numeric-key object produced when + a typed array crosses JSON transport, preserving pending cross-version + payments. ### Security diff --git a/packages/messaging/message-box-client/README.md b/packages/messaging/message-box-client/README.md index 66dfaea36..8b88fc42a 100644 --- a/packages/messaging/message-box-client/README.md +++ b/packages/messaging/message-box-client/README.md @@ -194,7 +194,8 @@ 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. +byte-array representation. Receipt remains compatible with pending messages +whose typed-array bytes were already serialized as contiguous numeric keys. ## Token settlement diff --git a/packages/messaging/message-box-client/src/PeerPayClient.ts b/packages/messaging/message-box-client/src/PeerPayClient.ts index d8bb26b19..3ab39ebcf 100644 --- a/packages/messaging/message-box-client/src/PeerPayClient.ts +++ b/packages/messaging/message-box-client/src/PeerPayClient.ts @@ -32,8 +32,18 @@ import { import * as Logger from './Utils/logger.js' -function toNumberArray(tx: AtomicBEEF): number[] { - return Array.isArray(tx) ? tx : Array.from(tx) +function toNumberArray(tx: unknown): number[] { + if (Array.isArray(tx)) return tx + if (tx instanceof Uint8Array) return Array.from(tx) + + if (tx != null && typeof tx === 'object') { + const entries = Object.entries(tx) + if (entries.length > 0 && entries.every(([key], index) => key === String(index))) { + return entries.map(([, byte]) => byte as number) + } + } + + return [] } function hexToBytes(hex: string): number[] { 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 From a599bde535076a3ddf1482d1a653391d0a7c6758 Mon Sep 17 00:00:00 2001 From: Ty J Everett Date: Fri, 14 Aug 2026 16:36:21 -0700 Subject: [PATCH 03/14] fix: normalize BRC-100 bytes across transports --- docs/packages/helpers/simple.md | 6 +- docs/packages/messaging/authsocket-client.md | 6 +- docs/packages/messaging/authsocket.md | 6 +- .../middleware/auth-express-middleware.md | 6 +- docs/packages/wallet/btms.md | 6 +- docs/packages/wallet/wallet-relay.md | 6 +- docs/packages/wallet/wallet-toolbox-client.md | 6 +- docs/packages/wallet/wallet-toolbox-mobile.md | 6 +- docs/packages/wallet/wallet-toolbox.md | 6 +- docs/reference/package-api-migrations.md | 70 ++++---- docs/reference/stack-facts.md | 18 +- governance/mutation-testing/targets.mjs | 2 +- governance/package-release-notes.json | 46 ++--- governance/repository-health/baselines.json | 18 +- packages/helpers/simple/README.md | 5 + packages/helpers/simple/package.json | 4 +- .../helpers/simple/src/modules/messagebox.ts | 21 ++- packages/helpers/simple/src/modules/tokens.ts | 31 +++- .../messaging/authsocket-client/README.md | 5 + .../messaging/authsocket-client/package.json | 4 +- .../authsocket-client/src/AuthSocketClient.ts | 8 +- .../src/__tests__/AuthSocketClient.test.ts | 23 +++ packages/messaging/authsocket/README.md | 5 + packages/messaging/authsocket/package.json | 4 +- .../authsocket/src/AuthSocketServer.ts | 15 +- .../src/__tests__/AuthSocket.test.ts | 17 ++ .../AuthSocketServer.lifecycle.test.ts | 1 + .../messaging/message-box-client/CHANGELOG.md | 5 + .../messaging/message-box-client/README.md | 4 + .../src/MessageBoxClient.ts | 77 ++++---- .../message-box-client/src/PeerPayClient.ts | 45 ++--- .../message-box-client/src/PeerTokenClient.ts | 35 ++-- .../src/RemittanceAdapter.ts | 4 +- .../__tests/MessageBoxClientHardening.test.ts | 26 ++- .../src/__tests/PeerTokenClientUnit.test.ts | 52 ++++++ .../src/__tests/RemittanceAdapter.test.ts | 19 ++ .../auth-express-middleware/README.md | 5 + .../auth-express-middleware/package.json | 4 +- .../__tests/ExpressTransportHardening.test.ts | 5 +- .../authMiddlewareHelpers.property.test.ts | 20 +++ .../src/authMiddlewareHelpers.ts | 4 +- .../auth-express-middleware/src/index.ts | 8 +- packages/sdk/CHANGELOG.md | 16 +- packages/sdk/browser-budget.json | 6 +- packages/sdk/src/auth/clients/AuthFetch.ts | 5 +- .../__tests__/AuthFetch.additional.test.ts | 10 ++ .../transports/SimplifiedFetchTransport.ts | 10 +- ...implifiedFetchTransport.additional.test.ts | 34 ++++ .../sdk/src/remittance/RemittanceManager.ts | 5 +- .../__tests/RemittanceManager.test.ts | 22 +++ .../sdk/src/remittance/modules/BasicBRC29.ts | 70 ++++++-- packages/sdk/src/wallet/BRC100ByteEncoding.ts | 164 ++++++++++++++++++ packages/sdk/src/wallet/WalletError.ts | 4 +- .../wallet/__tests/BRC100ByteEncoding.test.ts | 122 +++++++++++++ .../src/wallet/__tests/WalletError.test.ts | 14 +- packages/sdk/src/wallet/index.ts | 1 + .../src/wallet/substrates/HTTPWalletJSON.ts | 7 +- .../wallet/substrates/ReactNativeWebView.ts | 23 ++- .../substrates/__tests/HTTPWalletJSON.test.ts | 162 +++++++++-------- .../__tests/ReactNativeWebView.test.ts | 49 +++++- .../__tests/jsonByteEncoding.test.ts | 25 ++- .../substrates/utils/jsonByteEncoding.ts | 35 ++-- packages/wallet/btms/README.md | 5 + packages/wallet/btms/package.json | 4 +- packages/wallet/btms/src/BTMS.ts | 67 +++---- packages/wallet/btms/src/BTMSHelpers.ts | 12 +- .../src/__tests/BTMSHelpers.property.test.ts | 8 + packages/wallet/ts-wallet-relay/README.md | 5 + packages/wallet/ts-wallet-relay/package.json | 4 +- .../src/client/WalletPairingSession.ts | 13 +- .../src/client/WalletRelayClient.ts | 8 +- .../src/server/WalletRelayService.ts | 7 +- .../src/server/WalletRequestHandler.ts | 3 +- .../src/server/WebSocketRelay.ts | 9 +- .../ts-wallet-relay/src/shared/crypto.ts | 6 +- .../ts-wallet-relay/src/shared/encoding.ts | 6 +- .../tests/WalletRelayClient.test.ts | 24 +++ .../ts-wallet-relay/tests/crypto.test.ts | 39 +++++ .../ts-wallet-relay/tests/relay.e2e.test.ts | 25 +++ packages/wallet/wallet-toolbox/CHANGELOG.md | 6 + .../wallet/wallet-toolbox/client/package.json | 4 +- .../wallet/wallet-toolbox/mobile/package.json | 4 +- packages/wallet/wallet-toolbox/package.json | 4 +- .../wallet-toolbox/src/sdk/WERR_errors.ts | 18 +- .../src/sdk/__test/WalletError.test.ts | 12 +- .../src/wab-client/WABTransport.ts | 6 +- .../wab-client/__tests/WABTransport.test.ts | 14 ++ scripts/brc100-byte-boundary.test.mjs | 106 +++++++++++ scripts/check-auth-express-consumers.mjs | 21 ++- 89 files changed, 1450 insertions(+), 438 deletions(-) create mode 100644 packages/sdk/src/wallet/BRC100ByteEncoding.ts create mode 100644 packages/sdk/src/wallet/__tests/BRC100ByteEncoding.test.ts create mode 100644 scripts/brc100-byte-boundary.test.mjs 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/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/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 359cc4e5f..9a514777e 100644 --- a/docs/reference/package-api-migrations.md +++ b/docs/reference/package-api-migrations.md @@ -3,8 +3,8 @@ id: package-api-migrations title: 'Package API, Declarations, and Migration Ledger' kind: reference version: '1.0.0' -last_updated: '2026-08-13' -last_verified: '2026-08-13' +last_updated: '2026-08-14' +last_verified: '2026-08-14' review_cadence_days: 30 status: stable tags: [reference, packages, api, declarations, migrations, release-notes] @@ -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 and number arrays are unchanged; upgrade to @bsv/sdk 2.4.1 or later to send current typed wallet bytes portably and recover historical numeric-key byte fields after signature verification. | +| `@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 and number arrays are unchanged; upgrade to @bsv/sdk 2.4.1 or later to send current typed wallet bytes portably and recover historical numeric-key byte fields after signature verification. | +| `@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.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 and current Uint8Array Wallet Wire substrates produce the same JSON-safe payment token, while pending numeric-key tokens remain receivable. | +| `@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.4.0` | `2.4.1` | patch | [API and usage](../packages/sdk/bsv-sdk.md) | No API migration is required. Historical number-array wallets remain unchanged, while binary Wallet Wire wallets now work with the same BRC-29 and Message Box payment primitives. | -| `@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 peers remain compatible through portable number-array output and legacy numeric-key input 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.9.6` | patch | [API and usage](../packages/helpers/templates.md) | No consumer migration is required; template APIs, supported imports, and generated script semantics are unchanged. | | `@bsv/teranode-listener` | `1.1.1` | `1.1.4` | patch | [API and usage](../packages/network/teranode-listener.md) | No consumer migration is required; listener APIs, topics, and network configuration are unchanged. | | `@bsv/verifast` | `0.3.0` | `0.3.4` | patch | [API and usage](../packages/sdk/verifast.md) | No consumer migration is required; exports, verification behavior, worker protocols, package paths, and runtime defaults are unchanged. | | `@bsv/wallet-helper` | `0.1.1` | `0.1.6` | patch | [API and usage](../packages/helpers/wallet-helper.md) | No consumer migration is required; fluent builder APIs and transaction semantics are unchanged. | -| `@bsv/wallet-relay` | `0.2.2` | `0.3.4` | minor | [API and usage](../packages/wallet/wallet-relay.md) | QRPairingCode now renders a native button and accepts button wrapper attributes. Existing className, style, data, and ARIA props continue to work; update div-specific wrapper selectors or explicitly typed div event handlers. Express integrations now use the host application's matching Express runtime and type graph. | -| `@bsv/wallet-toolbox` | `2.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, and preserves BRC-100 byte fields in signed event envelopes across number-array, Uint8Array, and historical numeric-key JSON runtimes. +- Migration: No API migration is required. Existing event data and number arrays are unchanged; upgrade to @bsv/sdk 2.4.1 or later to send current typed wallet bytes portably and recover historical numeric-key byte fields after signature verification. | 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, and preserves BRC-100 byte fields in signed event envelopes across number-array, Uint8Array, and historical numeric-key JSON runtimes. +- Migration: No API migration is required. Existing event data and number arrays are unchanged; upgrade to @bsv/sdk 2.4.1 or later to send current typed wallet bytes portably and recover historical numeric-key byte fields after signature verification. | 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: Rebuilds BRC-29 payment transport against the SDK correction for binary Wallet Wire transaction results and recovers pending typed-array tokens serialized through JSON. -- Migration: No API migration is required. Upgrade @bsv/sdk and @bsv/message-box-client together; historical number-array wallets and current Uint8Array Wallet Wire substrates produce the same JSON-safe payment token, while pending numeric-key tokens remain receivable. +- 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: Restores BRC-29 payment construction for binary Wallet Wire substrates by accepting Uint8Array CreateActionResult transactions and emitting portable number-array settlements. -- Migration: No API migration is required. Historical number-array wallets remain unchanged, while binary Wallet Wire wallets now work with the same BRC-29 and Message Box payment primitives. +- Release note: Adds one strict BRC-100 byte-boundary contract and applies it to HTTP, React Native, authenticated fetch, AuthSocket, remittance, wallet-error, and BRC-29 paths so number arrays, Uint8Array values, and historical numeric-key JSON payloads interoperate. +- 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 peers remain compatible through portable number-array output and legacy numeric-key input 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 and token send/receive 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 and preserves typed AtomicBEEF, competing BEEF, WAB request/response bytes, and wallet review errors across portable and historical JSON representations. +- 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 cceddc884..4b6b89f42 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.9.6` | 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/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) | @@ -62,12 +62,12 @@ authorized release action. | 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.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/governance/mutation-testing/targets.mjs b/governance/mutation-testing/targets.mjs index a9e0b518f..6d5459392 100644 --- a/governance/mutation-testing/targets.mjs +++ b/governance/mutation-testing/targets.mjs @@ -112,7 +112,7 @@ export function buildMutationTargets(repositoryRoot) { ], [ 'src/auth/transports/SimplifiedFetchTransport.ts', - 'await this.onDataCallback!((await response.json()) as AuthMessage)', + 'await this.onDataCallback!(', "if (message.messageType === 'initialRequest') resolve()" ], [ diff --git a/governance/package-release-notes.json b/governance/package-release-notes.json index 47d3798a5..e2de5c833 100644 --- a/governance/package-release-notes.json +++ b/governance/package-release-notes.json @@ -1,6 +1,6 @@ { "schemaVersion": 1, - "lastReviewed": "2026-08-13", + "lastReviewed": "2026-08-14", "owner": "ts-stack-maintainers", "entries": [ { @@ -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, and preserves BRC-100 byte fields in signed event envelopes across number-array, Uint8Array, and historical numeric-key JSON runtimes.", + "migration": "No API migration is required. Existing event data and number arrays are unchanged; upgrade to @bsv/sdk 2.4.1 or later to send current typed wallet bytes portably and recover historical numeric-key byte fields after signature verification." }, { "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, and preserves BRC-100 byte fields in signed event envelopes across number-array, Uint8Array, and historical numeric-key JSON runtimes.", + "migration": "No API migration is required. Existing event data and number arrays are unchanged; upgrade to @bsv/sdk 2.4.1 or later to send current typed wallet bytes portably and recover historical numeric-key byte fields after signature verification." }, { "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", @@ -98,8 +98,8 @@ "name": "@bsv/message-box-client", "publishedVersion": "2.4.0", "releaseType": "patch", - "summary": "Rebuilds BRC-29 payment transport against the SDK correction for binary Wallet Wire transaction results and recovers pending typed-array tokens serialized through JSON.", - "migration": "No API migration is required. Upgrade @bsv/sdk and @bsv/message-box-client together; historical number-array wallets and current Uint8Array Wallet Wire substrates produce the same JSON-safe payment token, while pending numeric-key tokens remain receivable." + "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", @@ -147,15 +147,15 @@ "name": "@bsv/sdk", "publishedVersion": "2.4.0", "releaseType": "patch", - "summary": "Restores BRC-29 payment construction for binary Wallet Wire substrates by accepting Uint8Array CreateActionResult transactions and emitting portable number-array settlements.", - "migration": "No API migration is required. Historical number-array wallets remain unchanged, while binary Wallet Wire wallets now work with the same BRC-29 and Message Box payment primitives." + "summary": "Adds one strict BRC-100 byte-boundary contract and applies it to HTTP, React Native, authenticated fetch, AuthSocket, remittance, wallet-error, and BRC-29 paths so number arrays, Uint8Array values, and historical numeric-key JSON payloads interoperate.", + "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 peers remain compatible through portable number-array output and legacy numeric-key input 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 and token send/receive 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 and preserves typed AtomicBEEF, competing BEEF, WAB request/response bytes, and wallet review errors across portable and historical JSON representations.", + "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 50c1fe602..11a3024d0 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.9.6", - "@bsv/authsocket": "2.1.5", - "@bsv/authsocket-client": "2.1.4", + "@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", @@ -322,11 +322,11 @@ "@bsv/overlay-topics": "1.7.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/packages/helpers/simple/README.md b/packages/helpers/simple/README.md index ca8e4e13c..bba306ce1 100644 --- a/packages/helpers/simple/README.md +++ b/packages/helpers/simple/README.md @@ -40,6 +40,11 @@ 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. + ## 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/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/messaging/authsocket-client/README.md b/packages/messaging/authsocket-client/README.md index e1a7b3905..4b7a1a99c 100644 --- a/packages/messaging/authsocket-client/README.md +++ b/packages/messaging/authsocket-client/README.md @@ -64,6 +64,11 @@ 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 BRC-100 byte fields across historical and +current runtimes. Existing `number[]`, current `Uint8Array`, and legacy +numeric-key JSON representations are delivered as portable number arrays; +sparse or invalid byte records are not coerced. + ### 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..ccd58cfba 100644 --- a/packages/messaging/authsocket-client/src/AuthSocketClient.ts +++ b/packages/messaging/authsocket-client/src/AuthSocketClient.ts @@ -11,7 +11,9 @@ import { Peer, WalletInterface, Utils, - OriginatorDomainNameStringUnder250Bytes + OriginatorDomainNameStringUnder250Bytes, + normalizeBRC100WalletByteFields, + stringifyBRC100 } from '@bsv/sdk' import { SocketClientTransport } from './SocketClientTransport.js' @@ -31,7 +33,7 @@ export type AuthSocketClientErrorHandler = ( export function decodeAuthSocketEventPayload(payload: number[]): { eventName: string; data: any } { try { const str = Utils.toUTF8(payload) - const decoded: unknown = JSON.parse(str) + const decoded: unknown = normalizeBRC100WalletByteFields(JSON.parse(str)) if ( decoded === null || typeof decoded !== 'object' || @@ -167,7 +169,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..6c80cd6a4 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,28 @@ describe('AuthSocketClient', () => { expect(identityKey).toBe('server-key') }) + it('normalizes wallet bytes in both authenticated event directions', () => { + 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: [4, 5, 6] }) + const [payload] = mockPeer.toPeer.mock.calls[0] + expect(JSON.parse(Buffer.from(payload).toString('utf8'))).toEqual({ + eventName: 'payment', + data: { transaction: [1, 2, 3] } + }) + }) + 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..bccd01aa3 100644 --- a/packages/messaging/authsocket/README.md +++ b/packages/messaging/authsocket/README.md @@ -91,6 +91,11 @@ 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 BRC-100 byte fields across historical and +current runtimes. Existing `number[]`, current `Uint8Array`, and legacy +numeric-key JSON representations are delivered as portable number arrays; +sparse or invalid byte records are not coerced. + 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..d4eb548c9 100644 --- a/packages/messaging/authsocket/src/AuthSocketServer.ts +++ b/packages/messaging/authsocket/src/AuthSocketServer.ts @@ -1,6 +1,13 @@ 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, + normalizeBRC100WalletByteFields, + stringifyBRC100 +} from '@bsv/sdk' import { SocketServerTransport } from './SocketServerTransport.js' export type AuthSocketErrorPhase = 'authentication' | 'application' | 'connection' | 'send' @@ -19,7 +26,7 @@ export type AuthSocketErrorHandler = ( export function decodeAuthSocketEventPayload(payload: number[]): { eventName: string; data: any } { try { const str = Buffer.from(payload).toString('utf8') - const decoded: unknown = JSON.parse(str) + const decoded: unknown = normalizeBRC100WalletByteFields(JSON.parse(str)) if ( decoded === null || typeof decoded !== 'object' || @@ -251,7 +258,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 +372,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..da0be3758 100644 --- a/packages/messaging/authsocket/src/__tests__/AuthSocket.test.ts +++ b/packages/messaging/authsocket/src/__tests__/AuthSocket.test.ts @@ -65,6 +65,23 @@ describe('AuthSocket', () => { expect(identityKey).toBe('peer-key') }) + it('normalizes wallet bytes in both authenticated event directions', 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: [4, 5, 6] }) + const [payload] = peer.toPeer.mock.calls[0] + expect(JSON.parse(Buffer.from(payload).toString('utf8'))).toEqual({ + eventName: 'payment', + data: { transaction: [1, 2, 3] } + }) + }) + 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 db0830440..ddf8763fb 100644 --- a/packages/messaging/message-box-client/CHANGELOG.md +++ b/packages/messaging/message-box-client/CHANGELOG.md @@ -33,6 +33,11 @@ All notable changes to this project will be documented in this file. The format 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 8b88fc42a..8a190bc2c 100644 --- a/packages/messaging/message-box-client/README.md +++ b/packages/messaging/message-box-client/README.md @@ -196,6 +196,10 @@ 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/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 3ab39ebcf..17c61a443 100644 --- a/packages/messaging/message-box-client/src/PeerPayClient.ts +++ b/packages/messaging/message-box-client/src/PeerPayClient.ts @@ -27,25 +27,13 @@ import { Base64String, OriginatorDomainNameStringUnder250Bytes, Brc29RemittanceModule, - createNonce + createNonce, + toBRC100PortableByteArray, + stringifyBRC100 } from '@bsv/sdk' import * as Logger from './Utils/logger.js' -function toNumberArray(tx: unknown): number[] { - if (Array.isArray(tx)) return tx - if (tx instanceof Uint8Array) return Array.from(tx) - - if (tx != null && typeof tx === 'object') { - const entries = Object.entries(tx) - if (entries.length > 0 && entries.every(([key], index) => key === String(index))) { - return entries.map(([, byte]) => byte as number) - } - } - - return [] -} - function hexToBytes(hex: string): number[] { const matches = hex.match(/.{1,2}/g) return (matches ?? []).map(byte => Number.parseInt(byte, 16)) @@ -317,7 +305,7 @@ export class PeerPayClient extends MessageBoxClient { { recipient: payment.recipient, messageBox: this.messageBox, - body: JSON.stringify(paymentToken) + body: stringifyBRC100(paymentToken) }, hostOverride ) @@ -346,7 +334,7 @@ export class PeerPayClient extends MessageBoxClient { { recipient: payment.recipient, messageBox: this.messageBox, - body: JSON.stringify(paymentToken) + body: stringifyBRC100(paymentToken) }, overrideHost ) @@ -358,7 +346,7 @@ export class PeerPayClient extends MessageBoxClient { { recipient: payment.recipient, messageBox: this.messageBox, - body: JSON.stringify(paymentToken) + body: stringifyBRC100(paymentToken) }, overrideHost ) @@ -417,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( { @@ -428,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 } @@ -448,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}`) @@ -472,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.') @@ -672,7 +665,7 @@ export class PeerPayClient extends MessageBoxClient { { recipient: request.sender, messageBox: PAYMENT_REQUEST_RESPONSES_MESSAGEBOX, - body: JSON.stringify(response) + body: stringifyBRC100(response) }, hostOverride ) @@ -706,7 +699,7 @@ export class PeerPayClient extends MessageBoxClient { { recipient: request.sender, messageBox: PAYMENT_REQUEST_RESPONSES_MESSAGEBOX, - body: JSON.stringify(response) + body: stringifyBRC100(response) }, hostOverride ) @@ -768,7 +761,7 @@ export class PeerPayClient extends MessageBoxClient { { recipient: params.recipient, messageBox: PAYMENT_REQUESTS_MESSAGEBOX, - body: JSON.stringify(body) + body: stringifyBRC100(body) }, hostOverride ) @@ -988,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/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..f92898c52 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 current and historical nested wallet bytes portable without rewriting unrelated 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: [4, 5, 6] }, + 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..a6d7d19a2 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, + normalizeBRC100WalletByteFields, + stringifyBRC100, type AsyncSessionManager, type AuthMessage, type PubKeyHex, @@ -216,7 +218,7 @@ 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 = normalizeBRC100WalletByteFields(req.body) as Partial if ( typeof message.messageType !== 'string' || message.messageType.length === 0 || @@ -317,7 +319,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 +707,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 c60707b71..a785c4c38 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. @@ -308,8 +312,16 @@ All notable changes to this project will be documented in this file. The format 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. +- Preserve typed and historical numeric-key byte fields in authenticated JSON + request bodies, remittance envelopes, and serialized wallet review errors. + 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/browser-budget.json b/packages/sdk/browser-budget.json index 1d7fa9a41..69eff13a7 100644 --- a/packages/sdk/browser-budget.json +++ b/packages/sdk/browser-budget.json @@ -16,12 +16,12 @@ "prohibitedExports": [], "maximumBytes": { "vite": { - "raw": 738000, + "raw": 740000, "gzip": 185000, "brotli": 150000 }, "esbuild": { - "raw": 557000, + "raw": 559000, "gzip": 168000, "brotli": 140000 } @@ -30,7 +30,7 @@ "path": "dist/umd/bundle.js", "global": "bsv", "maximumBytes": { - "raw": 552000, + "raw": 554000, "gzip": 162000, "brotli": 136000 } 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..5d4c1ce3e 100644 --- a/packages/sdk/src/auth/transports/SimplifiedFetchTransport.ts +++ b/packages/sdk/src/auth/transports/SimplifiedFetchTransport.ts @@ -2,6 +2,10 @@ // @ts-expect-error import { AuthMessage, RequestedCertificateSet, Transport } from '../types.js' import * as Utils from '../../primitives/utils.js' +import { + normalizeBRC100WalletByteFields, + stringifyBRC100 +} from '../../wallet/BRC100ByteEncoding.js' const defaultFetch: typeof fetch = typeof globalThis !== 'undefined' && typeof globalThis.fetch === 'function' @@ -59,7 +63,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 +83,9 @@ 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!( + normalizeBRC100WalletByteFields(await response.json()) 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/remittance/RemittanceManager.ts b/packages/sdk/src/remittance/RemittanceManager.ts index 29ac37f08..fda9816e7 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 { normalizeBRC100WalletByteFields, 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') { @@ -1849,7 +1850,7 @@ export class InvoiceHandle extends ThreadHandle { function safeParseEnvelope (body: string): RemittanceEnvelope | undefined { try { - const parsed = JSON.parse(body) + const parsed = normalizeBRC100WalletByteFields(JSON.parse(body)) if (typeof parsed !== 'object') return undefined if (parsed.v !== 1) return undefined if (typeof parsed.kind !== 'string') return undefined diff --git a/packages/sdk/src/remittance/__tests/RemittanceManager.test.ts b/packages/sdk/src/remittance/__tests/RemittanceManager.test.ts index b7b74b58b..7c8f077fb 100644 --- a/packages/sdk/src/remittance/__tests/RemittanceManager.test.ts +++ b/packages/sdk/src/remittance/__tests/RemittanceManager.test.ts @@ -133,6 +133,28 @@ const waitForKind = async ( } describe('RemittanceManager base flows', () => { + it('serializes module-defined transaction bytes portably in envelopes', async () => { + const bus = new MessageBus() + const module: RemittanceModule<{ transaction: Uint8Array }, {}, {}> = { + id: 'byte-module', + name: 'Byte Module', + allowUnsolicitedSettlements: false, + createOption: async () => ({ transaction: new Uint8Array([1, 2, 3]) }), + 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]) + }) + 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 7d7f0eaa3..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) @@ -245,7 +259,10 @@ export class Brc29RemittanceModule } const transaction = toPortableTransaction(tx) if (transaction == null) { - return terminate('brc29.invalid_tx', 'wallet.createAction returned an invalid transaction payload.') + return terminate( + 'brc29.invalid_tx', + 'wallet.createAction returned an invalid transaction payload.' + ) } return { @@ -266,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 { @@ -295,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 } } } @@ -322,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 @@ -345,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 @@ -364,11 +401,8 @@ function ensureValidSettlement(settlement: Brc29SettlementArtifact): Brc29Settle } function toPortableTransaction(tx: unknown): number[] | undefined { - const bytes = tx instanceof Uint8Array ? [...tx] : tx - return Array.isArray(bytes) && bytes.length > 0 && - bytes.every(byte => Number.isInteger(byte) && byte >= 0 && byte <= 255) - ? bytes - : 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..909b8a1b0 --- /dev/null +++ b/packages/sdk/src/wallet/BRC100ByteEncoding.ts @@ -0,0 +1,164 @@ +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' +]) + +// These names are byte arrays in BRC-100 methods but are also common JSON +// envelope containers. An empty object is therefore ambiguous and must remain +// an object at recursive boundaries; explicit arrays and typed arrays are not. +const ambiguousContainerFieldNames = new Set(['data', 'payload']) + +function isAmbiguousEmptyContainer(key: string, value: unknown): boolean { + return ( + ambiguousContainerFieldNames.has(key) && + value != null && + typeof value === 'object' && + !Array.isArray(value) && + !isUint8Array(value) && + Object.keys(value).length === 0 + ) +} + +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 +} + +/** + * 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 ( + value != null && + typeof value === 'object' && + typeof ArrayBuffer !== 'undefined' && + (ArrayBuffer.isView(value) || Object.prototype.toString.call(value) === '[object ArrayBuffer]') + ) + return undefined + + if (Array.isArray(value)) { + for (let i = 0; i < value.length; i++) { + if (!hasOwn.call(value, i) || !isByte(value[i])) return undefined + } + return value as number[] + } + + if (value == null || typeof value !== 'object') return undefined + + try { + const keys = Object.keys(value) + 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 + } +} + +/** 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] + if (walletByteFieldNames.has(key)) { + const candidate = original ?? value + const bytes = isAmbiguousEmptyContainer(key, candidate) + ? undefined + : toBRC100PortableByteArray(candidate) + if (bytes != null) return bytes + } + 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 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)) { + const bytes = isAmbiguousEmptyContainer(key, fieldValue) + ? undefined + : normalizeBRC100ByteArray(fieldValue) + if (bytes != null) (candidate as Record)[key] = bytes + else visit(fieldValue) + } else { + visit(fieldValue) + } + } + } + + visit(value) + return value +} 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.test.ts b/packages/sdk/src/wallet/__tests/BRC100ByteEncoding.test.ts new file mode 100644 index 000000000..8c5272751 --- /dev/null +++ b/packages/sdk/src/wallet/__tests/BRC100ByteEncoding.test.ts @@ -0,0 +1,122 @@ +import vm from 'node:vm' +import { + brc100JsonReplacer, + normalizeBRC100ByteArray, + 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({})).toEqual([]) + }) + + 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() + }) + + 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('repairs historical numeric-key wallet byte fields during serialization', () => { + const tx = JSON.parse(JSON.stringify(new Uint8Array([1, 2, 3]))) + const unrelated = JSON.parse(JSON.stringify(new Uint8Array([4, 5]))) + + expect(stringifyBRC100({ tx, unrelated })).toBe('{"tx":[1,2,3],"unrelated":{"0":4,"1":5}}') + }) + + 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 generic empty data and payload containers while visiting nested byte fields', () => { + const mangled = (bytes: number[]): Record => + JSON.parse(JSON.stringify(new Uint8Array(bytes))) + const result = { + data: {}, + payload: { transaction: mangled([1, 2, 3]) } + } + + expect(normalizeBRC100WalletByteFields(result)).toEqual({ + data: {}, + payload: { transaction: [1, 2, 3] } + }) + expect(stringifyBRC100(result)).toBe('{"data":{},"payload":{"transaction":[1,2,3]}}') + }) +}) 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..97a3c6496 100644 --- a/packages/sdk/src/wallet/substrates/HTTPWalletJSON.ts +++ b/packages/sdk/src/wallet/substrates/HTTPWalletJSON.ts @@ -38,8 +38,9 @@ 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 { normalizeWalletJsonTx } from './utils/jsonByteEncoding.js' import WERR_INSUFFICIENT_FUNDS from '../WERR_INSUFFICIENT_FUNDS.js' +import { stringifyBRC100 } from '../BRC100ByteEncoding.js' function deserializeWalletError(data: any): Error | undefined { switch (data.code) { @@ -102,7 +103,7 @@ 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()) @@ -118,7 +119,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..321046901 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: { @@ -16,18 +17,18 @@ type ReactNativeWindow = Window & { export default class ReactNativeWebView extends InvokableWalletBase { private readonly domain: string - constructor(domain: string = '*') { 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.' ) @@ -39,12 +40,8 @@ export default class ReactNativeWebView extends InvokableWalletBase { return await new Promise((resolve, reject) => { const id = Utils.toBase64(Random(12)) const listener = (e: MessageEvent): void => { - const data = JSON.parse(e.data) - if ( - data.type !== 'CWI' || - data.id !== id || - data.isInvocation === true - ) { + const data = normalizeBRC100WalletByteFields(JSON.parse(e.data)) + if (data.type !== 'CWI' || data.id !== id || data.isInvocation === true) { return } if (typeof globalThis.window.removeEventListener === 'function') { @@ -59,7 +56,7 @@ export default class ReactNativeWebView extends InvokableWalletBase { } globalThis.window.addEventListener('message', listener) ;(globalThis.window as unknown as ReactNativeWindow).ReactNativeWebView.postMessage( - JSON.stringify({ + stringifyBRC100({ type: 'CWI', isInvocation: true, id, 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..bfd87d487 100644 --- a/packages/sdk/src/wallet/substrates/__tests/ReactNativeWebView.test.ts +++ b/packages/sdk/src/wallet/substrates/__tests/ReactNativeWebView.test.ts @@ -85,6 +85,20 @@ describe('ReactNativeWebView', () => { ) }) + 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 +116,29 @@ 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('rejects matching error responses as WalletError', async () => { jest.spyOn(Utils, 'toBase64').mockReturnValue('request-id') const substrate = new ReactNativeWebView() @@ -118,7 +155,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 +167,12 @@ describe('ReactNativeWebView', () => { const promise = substrate.invoke('getVersion', {}) let settled = false promise.then( - () => { settled = true }, - () => { settled = true } + () => { + settled = true + }, + () => { + settled = true + } ) dispatchMessage({ @@ -156,7 +197,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..9e4f5911a 100644 --- a/packages/sdk/src/wallet/substrates/__tests/jsonByteEncoding.test.ts +++ b/packages/sdk/src/wallet/substrates/__tests/jsonByteEncoding.test.ts @@ -24,13 +24,34 @@ describe('normalizeWalletJsonTx', () => { expect(normalizeWalletJsonTx(result).tx).toEqual([1, 1, 1, 1, 128, 7]) }) - it('does not rewrite unrelated numeric-keyed fields', () => { + it('repairs all 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)).toEqual({ + signature: [48, 68], + BEEF: [2, 0, 190, 239], + ciphertext: [1] + }) + }) + + it('repairs nested signable and review-action byte fields', () => { + const result = { + signableTransaction: { tx: mangled([1, 2]), reference: 'cmVm' }, + reviewActionResults: [{ competingBeef: mangled([3, 4]) }] + } + expect(normalizeWalletJsonTx(result)).toEqual({ + signableTransaction: { tx: [1, 2], reference: 'cmVm' }, + reviewActionResults: [{ competingBeef: [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..c53a39adf 100644 --- a/packages/sdk/src/wallet/substrates/utils/jsonByteEncoding.ts +++ b/packages/sdk/src/wallet/substrates/utils/jsonByteEncoding.ts @@ -11,32 +11,27 @@ * corrupt requests. * * `walletJsonReplacer` makes serialization safe. `normalizeWalletJsonTx` - * repairs already-corrupted action results produced by affected deployed - * wallets. + * repairs already-corrupted wallet payloads produced by affected deployed + * wallets. Both compatibility names delegate to the shared BRC-100 byte + * encoding contract used by every JSON transport. */ +import { brc100JsonReplacer, normalizeBRC100WalletByteFields } 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 wallet byte fields affected by Uint8Array JSON serialization, + * including direct and signable action transactions, list-output BEEF, + * cryptographic byte results, and review-action error BEEF. */ 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) }) - } - return value + return normalizeBRC100WalletByteFields(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..8c02d5b6f 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' @@ -185,7 +187,7 @@ export class BTMS { { satoshis: DEFAULT_TOKEN_SATOSHIS, lockingScript: lockingScriptHex, - customInstructions: JSON.stringify({ + customInstructions: stringifyBRC100({ derivationPrefix, derivationSuffix }), @@ -232,7 +234,7 @@ export class BTMS { protocol: 'basket insertion', insertionRemittance: { basket: BTMS_BASKET, - customInstructions: JSON.stringify({ + customInstructions: stringifyBRC100({ derivationPrefix, derivationSuffix }), @@ -351,7 +353,7 @@ export class BTMS { outputs.push({ satoshis: DEFAULT_TOKEN_SATOSHIS, lockingScript: recipientScriptHex, - customInstructions: JSON.stringify({ + customInstructions: stringifyBRC100({ derivationPrefix: transferDerivationPrefix, derivationSuffix: recipientDerivationSuffix }), @@ -434,7 +436,7 @@ export class BTMS { amount, satoshis: DEFAULT_TOKEN_SATOSHIS, beef: signedTx, - customInstructions: JSON.stringify({ + customInstructions: stringifyBRC100({ derivationPrefix: transferDerivationPrefix, derivationSuffix: recipientDerivationSuffix }), @@ -447,7 +449,7 @@ export class BTMS { await this.comms.sendMessage({ recipient, messageBox: BTMS_MESSAGE_BOX, - body: JSON.stringify(tokenForRecipient) + body: stringifyBRC100(tokenForRecipient) }) } @@ -644,17 +646,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 +666,10 @@ export class BTMS { */ async accept(token: IncomingToken): Promise { try { + const beef = token.beef === undefined ? undefined : normalizeBRC100ByteArray(token.beef) + if (token.beef !== undefined && (beef == null || beef.length === 0)) { + throw new Error('Incoming token BEEF must be a non-empty BRC-100 byte array') + } // Decode and validate the token const decoded = BTMSToken.decode(token.lockingScript) if (!decoded.valid) { @@ -682,8 +680,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 +716,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 +726,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 +794,10 @@ export class BTMS { if (!this.comms) { throw new Error('Comms layer is required to refund incoming tokens') } + const beef = token.beef === undefined ? undefined : normalizeBRC100ByteArray(token.beef) + if (token.beef !== undefined && (beef == null || beef.length === 0)) { + throw new Error('Incoming token BEEF must be a non-empty BRC-100 byte array') + } // Decode and validate the token const decoded = BTMSToken.decode(token.lockingScript) @@ -807,8 +809,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 +818,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 +876,7 @@ export class BTMS { { satoshis: DEFAULT_TOKEN_SATOSHIS, lockingScript: refundScript.toHex(), - customInstructions: JSON.stringify({ + customInstructions: stringifyBRC100({ derivationPrefix: refundDerivationPrefix, derivationSuffix: refundDerivationSuffix }), @@ -925,7 +926,7 @@ export class BTMS { } const originalInstructions = JSON.parse(token.customInstructions) - const refundInstructions = JSON.stringify({ + const refundInstructions = stringifyBRC100({ ...originalInstructions, senderIdentityKey: token.sender }) @@ -939,7 +940,7 @@ export class BTMS { customInstructions: refundInstructions, token: decoded, spendable: true, - beef: token.beef + beef } const spends = await this.buildSpendsForInputs([selectedUtxo], signableTransaction.tx) @@ -955,7 +956,7 @@ export class BTMS { amount: token.amount, satoshis: DEFAULT_TOKEN_SATOSHIS, beef: signedTx, - customInstructions: JSON.stringify({ + customInstructions: stringifyBRC100({ derivationPrefix: refundDerivationPrefix, derivationSuffix: refundDerivationSuffix }), @@ -966,7 +967,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 +1720,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 +1752,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..9c5b7221c 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,11 @@ 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 || 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..265a06896 100644 --- a/packages/wallet/btms/src/__tests/BTMSHelpers.property.test.ts +++ b/packages/wallet/btms/src/__tests/BTMSHelpers.property.test.ts @@ -82,6 +82,14 @@ 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() + }) + 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/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..75cd8198e 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') } @@ -274,7 +274,8 @@ export class WalletPairingSession { return // tampered or wrong key — drop } - const msg = JSON.parse(plaintext) as RpcRequest | RpcResponse + const msg = normalizeBRC100WalletByteFields(JSON.parse(plaintext)) as + RpcRequest | RpcResponse // M4: Replay protection — drop anything not strictly greater than last seq if (typeof msg.seq !== 'number' || msg.seq <= this._lastSeq) { @@ -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..8090dded1 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) { @@ -276,7 +276,7 @@ export class WalletRelayClient { throw new WalletRelayError(msg, code) } - const rpc = (await res.json()) as { + const rpc = normalizeBRC100WalletByteFields(await res.json()) as { result?: unknown error?: { code: number; message: string } } @@ -393,7 +393,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..9fe574513 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 { 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 }) @@ -546,7 +547,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/WalletRequestHandler.ts b/packages/wallet/ts-wallet-relay/src/server/WalletRequestHandler.ts index 27cb450af..e089e99b2 100644 --- a/packages/wallet/ts-wallet-relay/src/server/WalletRequestHandler.ts +++ b/packages/wallet/ts-wallet-relay/src/server/WalletRequestHandler.ts @@ -1,4 +1,5 @@ import { randomUUID } from 'node:crypto' +import { normalizeBRC100WalletByteFields } from '@bsv/sdk' import type { RpcRequest, RpcResponse } from '../types.js' /** @@ -19,7 +20,7 @@ export class WalletRequestHandler { } parseMessage(raw: string): RpcRequest | RpcResponse { - return JSON.parse(raw) as RpcRequest | RpcResponse + return normalizeBRC100WalletByteFields(JSON.parse(raw)) as RpcRequest | RpcResponse } isResponse(msg: RpcRequest | RpcResponse): msg is RpcResponse { 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..aa3c39d70 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. +- Normalize BRC-100 byte fields in both directions across the WAB JSON + transport, including historical numeric-key responses. + - 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/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/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..4a0cad888 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 { normalizeBRC100WalletByteFields, 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, @@ -437,7 +437,7 @@ export class WABTransport { ): T { let parsed: unknown try { - parsed = JSON.parse(responseText) + parsed = normalizeBRC100WalletByteFields(JSON.parse(responseText)) } catch (cause) { const error = new WABClientError( 'WAB_INVALID_RESPONSE', 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..788958380 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('normalizes wallet byte fields in both JSON directions', 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: number[] }>('/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([4, 5, 6]) + }) + 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..882445ca8 --- /dev/null +++ b/scripts/brc100-byte-boundary.test.mjs @@ -0,0 +1,106 @@ +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/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/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/WalletRequestHandler.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\)|const parsed = JSON\.parse\(body\))/ + }, + { + 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\)|parsed = JSON\.parse\(responseText\))/ + } +] + +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) + }) +} + +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 { From 10e5d574df7ef66a825717589426e9ac9b3c921f Mon Sep 17 00:00:00 2001 From: Ty J Everett Date: Fri, 14 Aug 2026 16:40:30 -0700 Subject: [PATCH 04/14] ci: preserve mutation scope across range edits --- scripts/mutation-testing.mjs | 24 +++++++++++++++++++++- scripts/mutation-testing.test.mjs | 33 ++++++++++++++++++++++++++++++- 2 files changed, 55 insertions(+), 2 deletions(-) diff --git a/scripts/mutation-testing.mjs b/scripts/mutation-testing.mjs index 3b9a89dfb..4d7cb71ac 100644 --- a/scripts/mutation-testing.mjs +++ b/scripts/mutation-testing.mjs @@ -134,12 +134,34 @@ 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) { + const message = error instanceof Error ? error.message : String(error) + const match = message.match(/Unable to resolve mutation range in ([^:]+):/) + if (match === null) return Object.keys(currentTargets) + + const filePath = normalized(match[1]) + const matchingTargets = Object.entries(currentTargets) + .filter(([, target]) => + (target.mutate ?? []).some(mutate => mutate.replace(/:\d+(?:-\d+)?$/, '') === filePath) + ) + .map(([id]) => id) + + // If the previous target was removed, selecting every current target is the + // conservative fallback: a configuration change must never suppress mutation coverage. + return matchingTargets.length > 0 ? matchingTargets : 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..398c3bdbb 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'] + ) + 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: [ From d93041c06899bd0a8faaf10f92a4bd26ee1897b6 Mon Sep 17 00:00:00 2001 From: Ty J Everett Date: Fri, 14 Aug 2026 16:48:31 -0700 Subject: [PATCH 05/14] fix: clear transport compatibility quality findings --- packages/sdk/CHANGELOG.md | 2 + packages/sdk/src/wallet/BRC100ByteEncoding.ts | 37 +++++++++++------- .../wallet/substrates/ReactNativeWebView.ts | 1 + .../__tests/ReactNativeWebView.test.ts | 38 +++++++++++++++++-- packages/wallet/btms/src/BTMS.ts | 19 ++++++---- scripts/mutation-testing.mjs | 2 +- 6 files changed, 73 insertions(+), 26 deletions(-) diff --git a/packages/sdk/CHANGELOG.md b/packages/sdk/CHANGELOG.md index a785c4c38..4f9373d1c 100644 --- a/packages/sdk/CHANGELOG.md +++ b/packages/sdk/CHANGELOG.md @@ -317,6 +317,8 @@ All notable changes to this project will be documented in this file. The format 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. + When a concrete domain is configured, response messages from every other + origin are ignored before parsing. - Preserve typed and historical numeric-key byte fields in authenticated JSON request bodies, remittance envelopes, and serialized wallet review errors. Existing `number[]` payloads retain their zero-copy fast path. The reviewed diff --git a/packages/sdk/src/wallet/BRC100ByteEncoding.ts b/packages/sdk/src/wallet/BRC100ByteEncoding.ts index 909b8a1b0..3fbbcdc1c 100644 --- a/packages/sdk/src/wallet/BRC100ByteEncoding.ts +++ b/packages/sdk/src/wallet/BRC100ByteEncoding.ts @@ -49,6 +49,15 @@ 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]') + ) +} + /** * Normalizes the runtime representations used for BRC-100 byte arrays. * @@ -59,14 +68,7 @@ function isByte(value: unknown): value is number { */ export function normalizeBRC100ByteArray(value: unknown): AtomicBEEF | undefined { if (isUint8Array(value)) return value - - if ( - value != null && - typeof value === 'object' && - typeof ArrayBuffer !== 'undefined' && - (ArrayBuffer.isView(value) || Object.prototype.toString.call(value) === '[object ArrayBuffer]') - ) - return undefined + if (isUnsupportedBinaryView(value)) return undefined if (Array.isArray(value)) { for (let i = 0; i < value.length; i++) { @@ -128,6 +130,19 @@ export function stringifyBRC100(value: unknown, space?: string | number): string return serialized } +function visitBRC100WalletByteField( + candidate: Record, + key: string, + fieldValue: unknown, + visit: (value: unknown) => void +): void { + const bytes = isAmbiguousEmptyContainer(key, fieldValue) + ? undefined + : 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 @@ -148,11 +163,7 @@ export function normalizeBRC100WalletByteFields(value: T): T { for (const [key, fieldValue] of Object.entries(candidate)) { if (walletByteFieldNames.has(key)) { - const bytes = isAmbiguousEmptyContainer(key, fieldValue) - ? undefined - : normalizeBRC100ByteArray(fieldValue) - if (bytes != null) (candidate as Record)[key] = bytes - else visit(fieldValue) + visitBRC100WalletByteField(candidate as Record, key, fieldValue, visit) } else { visit(fieldValue) } diff --git a/packages/sdk/src/wallet/substrates/ReactNativeWebView.ts b/packages/sdk/src/wallet/substrates/ReactNativeWebView.ts index 321046901..0f1df20a3 100644 --- a/packages/sdk/src/wallet/substrates/ReactNativeWebView.ts +++ b/packages/sdk/src/wallet/substrates/ReactNativeWebView.ts @@ -40,6 +40,7 @@ export default class ReactNativeWebView extends InvokableWalletBase { return await new Promise((resolve, reject) => { const id = Utils.toBase64(Random(12)) const listener = (e: MessageEvent): void => { + if (this.domain !== '*' && e.origin !== this.domain) return const data = normalizeBRC100WalletByteFields(JSON.parse(e.data)) if (data.type !== 'CWI' || data.id !== id || data.isInvocation === true) { return diff --git a/packages/sdk/src/wallet/substrates/__tests/ReactNativeWebView.test.ts b/packages/sdk/src/wallet/substrates/__tests/ReactNativeWebView.test.ts index bfd87d487..7a835f86a 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', () => { @@ -139,6 +139,36 @@ describe('ReactNativeWebView', () => { }) }) + it('accepts responses only from the configured origin', async () => { + jest.spyOn(Utils, 'toBase64').mockReturnValue('request-id') + const substrate = new ReactNativeWebView('https://trusted.example') + const promise = substrate.invoke('getVersion', {}) + let settled = false + promise.then( + () => { + settled = true + }, + () => { + settled = true + } + ) + const response = { + type: 'CWI', + isInvocation: false, + id: 'request-id', + status: 'success', + result: { version: '1.0.0' } + } + + dispatchMessage(response, 'https://hostile.example') + await new Promise(resolve => setTimeout(resolve, 1)) + expect(settled).toBe(false) + expect(removeEventListenerMock).not.toHaveBeenCalled() + + dispatchMessage(response, 'https://trusted.example') + await expect(promise).resolves.toEqual({ version: '1.0.0' }) + }) + it('rejects matching error responses as WalletError', async () => { jest.spyOn(Utils, 'toBase64').mockReturnValue('request-id') const substrate = new ReactNativeWebView() diff --git a/packages/wallet/btms/src/BTMS.ts b/packages/wallet/btms/src/BTMS.ts index 8c02d5b6f..f7e09f866 100644 --- a/packages/wallet/btms/src/BTMS.ts +++ b/packages/wallet/btms/src/BTMS.ts @@ -76,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 * @@ -666,10 +675,7 @@ export class BTMS { */ async accept(token: IncomingToken): Promise { try { - const beef = token.beef === undefined ? undefined : normalizeBRC100ByteArray(token.beef) - if (token.beef !== undefined && (beef == null || beef.length === 0)) { - throw new Error('Incoming token BEEF must be a non-empty BRC-100 byte array') - } + const beef = normalizeIncomingTokenBeef(token.beef) // Decode and validate the token const decoded = BTMSToken.decode(token.lockingScript) if (!decoded.valid) { @@ -794,10 +800,7 @@ export class BTMS { if (!this.comms) { throw new Error('Comms layer is required to refund incoming tokens') } - const beef = token.beef === undefined ? undefined : normalizeBRC100ByteArray(token.beef) - if (token.beef !== undefined && (beef == null || beef.length === 0)) { - throw new Error('Incoming token BEEF must be a non-empty BRC-100 byte array') - } + const beef = normalizeIncomingTokenBeef(token.beef) // Decode and validate the token const decoded = BTMSToken.decode(token.lockingScript) diff --git a/scripts/mutation-testing.mjs b/scripts/mutation-testing.mjs index 4d7cb71ac..9aef4c056 100644 --- a/scripts/mutation-testing.mjs +++ b/scripts/mutation-testing.mjs @@ -147,7 +147,7 @@ async function changedConfiguredTargets(base, currentTargets) { export function targetsForUnresolvedMutationRange(currentTargets, error) { const message = error instanceof Error ? error.message : String(error) - const match = message.match(/Unable to resolve mutation range in ([^:]+):/) + const match = /Unable to resolve mutation range in ([^:]+):/.exec(message) if (match === null) return Object.keys(currentTargets) const filePath = normalized(match[1]) From 39bfeaba4091cc936ccc1bd5165540a30af6a9b9 Mon Sep 17 00:00:00 2001 From: Ty J Everett Date: Fri, 14 Aug 2026 17:03:53 -0700 Subject: [PATCH 06/14] test: close wallet byte compatibility gates --- packages/wallet/btms/src/BTMSHelpers.ts | 3 +- .../src/__tests/BTMSHelpers.property.test.ts | 8 +++++ .../btms/src/__tests/BTMSHelpers.test.ts | 29 +++++++++++++++++++ .../client/platform-budget.json | 10 +++---- .../mobile/platform-budget.json | 2 +- 5 files changed, 45 insertions(+), 7 deletions(-) diff --git a/packages/wallet/btms/src/BTMSHelpers.ts b/packages/wallet/btms/src/BTMSHelpers.ts index 9c5b7221c..afab35154 100644 --- a/packages/wallet/btms/src/BTMSHelpers.ts +++ b/packages/wallet/btms/src/BTMSHelpers.ts @@ -249,7 +249,8 @@ export function parseIncomingMessage(msg: { const payment = JSON.parse(msg.body) as IncomingToken if (payment.beef !== undefined) { const beef = normalizeBRC100ByteArray(payment.beef) - if (beef == null || beef.length === 0) return null + if (beef == null) return null + if (beef.length === 0) return null payment.beef = beef } if (msg.messageId !== undefined) payment.messageId = msg.messageId diff --git a/packages/wallet/btms/src/__tests/BTMSHelpers.property.test.ts b/packages/wallet/btms/src/__tests/BTMSHelpers.property.test.ts index 265a06896..f489f5b49 100644 --- a/packages/wallet/btms/src/__tests/BTMSHelpers.property.test.ts +++ b/packages/wallet/btms/src/__tests/BTMSHelpers.property.test.ts @@ -88,6 +88,14 @@ describe('BTMS untrusted data properties', () => { 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', () => { 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/wallet-toolbox/client/platform-budget.json b/packages/wallet/wallet-toolbox/client/platform-budget.json index 7516f1550..73bcb81c6 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": 1606000, + "gzip": 378600, + "brotli": 296600 }, "esbuild": { - "raw": 1250600, + "raw": 1252000, "gzip": 344100, - "brotli": 276500 + "brotli": 276900 } } } diff --git a/packages/wallet/wallet-toolbox/mobile/platform-budget.json b/packages/wallet/wallet-toolbox/mobile/platform-budget.json index 7fa8087b9..0794bed35 100644 --- a/packages/wallet/wallet-toolbox/mobile/platform-budget.json +++ b/packages/wallet/wallet-toolbox/mobile/platform-budget.json @@ -7,7 +7,7 @@ "brotli": 360000 }, "hermes": { - "raw": 3361000, + "raw": 3365000, "gzip": 1362000, "brotli": 1070000 } From 35e25cfaaa749ed1ba774638c215b0df24f90da7 Mon Sep 17 00:00:00 2001 From: Ty J Everett Date: Fri, 14 Aug 2026 17:16:29 -0700 Subject: [PATCH 07/14] ci: account for portable byte bridge bundles --- packages/wallet/wallet-toolbox/client/platform-budget.json | 2 +- packages/wallet/wallet-toolbox/mobile/platform-budget.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/wallet/wallet-toolbox/client/platform-budget.json b/packages/wallet/wallet-toolbox/client/platform-budget.json index 73bcb81c6..59ad91ed1 100644 --- a/packages/wallet/wallet-toolbox/client/platform-budget.json +++ b/packages/wallet/wallet-toolbox/client/platform-budget.json @@ -8,7 +8,7 @@ }, "esbuild": { "raw": 1252000, - "gzip": 344100, + "gzip": 345000, "brotli": 276900 } } diff --git a/packages/wallet/wallet-toolbox/mobile/platform-budget.json b/packages/wallet/wallet-toolbox/mobile/platform-budget.json index 0794bed35..e69061a83 100644 --- a/packages/wallet/wallet-toolbox/mobile/platform-budget.json +++ b/packages/wallet/wallet-toolbox/mobile/platform-budget.json @@ -8,7 +8,7 @@ }, "hermes": { "raw": 3365000, - "gzip": 1362000, + "gzip": 1365000, "brotli": 1070000 } } From 8b3225fbc1f0aebc906021f871f4196c83369460 Mon Sep 17 00:00:00 2001 From: Ty J Everett Date: Fri, 14 Aug 2026 17:30:12 -0700 Subject: [PATCH 08/14] test(simple): cover token byte compatibility boundary --- .../src/modules/__tests__/tokens.test.ts | 128 ++++++++++++++++++ 1 file changed, 128 insertions(+) create mode 100644 packages/helpers/simple/src/modules/__tests__/tokens.test.ts 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() + }) +}) From 3fb8e142505a5092996c1344e1a9fd2c3f89baa8 Mon Sep 17 00:00:00 2001 From: Ty J Everett Date: Fri, 14 Aug 2026 17:41:26 -0700 Subject: [PATCH 09/14] fix: cover remaining typed-byte JSON edges --- docs/reference/package-api-migrations.md | 4 +- governance/package-release-notes.json | 4 +- packages/helpers/simple/README.md | 3 +- .../server/__tests__/handler-types.test.ts | 20 +++++++++ .../simple/src/server/handler-types.ts | 6 ++- packages/sdk/CHANGELOG.md | 5 ++- packages/sdk/README.md | 3 +- .../__tests__/BRC100OverlayTransport.test.ts | 41 +++++++++++++++++++ .../sdk/src/overlay-tools/LookupResolver.ts | 8 +++- scripts/brc100-byte-boundary.test.mjs | 2 + 10 files changed, 84 insertions(+), 12 deletions(-) create mode 100644 packages/helpers/simple/src/server/__tests__/handler-types.test.ts create mode 100644 packages/sdk/src/__tests__/BRC100OverlayTransport.test.ts diff --git a/docs/reference/package-api-migrations.md b/docs/reference/package-api-migrations.md index 9a514777e..65122b0f0 100644 --- a/docs/reference/package-api-migrations.md +++ b/docs/reference/package-api-migrations.md @@ -316,7 +316,7 @@ 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 one strict BRC-100 byte-boundary contract and applies it to HTTP, React Native, authenticated fetch, AuthSocket, remittance, wallet-error, and BRC-29 paths so number arrays, Uint8Array values, and historical numeric-key JSON payloads interoperate. +- Release note: Adds one strict BRC-100 byte-boundary contract and applies it to HTTP, React Native, authenticated fetch, AuthSocket, overlay lookup, remittance, wallet-error, and BRC-29 paths so number arrays, Uint8Array values, and historical numeric-key JSON payloads interoperate. - 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 peers remain compatible through portable number-array output and legacy numeric-key input recovery. | Public subpath | Runtime target(s) | Declaration target(s) | @@ -398,7 +398,7 @@ 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 and preserves Message Box and token send/receive transaction bytes across number-array, Uint8Array, and historical numeric-key JSON wallet runtimes. +- 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) | diff --git a/governance/package-release-notes.json b/governance/package-release-notes.json index e2de5c833..50666b22a 100644 --- a/governance/package-release-notes.json +++ b/governance/package-release-notes.json @@ -147,14 +147,14 @@ "name": "@bsv/sdk", "publishedVersion": "2.4.0", "releaseType": "patch", - "summary": "Adds one strict BRC-100 byte-boundary contract and applies it to HTTP, React Native, authenticated fetch, AuthSocket, remittance, wallet-error, and BRC-29 paths so number arrays, Uint8Array values, and historical numeric-key JSON payloads interoperate.", + "summary": "Adds one strict BRC-100 byte-boundary contract and applies it to HTTP, React Native, authenticated fetch, AuthSocket, overlay lookup, remittance, wallet-error, and BRC-29 paths so number arrays, Uint8Array values, and historical numeric-key JSON payloads interoperate.", "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 peers remain compatible through portable number-array output and legacy numeric-key input recovery." }, { "name": "@bsv/simple", "publishedVersion": "0.4.1", "releaseType": "minor", - "summary": "Adds TerraTestNet and preserves Message Box and token send/receive transaction bytes across number-array, Uint8Array, and historical numeric-key JSON wallet runtimes.", + "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." }, { diff --git a/packages/helpers/simple/README.md b/packages/helpers/simple/README.md index bba306ce1..f1188a0bd 100644 --- a/packages/helpers/simple/README.md +++ b/packages/helpers/simple/README.md @@ -43,7 +43,8 @@ Both entry points provide the same API surface — the only difference is how th 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 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 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..23f82b0b2 --- /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('normalizes historical request bytes and emits portable response bytes', async () => { + const post = jest.fn(async (req: { json: () => Promise }) => { + await expect(req.json()).resolves.toEqual({ transaction: [1, 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..7bd6fc407 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 { normalizeBRC100WalletByteFields, 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 () => normalizeBRC100WalletByteFields(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/sdk/CHANGELOG.md b/packages/sdk/CHANGELOG.md index 4f9373d1c..30fd2e1d3 100644 --- a/packages/sdk/CHANGELOG.md +++ b/packages/sdk/CHANGELOG.md @@ -320,8 +320,9 @@ All notable changes to this project will be documented in this file. The format When a concrete domain is configured, response messages from every other origin are ignored before parsing. - Preserve typed and historical numeric-key byte fields in authenticated JSON - request bodies, remittance envelopes, and serialized wallet review errors. - Existing `number[]` payloads retain their zero-copy fast path. The reviewed + request bodies, remittance envelopes, overlay lookup queries and BEEF + responses, and serialized wallet review errors. 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. diff --git a/packages/sdk/README.md b/packages/sdk/README.md index de365360b..5214700b1 100644 --- a/packages/sdk/README.md +++ b/packages/sdk/README.md @@ -13,7 +13,8 @@ For application-to-wallet integrations, the SDK exposes the BRC-100 `WalletClien 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. +and JSON transports preserve identical transaction bytes. The same boundary +protects overlay lookup queries and JSON BEEF responses. ## Table of Contents diff --git a/packages/sdk/src/__tests__/BRC100OverlayTransport.test.ts b/packages/sdk/src/__tests__/BRC100OverlayTransport.test.ts new file mode 100644 index 000000000..206957ac2 --- /dev/null +++ b/packages/sdk/src/__tests__/BRC100OverlayTransport.test.ts @@ -0,0 +1,41 @@ +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 }] + }) + }) +}) diff --git a/packages/sdk/src/overlay-tools/LookupResolver.ts b/packages/sdk/src/overlay-tools/LookupResolver.ts index 3232c54d8..b09183dfc 100644 --- a/packages/sdk/src/overlay-tools/LookupResolver.ts +++ b/packages/sdk/src/overlay-tools/LookupResolver.ts @@ -4,6 +4,10 @@ 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 { + normalizeBRC100WalletByteFields, + stringifyBRC100 +} from '../wallet/BRC100ByteEncoding.js' const defaultFetch: typeof fetch = typeof globalThis !== 'undefined' && typeof globalThis.fetch === 'function' @@ -407,7 +411,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 +432,7 @@ export class HTTPSOverlayLookupFacilitator implements OverlayLookupFacilitator { if (isOctetStream(response.headers.get('content-type'))) { return await this.parseOctetStreamLookup(response) } - return await response.json() + return normalizeBRC100WalletByteFields(await response.json()) } /** Parse the aggregated octet-stream lookup response into an output-list LookupAnswer. */ diff --git a/scripts/brc100-byte-boundary.test.mjs b/scripts/brc100-byte-boundary.test.mjs index 882445ca8..4d822e602 100644 --- a/scripts/brc100-byte-boundary.test.mjs +++ b/scripts/brc100-byte-boundary.test.mjs @@ -5,6 +5,7 @@ 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', @@ -14,6 +15,7 @@ const walletBoundaryFiles = [ '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', From 022ef620356f594c79f4f56ed0c85297ed0964bb Mon Sep 17 00:00:00 2001 From: Ty J Everett Date: Fri, 14 Aug 2026 19:52:28 -0700 Subject: [PATCH 10/14] fix: preserve wallet JSON and React Native compatibility --- docs/reference/package-api-migrations.md | 20 ++-- governance/mutation-testing/policy.json | 10 ++ governance/mutation-testing/targets.mjs | 40 ++++++- governance/package-release-notes.json | 14 +-- governance/test-quality/policy.json | 12 ++ .../server/__tests__/handler-types.test.ts | 4 +- .../simple/src/server/handler-types.ts | 4 +- .../messaging/authsocket-client/README.md | 9 +- .../authsocket-client/src/AuthSocketClient.ts | 3 +- .../src/__tests__/AuthSocketClient.test.ts | 25 ++++- packages/messaging/authsocket/README.md | 9 +- .../authsocket/src/AuthSocketServer.ts | 3 +- .../src/__tests__/AuthSocket.test.ts | 20 +++- .../authMiddlewareHelpers.property.test.ts | 4 +- .../auth-express-middleware/src/index.ts | 7 +- packages/sdk/CHANGELOG.md | 16 ++- packages/sdk/package.json | 2 +- .../__tests__/BRC100OverlayTransport.test.ts | 14 +++ .../transports/SimplifiedFetchTransport.ts | 10 +- .../sdk/src/overlay-tools/LookupResolver.ts | 19 +++- .../sdk/src/remittance/RemittanceManager.ts | 4 +- .../__tests/RemittanceManager.test.ts | 24 +++- packages/sdk/src/wallet/BRC100ByteEncoding.ts | 62 +++++----- packages/sdk/src/wallet/WalletClient.ts | 44 ++++++-- .../BRC100ByteEncoding.property.test.ts | 39 +++++++ .../wallet/__tests/BRC100ByteEncoding.test.ts | 84 +++++++++++++- .../__tests/WalletClient.additional.test.ts | 34 ++++++ .../__tests/WalletClient.substrate.test.ts | 23 ++++ .../src/wallet/substrates/HTTPWalletJSON.ts | 4 +- .../wallet/substrates/ReactNativeWebView.ts | 71 ++++++++++-- .../__tests/ReactNativeWebView.test.ts | 106 +++++++++++++++--- .../__tests/jsonByteEncoding.test.ts | 20 ++-- .../substrates/utils/jsonByteEncoding.ts | 19 ++-- .../src/client/WalletPairingSession.ts | 4 +- .../src/client/WalletRelayClient.ts | 3 +- .../src/server/WalletRelayService.ts | 3 +- .../src/server/WalletRequestHandler.ts | 3 +- packages/wallet/wallet-toolbox/CHANGELOG.md | 4 +- .../src/wab-client/WABTransport.ts | 4 +- .../wab-client/__tests/WABTransport.test.ts | 6 +- scripts/brc100-byte-boundary.test.mjs | 21 +++- scripts/mutation-testing.mjs | 20 +--- scripts/mutation-testing.test.mjs | 2 +- scripts/test-governance.test.mjs | 4 +- 44 files changed, 663 insertions(+), 190 deletions(-) create mode 100644 packages/sdk/src/wallet/__tests/BRC100ByteEncoding.property.test.ts diff --git a/docs/reference/package-api-migrations.md b/docs/reference/package-api-migrations.md index 65122b0f0..e21659716 100644 --- a/docs/reference/package-api-migrations.md +++ b/docs/reference/package-api-migrations.md @@ -30,8 +30,8 @@ and clean-consumer tests remain the executable type authority. | `@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.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 and number arrays are unchanged; upgrade to @bsv/sdk 2.4.1 or later to send current typed wallet bytes portably and recover historical numeric-key byte fields after signature verification. | -| `@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 and number arrays are unchanged; upgrade to @bsv/sdk 2.4.1 or later to send current typed wallet bytes portably and recover historical numeric-key byte fields after signature verification. | +| `@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. | @@ -45,7 +45,7 @@ and clean-consumer tests remain the executable type authority. | `@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.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 peers remain compatible through portable number-array output and legacy numeric-key input recovery. | +| `@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.9.6` | patch | [API and usage](../packages/helpers/templates.md) | No consumer migration is required; template APIs, supported imports, and generated script semantics are unchanged. | | `@bsv/teranode-listener` | `1.1.1` | `1.1.4` | patch | [API and usage](../packages/network/teranode-listener.md) | No consumer migration is required; listener APIs, topics, and network configuration are unchanged. | @@ -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, caps per-socket authentication concurrency, and preserves BRC-100 byte fields in signed event envelopes across number-array, Uint8Array, and historical numeric-key JSON runtimes. -- Migration: No API migration is required. Existing event data and number arrays are unchanged; upgrade to @bsv/sdk 2.4.1 or later to send current typed wallet bytes portably and recover historical numeric-key byte fields after signature verification. +- 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, and preserves BRC-100 byte fields in signed event envelopes across number-array, Uint8Array, and historical numeric-key JSON runtimes. -- Migration: No API migration is required. Existing event data and number arrays are unchanged; upgrade to @bsv/sdk 2.4.1 or later to send current typed wallet bytes portably and recover historical numeric-key byte fields after signature verification. +- 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) | | ---------------- | ----------------------------------- | --------------------------------------- | @@ -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 one strict BRC-100 byte-boundary contract and applies it to HTTP, React Native, authenticated fetch, AuthSocket, overlay lookup, remittance, wallet-error, and BRC-29 paths so number arrays, Uint8Array values, and historical numeric-key JSON payloads interoperate. -- 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 peers remain compatible through portable number-array output and legacy numeric-key input recovery. +- 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) | | ---------------------------------- | ---------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------- | @@ -477,7 +477,7 @@ 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: Retains BRC-95 internalization compatibility and preserves typed AtomicBEEF, competing BEEF, WAB request/response bytes, and wallet review errors across portable and historical JSON representations. +- 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) | 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 6d5459392..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!(', - "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 50666b22a..c5bc83cdd 100644 --- a/governance/package-release-notes.json +++ b/governance/package-release-notes.json @@ -42,15 +42,15 @@ "name": "@bsv/authsocket", "publishedVersion": "2.1.1", "releaseType": "patch", - "summary": "Contains authentication and application callback failures, caps per-socket authentication concurrency, and preserves BRC-100 byte fields in signed event envelopes across number-array, Uint8Array, and historical numeric-key JSON runtimes.", - "migration": "No API migration is required. Existing event data and number arrays are unchanged; upgrade to @bsv/sdk 2.4.1 or later to send current typed wallet bytes portably and recover historical numeric-key byte fields after signature verification." + "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, and preserves BRC-100 byte fields in signed event envelopes across number-array, Uint8Array, and historical numeric-key JSON runtimes.", - "migration": "No API migration is required. Existing event data and number arrays are unchanged; upgrade to @bsv/sdk 2.4.1 or later to send current typed wallet bytes portably and recover historical numeric-key byte fields after signature verification." + "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", @@ -147,8 +147,8 @@ "name": "@bsv/sdk", "publishedVersion": "2.4.0", "releaseType": "patch", - "summary": "Adds one strict BRC-100 byte-boundary contract and applies it to HTTP, React Native, authenticated fetch, AuthSocket, overlay lookup, remittance, wallet-error, and BRC-29 paths so number arrays, Uint8Array values, and historical numeric-key JSON payloads interoperate.", - "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 peers remain compatible through portable number-array output and legacy numeric-key input recovery." + "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", @@ -196,7 +196,7 @@ "name": "@bsv/wallet-toolbox", "publishedVersion": "2.10.0", "releaseType": "patch", - "summary": "Retains BRC-95 internalization compatibility and preserves typed AtomicBEEF, competing BEEF, WAB request/response bytes, and wallet review errors across portable and historical JSON representations.", + "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." }, { 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/src/server/__tests__/handler-types.test.ts b/packages/helpers/simple/src/server/__tests__/handler-types.test.ts index 23f82b0b2..54d5fe0eb 100644 --- a/packages/helpers/simple/src/server/__tests__/handler-types.test.ts +++ b/packages/helpers/simple/src/server/__tests__/handler-types.test.ts @@ -1,9 +1,9 @@ import { jsonResponse, toNextHandlers } from '../handler-types' describe('Next.js handler BRC-100 byte compatibility', () => { - it('normalizes historical request bytes and emits portable response bytes', async () => { + 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: [1, 2, 255] }) + 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 }) diff --git a/packages/helpers/simple/src/server/handler-types.ts b/packages/helpers/simple/src/server/handler-types.ts index 7bd6fc407..901f6df4c 100644 --- a/packages/helpers/simple/src/server/handler-types.ts +++ b/packages/helpers/simple/src/server/handler-types.ts @@ -3,7 +3,7 @@ * Avoids importing 'next/server' so the library has no Next.js build dependency. */ -import { normalizeBRC100WalletByteFields, stringifyBRC100 } from '@bsv/sdk' +import { stringifyBRC100 } from '@bsv/sdk' export interface HandlerRequest { url: string @@ -54,7 +54,7 @@ export function toNextHandlers(handler: RouteHandler): { const result = await coreFn({ url: (req.url as string | undefined) ?? req.nextUrl?.toString() ?? '', method, - json: async () => normalizeBRC100WalletByteFields(await req.json()) + json: async () => await req.json() }) // Use Web-standard Response (works in Next.js, Deno, Bun, Workers) diff --git a/packages/messaging/authsocket-client/README.md b/packages/messaging/authsocket-client/README.md index 4b7a1a99c..5934a0e0c 100644 --- a/packages/messaging/authsocket-client/README.md +++ b/packages/messaging/authsocket-client/README.md @@ -64,10 +64,11 @@ 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 BRC-100 byte fields across historical and -current runtimes. Existing `number[]`, current `Uint8Array`, and legacy -numeric-key JSON representations are delivered as portable number arrays; -sparse or invalid byte records are not coerced. +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 diff --git a/packages/messaging/authsocket-client/src/AuthSocketClient.ts b/packages/messaging/authsocket-client/src/AuthSocketClient.ts index ccd58cfba..f50ecd671 100644 --- a/packages/messaging/authsocket-client/src/AuthSocketClient.ts +++ b/packages/messaging/authsocket-client/src/AuthSocketClient.ts @@ -12,7 +12,6 @@ import { WalletInterface, Utils, OriginatorDomainNameStringUnder250Bytes, - normalizeBRC100WalletByteFields, stringifyBRC100 } from '@bsv/sdk' import { SocketClientTransport } from './SocketClientTransport.js' @@ -33,7 +32,7 @@ export type AuthSocketClientErrorHandler = ( export function decodeAuthSocketEventPayload(payload: number[]): { eventName: string; data: any } { try { const str = Utils.toUTF8(payload) - const decoded: unknown = normalizeBRC100WalletByteFields(JSON.parse(str)) + const decoded: unknown = JSON.parse(str) if ( decoded === null || typeof decoded !== 'object' || diff --git a/packages/messaging/authsocket-client/src/__tests__/AuthSocketClient.test.ts b/packages/messaging/authsocket-client/src/__tests__/AuthSocketClient.test.ts index 6c80cd6a4..759ce0216 100644 --- a/packages/messaging/authsocket-client/src/__tests__/AuthSocketClient.test.ts +++ b/packages/messaging/authsocket-client/src/__tests__/AuthSocketClient.test.ts @@ -142,7 +142,7 @@ describe('AuthSocketClient', () => { expect(identityKey).toBe('server-key') }) - it('normalizes wallet bytes in both authenticated event directions', () => { + 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]))) @@ -156,7 +156,7 @@ describe('AuthSocketClient', () => { ) client.emit('payment', { transaction: new Uint8Array([1, 2, 3]) }) - expect(received).toHaveBeenCalledWith({ transaction: [4, 5, 6] }) + expect(received).toHaveBeenCalledWith({ transaction: historicalTx }) const [payload] = mockPeer.toPeer.mock.calls[0] expect(JSON.parse(Buffer.from(payload).toString('utf8'))).toEqual({ eventName: 'payment', @@ -164,6 +164,27 @@ describe('AuthSocketClient', () => { }) }) + 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 bccd01aa3..64b59216d 100644 --- a/packages/messaging/authsocket/README.md +++ b/packages/messaging/authsocket/README.md @@ -91,10 +91,11 @@ 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 BRC-100 byte fields across historical and -current runtimes. Existing `number[]`, current `Uint8Array`, and legacy -numeric-key JSON representations are delivered as portable number arrays; -sparse or invalid byte records are not coerced. +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/src/AuthSocketServer.ts b/packages/messaging/authsocket/src/AuthSocketServer.ts index d4eb548c9..d2622f125 100644 --- a/packages/messaging/authsocket/src/AuthSocketServer.ts +++ b/packages/messaging/authsocket/src/AuthSocketServer.ts @@ -5,7 +5,6 @@ import { Peer, SessionManager, AsyncSessionManager, - normalizeBRC100WalletByteFields, stringifyBRC100 } from '@bsv/sdk' import { SocketServerTransport } from './SocketServerTransport.js' @@ -26,7 +25,7 @@ export type AuthSocketErrorHandler = ( export function decodeAuthSocketEventPayload(payload: number[]): { eventName: string; data: any } { try { const str = Buffer.from(payload).toString('utf8') - const decoded: unknown = normalizeBRC100WalletByteFields(JSON.parse(str)) + const decoded: unknown = JSON.parse(str) if ( decoded === null || typeof decoded !== 'object' || diff --git a/packages/messaging/authsocket/src/__tests__/AuthSocket.test.ts b/packages/messaging/authsocket/src/__tests__/AuthSocket.test.ts index da0be3758..64e157783 100644 --- a/packages/messaging/authsocket/src/__tests__/AuthSocket.test.ts +++ b/packages/messaging/authsocket/src/__tests__/AuthSocket.test.ts @@ -65,7 +65,7 @@ describe('AuthSocket', () => { expect(identityKey).toBe('peer-key') }) - it('normalizes wallet bytes in both authenticated event directions', async () => { + 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]))) @@ -74,7 +74,7 @@ describe('AuthSocket', () => { generalMessage({ eventName: 'payment', data: { transaction: historicalTx } }) await authSocket.emit('payment', { transaction: new Uint8Array([1, 2, 3]) }) - expect(received).toHaveBeenCalledWith({ transaction: [4, 5, 6] }) + expect(received).toHaveBeenCalledWith({ transaction: historicalTx }) const [payload] = peer.toPeer.mock.calls[0] expect(JSON.parse(Buffer.from(payload).toString('utf8'))).toEqual({ eventName: 'payment', @@ -82,6 +82,22 @@ describe('AuthSocket', () => { }) }) + 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/middleware/auth-express-middleware/src/__tests/authMiddlewareHelpers.property.test.ts b/packages/middleware/auth-express-middleware/src/__tests/authMiddlewareHelpers.property.test.ts index f92898c52..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 @@ -95,7 +95,7 @@ describe('auth transport serialization properties', () => { expect(convertValueToArray(1n, {})).toEqual(Utils.toArray('1', 'utf8')) }) - test('keeps current and historical nested wallet bytes portable without rewriting unrelated records', () => { + 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( { @@ -109,7 +109,7 @@ describe('auth transport serialization properties', () => { expect(JSON.parse(Utils.toUTF8(encoded))).toEqual({ tx: [1, 2, 3], - payload: { transaction: [4, 5, 6] }, + payload: { transaction: historical }, data: {}, unrelated: historical }) diff --git a/packages/middleware/auth-express-middleware/src/index.ts b/packages/middleware/auth-express-middleware/src/index.ts index a6d7d19a2..aa565ce4b 100644 --- a/packages/middleware/auth-express-middleware/src/index.ts +++ b/packages/middleware/auth-express-middleware/src/index.ts @@ -6,7 +6,7 @@ import { SessionManager, PublicKey, Telemetry, - normalizeBRC100WalletByteFields, + normalizeBRC100ByteFields, stringifyBRC100, type AsyncSessionManager, type AuthMessage, @@ -218,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 = normalizeBRC100WalletByteFields(req.body) as Partial + const message = normalizeBRC100ByteFields(req.body, [ + 'payload', + 'signature' + ]) as Partial if ( typeof message.messageType !== 'string' || message.messageType.length === 0 || diff --git a/packages/sdk/CHANGELOG.md b/packages/sdk/CHANGELOG.md index 30fd2e1d3..eed4f49c9 100644 --- a/packages/sdk/CHANGELOG.md +++ b/packages/sdk/CHANGELOG.md @@ -317,12 +317,16 @@ All notable changes to this project will be documented in this file. The format 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. - When a concrete domain is configured, response messages from every other - origin are ignored before parsing. -- Preserve typed and historical numeric-key byte fields in authenticated JSON - request bodies, remittance envelopes, overlay lookup queries and BEEF - responses, and serialized wallet review errors. Existing `number[]` payloads - retain their zero-copy fast path. The reviewed + `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. diff --git a/packages/sdk/package.json b/packages/sdk/package.json index 78df45594..f62c5cce4 100644 --- a/packages/sdk/package.json +++ b/packages/sdk/package.json @@ -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 index 206957ac2..f9f0e1881 100644 --- a/packages/sdk/src/__tests__/BRC100OverlayTransport.test.ts +++ b/packages/sdk/src/__tests__/BRC100OverlayTransport.test.ts @@ -38,4 +38,18 @@ describe('overlay lookup BRC-100 byte compatibility', () => { 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/transports/SimplifiedFetchTransport.ts b/packages/sdk/src/auth/transports/SimplifiedFetchTransport.ts index 5d4c1ce3e..dd52535f1 100644 --- a/packages/sdk/src/auth/transports/SimplifiedFetchTransport.ts +++ b/packages/sdk/src/auth/transports/SimplifiedFetchTransport.ts @@ -2,10 +2,7 @@ // @ts-expect-error import { AuthMessage, RequestedCertificateSet, Transport } from '../types.js' import * as Utils from '../../primitives/utils.js' -import { - normalizeBRC100WalletByteFields, - stringifyBRC100 -} from '../../wallet/BRC100ByteEncoding.js' +import { normalizeBRC100ByteFields, stringifyBRC100 } from '../../wallet/BRC100ByteEncoding.js' const defaultFetch: typeof fetch = typeof globalThis !== 'undefined' && typeof globalThis.fetch === 'function' @@ -84,7 +81,10 @@ export class SimplifiedFetchTransport implements Transport { throw this.createUnauthenticatedResponseError(url, response, body) } await this.onDataCallback!( - normalizeBRC100WalletByteFields(await response.json()) as AuthMessage + normalizeBRC100ByteFields(await response.json(), [ + 'payload', + 'signature' + ]) as AuthMessage ) if (message.messageType === 'initialRequest') resolve() } catch (error) { diff --git a/packages/sdk/src/overlay-tools/LookupResolver.ts b/packages/sdk/src/overlay-tools/LookupResolver.ts index b09183dfc..66f44f1bc 100644 --- a/packages/sdk/src/overlay-tools/LookupResolver.ts +++ b/packages/sdk/src/overlay-tools/LookupResolver.ts @@ -4,10 +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 { - normalizeBRC100WalletByteFields, - stringifyBRC100 -} from '../wallet/BRC100ByteEncoding.js' +import { normalizeBRC100ByteFields, stringifyBRC100 } from '../wallet/BRC100ByteEncoding.js' const defaultFetch: typeof fetch = typeof globalThis !== 'undefined' && typeof globalThis.fetch === 'function' @@ -432,7 +429,19 @@ export class HTTPSOverlayLookupFacilitator implements OverlayLookupFacilitator { if (isOctetStream(response.headers.get('content-type'))) { return await this.parseOctetStreamLookup(response) } - return normalizeBRC100WalletByteFields(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 fda9816e7..20417d0ff 100644 --- a/packages/sdk/src/remittance/RemittanceManager.ts +++ b/packages/sdk/src/remittance/RemittanceManager.ts @@ -21,7 +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 { normalizeBRC100WalletByteFields, stringifyBRC100 } from '../wallet/BRC100ByteEncoding.js' +import { stringifyBRC100 } from '../wallet/BRC100ByteEncoding.js' import { toBase64, toSafeString } from '../primitives/utils.js' import Random from '../primitives/Random.js' @@ -1850,7 +1850,7 @@ export class InvoiceHandle extends ThreadHandle { function safeParseEnvelope (body: string): RemittanceEnvelope | undefined { try { - const parsed = normalizeBRC100WalletByteFields(JSON.parse(body)) + const parsed = JSON.parse(body) if (typeof parsed !== 'object') return undefined if (parsed.v !== 1) return undefined if (typeof parsed.kind !== 'string') return undefined diff --git a/packages/sdk/src/remittance/__tests/RemittanceManager.test.ts b/packages/sdk/src/remittance/__tests/RemittanceManager.test.ts index 7c8f077fb..a561c1059 100644 --- a/packages/sdk/src/remittance/__tests/RemittanceManager.test.ts +++ b/packages/sdk/src/remittance/__tests/RemittanceManager.test.ts @@ -133,13 +133,18 @@ const waitForKind = async ( } describe('RemittanceManager base flows', () => { - it('serializes module-defined transaction bytes portably in envelopes', async () => { + it('serializes typed module bytes while preserving byte-like opaque module data', async () => { const bus = new MessageBus() - const module: RemittanceModule<{ transaction: Uint8Array }, {}, {}> = { + 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]) }), + createOption: async () => ({ transaction: new Uint8Array([1, 2, 3]), applicationData }), buildSettlement: async () => ({ action: 'settle', artifact: {} }), acceptSettlement: async () => ({ action: 'accept', receiptData: {} }) } @@ -153,6 +158,19 @@ describe('RemittanceManager base flows', () => { 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 () => { diff --git a/packages/sdk/src/wallet/BRC100ByteEncoding.ts b/packages/sdk/src/wallet/BRC100ByteEncoding.ts index 3fbbcdc1c..a31b84dff 100644 --- a/packages/sdk/src/wallet/BRC100ByteEncoding.ts +++ b/packages/sdk/src/wallet/BRC100ByteEncoding.ts @@ -22,22 +22,6 @@ const walletByteFieldNames = new Set([ 'tx' ]) -// These names are byte arrays in BRC-100 methods but are also common JSON -// envelope containers. An empty object is therefore ambiguous and must remain -// an object at recursive boundaries; explicit arrays and typed arrays are not. -const ambiguousContainerFieldNames = new Set(['data', 'payload']) - -function isAmbiguousEmptyContainer(key: string, value: unknown): boolean { - return ( - ambiguousContainerFieldNames.has(key) && - value != null && - typeof value === 'object' && - !Array.isArray(value) && - !isUint8Array(value) && - Object.keys(value).length === 0 - ) -} - function isUint8Array(value: unknown): value is Uint8Array { if (value == null || typeof value !== 'object' || typeof ArrayBuffer === 'undefined') return false return ( @@ -81,6 +65,10 @@ export function normalizeBRC100ByteArray(value: unknown): AtomicBEEF | 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 @@ -110,13 +98,10 @@ export function brc100JsonReplacer( // 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] - if (walletByteFieldNames.has(key)) { - const candidate = original ?? value - const bytes = isAmbiguousEmptyContainer(key, candidate) - ? undefined - : toBRC100PortableByteArray(candidate) - if (bytes != null) return bytes - } + // 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 } @@ -130,15 +115,40 @@ export function stringifyBRC100(value: unknown, space?: string | number): string 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 = isAmbiguousEmptyContainer(key, fieldValue) - ? undefined - : normalizeBRC100ByteArray(fieldValue) + const bytes = normalizeBRC100ByteArray(fieldValue) if (bytes != null) candidate[key] = bytes else visit(fieldValue) } 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/__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 index 8c5272751..2d4fa5482 100644 --- a/packages/sdk/src/wallet/__tests/BRC100ByteEncoding.test.ts +++ b/packages/sdk/src/wallet/__tests/BRC100ByteEncoding.test.ts @@ -2,6 +2,7 @@ import vm from 'node:vm' import { brc100JsonReplacer, normalizeBRC100ByteArray, + normalizeBRC100ByteFields, normalizeBRC100WalletByteFields, stringifyBRC100, toBRC100PortableByteArray @@ -33,7 +34,7 @@ describe('BRC-100 byte encoding', () => { 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({})).toEqual([]) + expect(normalizeBRC100ByteArray({})).toBeUndefined() }) it.each([ @@ -50,6 +51,25 @@ describe('BRC-100 byte encoding', () => { 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', () => { @@ -74,11 +94,57 @@ describe('BRC-100 byte encoding', () => { expect(stringifyBRC100(payload)).toBe('{"tx":[1,2,3]}') }) - it('repairs historical numeric-key wallet byte fields during serialization', () => { + 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 unrelated = JSON.parse(JSON.stringify(new Uint8Array([4, 5]))) + 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: {} + }) + }) - expect(stringifyBRC100({ tx, unrelated })).toBe('{"tx":[1,2,3],"unrelated":{"0":4,"1":5}}') + 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', () => { @@ -105,18 +171,24 @@ describe('BRC-100 byte encoding', () => { expect(result.unrelated).toBe(unrelated) }) - it('preserves generic empty data and payload containers while visiting nested byte fields', () => { + 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":{},"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/substrates/HTTPWalletJSON.ts b/packages/sdk/src/wallet/substrates/HTTPWalletJSON.ts index 97a3c6496..4c64887ce 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 } from './utils/jsonByteEncoding.js' +import { normalizeBRC100WalletByteFields } from '../BRC100ByteEncoding.js' import WERR_INSUFFICIENT_FUNDS from '../WERR_INSUFFICIENT_FUNDS.js' import { stringifyBRC100 } from '../BRC100ByteEncoding.js' @@ -106,7 +106,7 @@ export default class HTTPWalletJSON implements WalletInterface { 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) { diff --git a/packages/sdk/src/wallet/substrates/ReactNativeWebView.ts b/packages/sdk/src/wallet/substrates/ReactNativeWebView.ts index 0f1df20a3..44e3ed504 100644 --- a/packages/sdk/src/wallet/substrates/ReactNativeWebView.ts +++ b/packages/sdk/src/wallet/substrates/ReactNativeWebView.ts @@ -16,8 +16,9 @@ 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.') @@ -33,21 +34,46 @@ export default class ReactNativeWebView extends InvokableWalletBase { '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 => { - if (this.domain !== '*' && e.origin !== this.domain) return - const data = normalizeBRC100WalletByteFields(JSON.parse(e.data)) - if (data.type !== 'CWI' || data.id !== id || data.isInvocation === true) { + let data: any + try { + data = JSON.parse(e.data) + } catch { return } - if (typeof globalThis.window.removeEventListener === 'function') { - globalThis.window.removeEventListener('message', listener) + if (data?.type !== 'CWI' || data.id !== id || data.isInvocation === true) { + return + } + if ( + 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 } + cleanup() + normalizeBRC100WalletByteFields(data.result) if (data.status === 'error') { const err = new WalletError(data.description, data.code) reject(err) @@ -56,15 +82,40 @@ export default class ReactNativeWebView extends InvokableWalletBase { } } globalThis.window.addEventListener('message', listener) - ;(globalThis.window as unknown as ReactNativeWindow).ReactNativeWebView.postMessage( - stringifyBRC100({ + 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() + } + const candidate = /^https?:\/\//i.test(domain) ? domain : `https://${domain}` + const origin = new URL(candidate).origin + if (origin === 'null') throw new TypeError() + 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/ReactNativeWebView.test.ts b/packages/sdk/src/wallet/substrates/__tests/ReactNativeWebView.test.ts index 7a835f86a..e2c69edf1 100644 --- a/packages/sdk/src/wallet/substrates/__tests/ReactNativeWebView.test.ts +++ b/packages/sdk/src/wallet/substrates/__tests/ReactNativeWebView.test.ts @@ -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,27 @@ 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() @@ -139,19 +166,10 @@ describe('ReactNativeWebView', () => { }) }) - it('accepts responses only from the configured origin', async () => { + it('normalizes a schemeless configured domain and accepts its full origin', async () => { jest.spyOn(Utils, 'toBase64').mockReturnValue('request-id') - const substrate = new ReactNativeWebView('https://trusted.example') + const substrate = new ReactNativeWebView('trusted.example') const promise = substrate.invoke('getVersion', {}) - let settled = false - promise.then( - () => { - settled = true - }, - () => { - settled = true - } - ) const response = { type: 'CWI', isInvocation: false, @@ -160,15 +178,70 @@ describe('ReactNativeWebView', () => { result: { version: '1.0.0' } } - dispatchMessage(response, 'https://hostile.example') - await new Promise(resolve => setTimeout(resolve, 1)) - expect(settled).toBe(false) - expect(removeEventListenerMock).not.toHaveBeenCalled() - 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() @@ -212,6 +285,7 @@ describe('ReactNativeWebView', () => { status: 'success', result: {} }) + getMessageListener()({ data: '{not-json', origin: 'https://hostile.example' }) dispatchMessage({ type: 'CWI', isInvocation: false, diff --git a/packages/sdk/src/wallet/substrates/__tests/jsonByteEncoding.test.ts b/packages/sdk/src/wallet/substrates/__tests/jsonByteEncoding.test.ts index 9e4f5911a..2b75c6d45 100644 --- a/packages/sdk/src/wallet/substrates/__tests/jsonByteEncoding.test.ts +++ b/packages/sdk/src/wallet/substrates/__tests/jsonByteEncoding.test.ts @@ -24,28 +24,26 @@ describe('normalizeWalletJsonTx', () => { expect(normalizeWalletJsonTx(result).tx).toEqual([1, 1, 1, 1, 128, 7]) }) - it('repairs all top-level wallet byte 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({ - signature: [48, 68], - BEEF: [2, 0, 190, 239], - ciphertext: [1] - }) + 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('repairs nested signable and review-action byte fields', () => { + 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)).toEqual({ - signableTransaction: { tx: [1, 2], reference: 'cmVm' }, - reviewActionResults: [{ competingBeef: [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', () => { diff --git a/packages/sdk/src/wallet/substrates/utils/jsonByteEncoding.ts b/packages/sdk/src/wallet/substrates/utils/jsonByteEncoding.ts index c53a39adf..8fdcaf988 100644 --- a/packages/sdk/src/wallet/substrates/utils/jsonByteEncoding.ts +++ b/packages/sdk/src/wallet/substrates/utils/jsonByteEncoding.ts @@ -11,12 +11,12 @@ * corrupt requests. * * `walletJsonReplacer` makes serialization safe. `normalizeWalletJsonTx` - * repairs already-corrupted wallet payloads produced by affected deployed - * wallets. Both compatibility names delegate to the shared BRC-100 byte - * encoding contract used by every JSON transport. + * 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, normalizeBRC100WalletByteFields } from '../../BRC100ByteEncoding.js' +import { brc100JsonReplacer, normalizeBRC100ByteArray } from '../../BRC100ByteEncoding.js' /** JSON.stringify replacer that encodes Uint8Array values as plain number arrays. */ export function walletJsonReplacer( @@ -28,10 +28,13 @@ export function walletJsonReplacer( } /** - * Repair wallet byte fields affected by Uint8Array JSON serialization, - * including direct and signable action transactions, list-output BEEF, - * cryptographic byte results, and review-action error BEEF. + * 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 { - return normalizeBRC100WalletByteFields(value) + 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/ts-wallet-relay/src/client/WalletPairingSession.ts b/packages/wallet/ts-wallet-relay/src/client/WalletPairingSession.ts index 75cd8198e..4842af5ad 100644 --- a/packages/wallet/ts-wallet-relay/src/client/WalletPairingSession.ts +++ b/packages/wallet/ts-wallet-relay/src/client/WalletPairingSession.ts @@ -274,8 +274,7 @@ export class WalletPairingSession { return // tampered or wrong key — drop } - const msg = normalizeBRC100WalletByteFields(JSON.parse(plaintext)) as - RpcRequest | RpcResponse + const msg = JSON.parse(plaintext) as RpcRequest | RpcResponse // M4: Replay protection — drop anything not strictly greater than last seq if (typeof msg.seq !== 'number' || msg.seq <= this._lastSeq) { @@ -340,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, diff --git a/packages/wallet/ts-wallet-relay/src/client/WalletRelayClient.ts b/packages/wallet/ts-wallet-relay/src/client/WalletRelayClient.ts index 8090dded1..98afe85a9 100644 --- a/packages/wallet/ts-wallet-relay/src/client/WalletRelayClient.ts +++ b/packages/wallet/ts-wallet-relay/src/client/WalletRelayClient.ts @@ -276,10 +276,11 @@ export class WalletRelayClient { throw new WalletRelayError(msg, code) } - const rpc = normalizeBRC100WalletByteFields(await res.json()) as { + const rpc = (await res.json()) as { result?: unknown error?: { code: number; message: string } } + normalizeBRC100WalletByteFields(rpc.result) const response: WalletResponse = { requestId, result: rpc.result, diff --git a/packages/wallet/ts-wallet-relay/src/server/WalletRelayService.ts b/packages/wallet/ts-wallet-relay/src/server/WalletRelayService.ts index 9fe574513..91401356d 100644 --- a/packages/wallet/ts-wallet-relay/src/server/WalletRelayService.ts +++ b/packages/wallet/ts-wallet-relay/src/server/WalletRelayService.ts @@ -22,7 +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 { stringifyBRC100 } from '@bsv/sdk' +import { normalizeBRC100WalletByteFields, stringifyBRC100 } from '@bsv/sdk' export interface WalletRelayServiceOptions { /** @@ -500,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) diff --git a/packages/wallet/ts-wallet-relay/src/server/WalletRequestHandler.ts b/packages/wallet/ts-wallet-relay/src/server/WalletRequestHandler.ts index e089e99b2..27cb450af 100644 --- a/packages/wallet/ts-wallet-relay/src/server/WalletRequestHandler.ts +++ b/packages/wallet/ts-wallet-relay/src/server/WalletRequestHandler.ts @@ -1,5 +1,4 @@ import { randomUUID } from 'node:crypto' -import { normalizeBRC100WalletByteFields } from '@bsv/sdk' import type { RpcRequest, RpcResponse } from '../types.js' /** @@ -20,7 +19,7 @@ export class WalletRequestHandler { } parseMessage(raw: string): RpcRequest | RpcResponse { - return normalizeBRC100WalletByteFields(JSON.parse(raw)) as RpcRequest | RpcResponse + return JSON.parse(raw) as RpcRequest | RpcResponse } isResponse(msg: RpcRequest | RpcResponse): msg is RpcResponse { diff --git a/packages/wallet/wallet-toolbox/CHANGELOG.md b/packages/wallet/wallet-toolbox/CHANGELOG.md index aa3c39d70..c1416b71c 100644 --- a/packages/wallet/wallet-toolbox/CHANGELOG.md +++ b/packages/wallet/wallet-toolbox/CHANGELOG.md @@ -9,8 +9,8 @@ attention to changes that materially alter behavior or extend functionality. - 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. -- Normalize BRC-100 byte fields in both directions across the WAB JSON - transport, including historical numeric-key responses. +- 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 diff --git a/packages/wallet/wallet-toolbox/src/wab-client/WABTransport.ts b/packages/wallet/wallet-toolbox/src/wab-client/WABTransport.ts index 4a0cad888..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 { normalizeBRC100WalletByteFields, stringifyBRC100, 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 @@ -437,7 +437,7 @@ export class WABTransport { ): T { let parsed: unknown try { - parsed = normalizeBRC100WalletByteFields(JSON.parse(responseText)) + parsed = JSON.parse(responseText) } catch (cause) { const error = new WABClientError( 'WAB_INVALID_RESPONSE', 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 788958380..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,18 +232,18 @@ describe('WAB transport hardening', () => { }) }) - it('normalizes wallet byte fields in both JSON directions', async () => { + 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: number[] }>('/test', { + 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([4, 5, 6]) + expect(result.tx).toEqual(historicalTx) }) it('sends correlation IDs without telemetry and preserves request context on errors', async () => { diff --git a/scripts/brc100-byte-boundary.test.mjs b/scripts/brc100-byte-boundary.test.mjs index 4d822e602..1cec7271b 100644 --- a/scripts/brc100-byte-boundary.test.mjs +++ b/scripts/brc100-byte-boundary.test.mjs @@ -20,7 +20,6 @@ const walletBoundaryFiles = [ '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/WalletRequestHandler.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', @@ -61,7 +60,7 @@ const specializedBoundaries = [ }, { file: 'packages/sdk/src/remittance/RemittanceManager.ts', - forbidden: /(?:const body = JSON\.stringify\(env\)|const parsed = JSON\.parse\(body\))/ + forbidden: /const body = JSON\.stringify\(env\)/ }, { file: 'packages/sdk/src/wallet/WalletError.ts', @@ -69,7 +68,7 @@ const specializedBoundaries = [ }, { file: 'packages/wallet/wallet-toolbox/src/wab-client/WABTransport.ts', - forbidden: /(?:JSON\.stringify\(options\.body\)|parsed = JSON\.parse\(responseText\))/ + forbidden: /JSON\.stringify\(options\.body\)/ } ] @@ -81,6 +80,22 @@ for (const { file, forbidden } of specializedBoundaries) { }) } +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['"]/) diff --git a/scripts/mutation-testing.mjs b/scripts/mutation-testing.mjs index 9aef4c056..bd3941062 100644 --- a/scripts/mutation-testing.mjs +++ b/scripts/mutation-testing.mjs @@ -145,21 +145,11 @@ async function changedConfiguredTargets(base, currentTargets) { ) } -export function targetsForUnresolvedMutationRange(currentTargets, error) { - const message = error instanceof Error ? error.message : String(error) - const match = /Unable to resolve mutation range in ([^:]+):/.exec(message) - if (match === null) return Object.keys(currentTargets) - - const filePath = normalized(match[1]) - const matchingTargets = Object.entries(currentTargets) - .filter(([, target]) => - (target.mutate ?? []).some(mutate => mutate.replace(/:\d+(?:-\d+)?$/, '') === filePath) - ) - .map(([id]) => id) - - // If the previous target was removed, selecting every current target is the - // conservative fallback: a configuration change must never suppress mutation coverage. - return matchingTargets.length > 0 ? matchingTargets : Object.keys(currentTargets) +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() { diff --git a/scripts/mutation-testing.test.mjs b/scripts/mutation-testing.test.mjs index 398c3bdbb..1adf31a8c 100644 --- a/scripts/mutation-testing.test.mjs +++ b/scripts/mutation-testing.test.mjs @@ -105,7 +105,7 @@ test('mutation selection survives source edits that invalidate previous range ma rangedTargets, new Error('Unable to resolve mutation range in src/auth/Transport.ts: old .. new') ), - ['auth'] + ['auth', 'wallet'] ) assert.deepEqual( targetsForUnresolvedMutationRange( 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) From 32bebdb52c2d0d59501538feb5b8ef88e09f4408 Mon Sep 17 00:00:00 2001 From: Ty J Everett Date: Fri, 14 Aug 2026 19:58:16 -0700 Subject: [PATCH 11/14] fix: satisfy zero-findings quality gate --- packages/sdk/src/wallet/BRC100ByteEncoding.ts | 39 ++++++++++--------- .../src/wallet/substrates/HTTPWalletJSON.ts | 3 +- .../wallet/substrates/ReactNativeWebView.ts | 4 +- 3 files changed, 24 insertions(+), 22 deletions(-) diff --git a/packages/sdk/src/wallet/BRC100ByteEncoding.ts b/packages/sdk/src/wallet/BRC100ByteEncoding.ts index a31b84dff..6777945b3 100644 --- a/packages/sdk/src/wallet/BRC100ByteEncoding.ts +++ b/packages/sdk/src/wallet/BRC100ByteEncoding.ts @@ -42,27 +42,15 @@ function isUnsupportedBinaryView(value: unknown): boolean { ) } -/** - * 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)) { - for (let i = 0; i < value.length; i++) { - if (!hasOwn.call(value, i) || !isByte(value[i])) return undefined - } - return value as number[] +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 @@ -82,6 +70,21 @@ export function normalizeBRC100ByteArray(value: unknown): AtomicBEEF | 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) diff --git a/packages/sdk/src/wallet/substrates/HTTPWalletJSON.ts b/packages/sdk/src/wallet/substrates/HTTPWalletJSON.ts index 4c64887ce..ff5d138b8 100644 --- a/packages/sdk/src/wallet/substrates/HTTPWalletJSON.ts +++ b/packages/sdk/src/wallet/substrates/HTTPWalletJSON.ts @@ -38,9 +38,8 @@ 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 { normalizeBRC100WalletByteFields } from '../BRC100ByteEncoding.js' +import { normalizeBRC100WalletByteFields, stringifyBRC100 } from '../BRC100ByteEncoding.js' import WERR_INSUFFICIENT_FUNDS from '../WERR_INSUFFICIENT_FUNDS.js' -import { stringifyBRC100 } from '../BRC100ByteEncoding.js' function deserializeWalletError(data: any): Error | undefined { switch (data.code) { diff --git a/packages/sdk/src/wallet/substrates/ReactNativeWebView.ts b/packages/sdk/src/wallet/substrates/ReactNativeWebView.ts index 44e3ed504..20313e2ea 100644 --- a/packages/sdk/src/wallet/substrates/ReactNativeWebView.ts +++ b/packages/sdk/src/wallet/substrates/ReactNativeWebView.ts @@ -109,11 +109,11 @@ 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() + 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() + 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.') From be86316d5f5d1395d393240fecdcb749ac9313fc Mon Sep 17 00:00:00 2001 From: Ty J Everett Date: Fri, 14 Aug 2026 20:07:54 -0700 Subject: [PATCH 12/14] chore: ratchet reviewed browser bundle growth --- .../wallet/wallet-toolbox/client/platform-budget.json | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/wallet/wallet-toolbox/client/platform-budget.json b/packages/wallet/wallet-toolbox/client/platform-budget.json index 59ad91ed1..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": 1606000, - "gzip": 378600, - "brotli": 296600 + "raw": 1607000, + "gzip": 378800, + "brotli": 297000 }, "esbuild": { - "raw": 1252000, + "raw": 1252500, "gzip": 345000, - "brotli": 276900 + "brotli": 277300 } } } From 87be23e204cfd067b271fafc50bc7fccd09e1c45 Mon Sep 17 00:00:00 2001 From: Ty J Everett Date: Fri, 14 Aug 2026 20:19:55 -0700 Subject: [PATCH 13/14] ci: ratchet portable wallet bundle sizes --- packages/sdk/browser-budget.json | 6 +++--- packages/wallet/wallet-toolbox/mobile/platform-budget.json | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/sdk/browser-budget.json b/packages/sdk/browser-budget.json index 69eff13a7..b1c0b6ae3 100644 --- a/packages/sdk/browser-budget.json +++ b/packages/sdk/browser-budget.json @@ -16,12 +16,12 @@ "prohibitedExports": [], "maximumBytes": { "vite": { - "raw": 740000, + "raw": 742000, "gzip": 185000, "brotli": 150000 }, "esbuild": { - "raw": 559000, + "raw": 560000, "gzip": 168000, "brotli": 140000 } @@ -30,7 +30,7 @@ "path": "dist/umd/bundle.js", "global": "bsv", "maximumBytes": { - "raw": 554000, + "raw": 555000, "gzip": 162000, "brotli": 136000 } diff --git a/packages/wallet/wallet-toolbox/mobile/platform-budget.json b/packages/wallet/wallet-toolbox/mobile/platform-budget.json index e69061a83..599068f41 100644 --- a/packages/wallet/wallet-toolbox/mobile/platform-budget.json +++ b/packages/wallet/wallet-toolbox/mobile/platform-budget.json @@ -7,7 +7,7 @@ "brotli": 360000 }, "hermes": { - "raw": 3365000, + "raw": 3367000, "gzip": 1365000, "brotli": 1070000 } From 48cbbccadaa682c2af9e39c2108e5f229f12bd0d Mon Sep 17 00:00:00 2001 From: Ty J Everett Date: Fri, 14 Aug 2026 20:27:51 -0700 Subject: [PATCH 14/14] ci: bound hosted Hermes compression variance --- packages/wallet/wallet-toolbox/mobile/platform-budget.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/wallet/wallet-toolbox/mobile/platform-budget.json b/packages/wallet/wallet-toolbox/mobile/platform-budget.json index 599068f41..ed26d710e 100644 --- a/packages/wallet/wallet-toolbox/mobile/platform-budget.json +++ b/packages/wallet/wallet-toolbox/mobile/platform-budget.json @@ -8,7 +8,7 @@ }, "hermes": { "raw": 3367000, - "gzip": 1365000, + "gzip": 1366000, "brotli": 1070000 } }