From 0c37478860aa2e274caa0f971a480fc6c52cfdcf Mon Sep 17 00:00:00 2001 From: Ty J Everett Date: Sun, 26 Jul 2026 13:56:20 -0700 Subject: [PATCH 1/6] feat: standardize foundation runtime packages --- .../repository-health/contract-baseline.json | 86 +- packages/network/ts-p2p/README.md | 221 +- packages/network/ts-p2p/demo.ts | 70 +- packages/network/ts-p2p/jest.config.js | 41 +- packages/network/ts-p2p/package.json | 12 +- packages/network/ts-p2p/src/index.ts | 445 ++-- packages/network/ts-p2p/src/messages.ts | 6 +- packages/network/ts-p2p/src/subtree-test.ts | 105 - packages/network/ts-p2p/src/subtrees.ts | 623 +++--- packages/network/ts-p2p/test/index.test.ts | 252 +++ packages/network/ts-p2p/test/messages.test.ts | 2 +- packages/network/ts-p2p/test/subtrees.test.ts | 146 ++ packages/network/ts-p2p/tsconfig.json | 9 +- packages/overlays/gasp-core/AGENTS.md | 22 +- packages/overlays/gasp-core/API.md | 256 ++- packages/overlays/gasp-core/BASELINE.md | 59 +- packages/overlays/gasp-core/README.md | 164 +- packages/overlays/gasp-core/jest.config.js | 22 +- packages/overlays/gasp-core/mod.ts | 2 +- packages/overlays/gasp-core/package.json | 49 +- packages/overlays/gasp-core/src/GASP.ts | 132 +- .../gasp-core/src/__tests/GASP.test.ts | 1793 +++++++++-------- packages/overlays/gasp-core/ts2md.json | 2 +- packages/overlays/gasp-core/tsconfig.cjs.json | 7 +- .../overlays/gasp-core/tsconfig.eslint.json | 6 +- packages/overlays/gasp-core/tsconfig.esm.json | 3 +- .../overlays/gasp-core/tsconfig.types.json | 3 +- packages/sdk/.npmignore | 1 + packages/sdk/README.md | 53 +- packages/sdk/browser-budget.json | 38 + packages/sdk/jest.config.js | 47 +- packages/sdk/package.json | 696 +++++-- packages/sdk/rspack.config.js | 1 + packages/sdk/src/auth/clients/AuthFetch.ts | 562 +++--- packages/sdk/src/compat/ECIES.ts | 135 +- packages/sdk/src/compat/Utxo.ts | 7 +- packages/sdk/src/identity/ContactsManager.ts | 306 ++- packages/sdk/src/identity/IdentityClient.ts | 121 +- packages/sdk/src/kvstore/GlobalKVStore.ts | 270 ++- .../overlay-tools/HostReputationTracker.ts | 54 +- .../sdk/src/overlay-tools/LookupResolver.ts | 280 ++- .../sdk/src/overlay-tools/SHIPBroadcaster.ts | 134 +- packages/sdk/src/primitives/BigNumber.ts | 831 ++++++-- packages/sdk/src/primitives/Curve.ts | 96 +- packages/sdk/src/primitives/DRBG.ts | 12 +- packages/sdk/src/primitives/Hash.ts | 642 +++--- packages/sdk/src/primitives/K256.ts | 38 +- packages/sdk/src/primitives/Point.ts | 283 ++- packages/sdk/src/primitives/Secp256r1.ts | 77 +- .../src/primitives/TransactionSignature.ts | 109 +- packages/sdk/src/primitives/utils.ts | 167 +- packages/sdk/src/script/Script.ts | 100 +- packages/sdk/src/script/Spend.ts | 822 +++++--- packages/sdk/src/transaction/MerklePath.ts | 143 +- packages/sdk/src/transaction/Transaction.ts | 342 ++-- .../src/transaction/http/BinaryFetchClient.ts | 42 +- .../src/transaction/http/DefaultHttpClient.ts | 25 +- packages/sdk/src/wallet/ProtoWallet.ts | 137 +- packages/sdk/tsconfig.base.json | 24 +- packages/sdk/tsconfig.cjs.json | 7 +- packages/sdk/tsconfig.esm.json | 3 +- packages/sdk/tsconfig.json | 2 +- packages/sdk/tsconfig.types.json | 3 +- packages/verifast/README.md | 14 +- packages/verifast/bench/batch-benchmark.ts | 82 +- packages/verifast/bench/benchmark.ts | 68 +- packages/verifast/bench/corpus.ts | 34 +- packages/verifast/bench/crypto-benchmark.ts | 26 +- .../bench/results/2026-07-15-m3-max.md | 42 +- .../bench/results/2026-07-22-m3-max.md | 38 +- .../2026-07-23-compact-crypto-workers.md | 52 +- packages/verifast/bench/warmup-benchmark.ts | 10 +- packages/verifast/browser/main.ts | 26 +- packages/verifast/browser/test.mjs | 16 +- packages/verifast/jest.config.js | 53 +- packages/verifast/mod.browser.ts | 5 +- packages/verifast/mod.ts | 5 +- packages/verifast/package.json | 47 +- packages/verifast/scripts/build-cjs.mjs | 8 +- packages/verifast/scripts/check-artifacts.mjs | 4 +- .../verifast/scripts/test-browser-bundler.mjs | 43 +- .../verifast/scripts/test-node-consumers.mjs | 97 +- packages/verifast/src/BdkBatch.ts | 11 +- packages/verifast/src/BdkVerifier.browser.ts | 50 +- packages/verifast/src/BdkVerifier.ts | 39 +- packages/verifast/src/BdkVerifierCore.ts | 346 ++-- packages/verifast/src/BdkVerifierTypes.ts | 127 +- .../verifast/src/__tests/BdkBatch.test.ts | 41 + .../verifast/src/__tests/BdkVerifier.test.ts | 360 ++-- .../__tests/BdkVerifierEntrypoints.test.ts | 85 + .../verifast/src/__tests/BdkWorkers.test.ts | 233 ++- packages/verifast/src/__tests/flags.test.ts | 14 +- .../verifast/src/__tests/realWasm.test.ts | 249 +-- packages/verifast/src/flags.ts | 2 +- .../src/workers/BdkVerifierBrowserWorker.ts | 9 +- .../src/workers/BdkVerifierNodeWorker.ts | 10 +- .../verifast/src/workers/BdkWorkerPool.ts | 53 +- .../verifast/src/workers/BdkWorkerProtocol.ts | 117 +- .../src/workers/BdkWorkerScheduler.ts | 67 +- packages/verifast/tsconfig.json | 9 +- packages/verifast/umd.ts | 10 +- packages/wallet/ts-wallet-relay/AGENTS.md | 65 +- packages/wallet/ts-wallet-relay/API.md | 473 +++-- packages/wallet/ts-wallet-relay/BASELINE.md | 65 +- packages/wallet/ts-wallet-relay/README.md | 186 +- packages/wallet/ts-wallet-relay/bin/init.mjs | 43 +- .../ts-wallet-relay/browser-budget.json | 25 + packages/wallet/ts-wallet-relay/build.mjs | 21 +- .../wallet/ts-wallet-relay/jest.config.cjs | 69 +- packages/wallet/ts-wallet-relay/package.json | 71 +- .../scripts/copy-cjs-types.mjs | 34 + packages/wallet/ts-wallet-relay/src/client.ts | 12 +- .../src/client/WalletPairingSession.ts | 84 +- .../src/client/WalletRelayClient.ts | 150 +- packages/wallet/ts-wallet-relay/src/index.ts | 11 +- .../ts-wallet-relay/src/react/QRDisplay.tsx | 23 +- .../src/react/QRPairingCode.tsx | 18 +- .../ts-wallet-relay/src/react/RequestLog.tsx | 2 +- .../src/react/WalletConnectionModal.tsx | 16 +- .../src/react/useWalletRelayClient.ts | 41 +- .../src/server/QRSessionManager.ts | 16 +- .../src/server/WalletRelayService.ts | 102 +- .../src/server/WebSocketRelay.ts | 34 +- .../ts-wallet-relay/src/shared/crypto.ts | 4 +- .../src/shared/originMatcher.ts | 12 +- .../ts-wallet-relay/src/shared/pairingUri.ts | 63 +- packages/wallet/ts-wallet-relay/src/types.ts | 41 +- .../template/backend/.env.example | 4 + .../template/backend/server.ts | 27 +- .../frontend/components/QRDisplay.tsx | 10 +- .../frontend/components/RequestLog.tsx | 4 +- .../frontend/components/WalletActions.tsx | 6 +- .../components/WalletConnectionModal.tsx | 4 +- .../template/frontend/types/wallet.ts | 18 +- .../template/frontend/views/DesktopView.tsx | 27 +- .../nextjs/app/api/request/[id]/route.ts | 7 +- .../nextjs/app/api/session/[id]/route.ts | 5 +- .../template/nextjs/components/QRDisplay.tsx | 10 +- .../template/nextjs/components/RequestLog.tsx | 4 +- .../nextjs/components/WalletActions.tsx | 6 +- .../nextjs/components/WalletPairingView.tsx | 6 +- .../template/nextjs/lib/relay.ts | 8 +- .../template/nextjs/server.mjs | 4 +- .../template/nextjs/types/wallet.ts | 18 +- .../tests/QRPairingCode.test.tsx | 5 +- .../tests/WalletRelayClient.test.ts | 302 +++ .../ts-wallet-relay/tests/crypto.test.ts | 4 +- .../ts-wallet-relay/tests/pairingUri.test.ts | 110 +- .../tests/react-components.test.tsx | 187 ++ .../ts-wallet-relay/tests/relay.e2e.test.ts | 200 +- .../tests/relay.upgrade.test.ts | 28 +- .../ts-wallet-relay/tests/security.test.ts | 10 +- packages/wallet/wallet-toolbox/README.md | 84 +- packages/wallet/wallet-toolbox/jest.config.ts | 19 +- packages/wallet/wallet-toolbox/package.json | 21 +- .../src/CWIStyleWalletManager.ts | 437 ++-- packages/wallet/wallet-toolbox/src/Setup.ts | 55 +- .../wallet/wallet-toolbox/src/SetupClient.ts | 41 +- packages/wallet/wallet-toolbox/src/Wallet.ts | 179 +- .../src/WalletPermissionsManager.ts | 592 +++--- .../src/mockchain/merkleTree.ts | 13 +- .../src/monitor/tasks/TaskCheckForProofs.ts | 20 +- .../src/monitor/tasks/TaskClock.ts | 8 +- .../src/monitor/tasks/TaskNewHeader.ts | 10 +- .../src/monitor/tasks/TaskSyncWhenIdle.ts | 6 +- .../src/sdk/PrivilegedKeyManager.ts | 50 +- .../wallet-toolbox/src/sdk/WERR_errors.ts | 42 +- .../wallet-toolbox/src/services/Services.ts | 119 +- .../services/chaintracker/BHServiceClient.ts | 50 +- .../chaintracks/ChaintracksServiceClient.ts | 44 +- .../Ingest/BulkIngestorWhatsOnChainCdn.ts | 10 +- .../Ingest/LiveIngestorTeranodeP2P.ts | 13 +- .../Ingest/WhatsOnChainIngestorWs.ts | 42 +- .../__tests/BulkIngestorCDNBabbage.test.ts | 177 +- .../Storage/ChaintracksStorageIdb.ts | 76 +- .../chaintracks/util/BulkFilesReader.ts | 61 +- .../chaintracks/util/ChaintracksFs.ts | 44 +- .../chaintracks/util/blockHeaderUtilities.ts | 78 +- .../src/services/providers/Arcade.ts | 72 +- .../signer/actionBatch/ActionBatchPlanner.ts | 125 +- .../methods/buildSignableTransaction.ts | 37 +- .../src/signer/methods/createAction.ts | 39 +- .../src/signer/methods/internalizeAction.ts | 16 +- .../wallet-toolbox/src/storage/StorageIdb.ts | 446 ++-- .../src/storage/StorageProvider.ts | 332 +-- .../src/storage/WalletStorageManager.ts | 195 +- .../storage/methods/actionBatchValidation.ts | 112 +- .../src/storage/methods/createAction.ts | 260 +-- .../src/storage/methods/generateChange.ts | 39 +- .../storage/methods/getBeefForTransaction.ts | 59 +- .../src/storage/methods/listCertificates.ts | 4 +- .../src/storage/methods/listOutputsIdb.ts | 14 +- .../src/storage/methods/listOutputsKnex.ts | 18 +- .../src/storage/methods/offsetKey.ts | 19 +- .../src/storage/methods/processAction.ts | 63 +- .../src/storage/methods/purgeData.ts | 10 +- .../src/storage/methods/purgeDataIdb.ts | 2 +- .../src/storage/methods/reviewStatus.ts | 18 +- .../src/storage/remoting/StorageClientBase.ts | 91 +- .../src/storage/remoting/StorageMobile.ts | 83 +- .../schema/entities/EntityProvenTxReq.ts | 156 +- .../schema/entities/EntitySyncState.ts | 99 +- .../storage/schema/entities/MergeEntity.ts | 30 +- .../wallet-toolbox/src/utility/Format.ts | 22 +- .../src/utility/tscProofToMerklePath.ts | 4 +- .../src/utility/utilityHelpers.ts | 82 +- .../wallet/wallet-toolbox/tsconfig.all.json | 5 +- .../wallet-toolbox/tsconfig.client.json | 12 +- .../wallet-toolbox/tsconfig.eslint.json | 10 +- packages/wallet/wallet-toolbox/tsconfig.json | 32 +- .../wallet-toolbox/tsconfig.mobile.json | 11 +- pnpm-lock.yaml | 16 +- scripts/check-browser-package.mjs | 13 +- scripts/check-package-artifact.mjs | 126 +- scripts/check-package-artifact.test.mjs | 35 + 215 files changed, 12633 insertions(+), 8710 deletions(-) delete mode 100644 packages/network/ts-p2p/src/subtree-test.ts create mode 100644 packages/network/ts-p2p/test/index.test.ts create mode 100644 packages/network/ts-p2p/test/subtrees.test.ts create mode 100644 packages/sdk/.npmignore create mode 100644 packages/sdk/browser-budget.json create mode 100644 packages/verifast/src/__tests/BdkBatch.test.ts create mode 100644 packages/verifast/src/__tests/BdkVerifierEntrypoints.test.ts create mode 100644 packages/wallet/ts-wallet-relay/browser-budget.json create mode 100644 packages/wallet/ts-wallet-relay/scripts/copy-cjs-types.mjs create mode 100644 packages/wallet/ts-wallet-relay/tests/WalletRelayClient.test.ts create mode 100644 packages/wallet/ts-wallet-relay/tests/react-components.test.tsx diff --git a/governance/repository-health/contract-baseline.json b/governance/repository-health/contract-baseline.json index 1a61fe707..23826b030 100644 --- a/governance/repository-health/contract-baseline.json +++ b/governance/repository-health/contract-baseline.json @@ -1,7 +1,7 @@ { "schemaVersion": 1, "recordedAt": "2026-07-26", - "findingCount": 45, + "findingCount": 24, "findings": [ { "id": ".::missing-script::format:check", @@ -39,34 +39,6 @@ "id": "docs-site::missing-script::test", "message": "Profile documentation-site requires script test" }, - { - "id": "packages/network/ts-p2p::missing-script::format:check", - "message": "Profile node-library requires script format:check" - }, - { - "id": "packages/network/ts-p2p::missing-script::lint", - "message": "Profile node-library requires script lint" - }, - { - "id": "packages/network/ts-p2p::missing-script::pack:check", - "message": "Profile node-library requires script pack:check" - }, - { - "id": "packages/network/ts-p2p::missing-script::typecheck", - "message": "Profile node-library requires script typecheck" - }, - { - "id": "packages/overlays/gasp-core::missing-script::format:check", - "message": "Profile dual-runtime-library requires script format:check" - }, - { - "id": "packages/overlays/gasp-core::missing-script::pack:check", - "message": "Profile dual-runtime-library requires script pack:check" - }, - { - "id": "packages/overlays/gasp-core::missing-script::typecheck", - "message": "Profile dual-runtime-library requires script typecheck" - }, { "id": "packages/overlays/overlay-discovery-services::missing-script::format:check", "message": "Profile node-library requires script format:check" @@ -115,50 +87,6 @@ "id": "packages/overlays/topics::missing-script::typecheck", "message": "Profile node-library requires script typecheck" }, - { - "id": "packages/sdk::missing-script::format:check", - "message": "Profile browser-library requires script format:check" - }, - { - "id": "packages/sdk::missing-script::pack:check", - "message": "Profile browser-library requires script pack:check" - }, - { - "id": "packages/sdk::missing-script::test:browser", - "message": "Profile browser-library requires script test:browser" - }, - { - "id": "packages/sdk::missing-script::typecheck", - "message": "Profile browser-library requires script typecheck" - }, - { - "id": "packages/verifast::missing-script::format:check", - "message": "Profile wasm-library requires script format:check" - }, - { - "id": "packages/verifast::missing-script::lint", - "message": "Profile wasm-library requires script lint" - }, - { - "id": "packages/verifast::missing-script::pack:check", - "message": "Profile wasm-library requires script pack:check" - }, - { - "id": "packages/wallet/ts-wallet-relay::missing-script::format:check", - "message": "Profile cli-library requires script format:check" - }, - { - "id": "packages/wallet/ts-wallet-relay::missing-script::lint", - "message": "Profile cli-library requires script lint" - }, - { - "id": "packages/wallet/ts-wallet-relay::missing-script::pack:check", - "message": "Profile cli-library requires script pack:check" - }, - { - "id": "packages/wallet/ts-wallet-relay::missing-script::test:coverage", - "message": "Profile cli-library requires script test:coverage" - }, { "id": "packages/wallet/wallet-toolbox-examples::missing-script::format:check", "message": "Profile examples requires script format:check" @@ -170,18 +98,6 @@ { "id": "packages/wallet/wallet-toolbox-examples::mutating-check-script::lint", "message": "lint must not modify the working tree" - }, - { - "id": "packages/wallet/wallet-toolbox::missing-script::format:check", - "message": "Profile node-library requires script format:check" - }, - { - "id": "packages/wallet/wallet-toolbox::missing-script::pack:check", - "message": "Profile node-library requires script pack:check" - }, - { - "id": "packages/wallet/wallet-toolbox::missing-script::typecheck", - "message": "Profile node-library requires script typecheck" } ] } diff --git a/packages/network/ts-p2p/README.md b/packages/network/ts-p2p/README.md index f3d219773..1b42ee698 100644 --- a/packages/network/ts-p2p/README.md +++ b/packages/network/ts-p2p/README.md @@ -46,28 +46,28 @@ npm install @bsv/teranode-listener The easiest way to use the library is with the `TeranodeListener` class, which provides topic-specific callbacks: ```typescript -import { TeranodeListener } from '@bsv/teranode-listener'; +import { TeranodeListener } from '@bsv/teranode-listener' // Define callback functions for different topics const blockCallback = (data: Uint8Array, topic: string, from: string) => { - console.log(`New block received from ${from}:`, data); + console.log(`New block received from ${from}:`, data) // Process block data here -}; +} const subtreeCallback = (data: Uint8Array, topic: string, from: string) => { - console.log(`Subtree update from ${from}:`, data); + console.log(`Subtree update from ${from}:`, data) // Process subtree data here -}; +} // Create listener with topic callbacks const listener = new TeranodeListener({ 'bitcoin/mainnet-block': blockCallback, 'bitcoin/mainnet-subtree': subtreeCallback -}); +}) // Start the listener and connect to Teranode mainnet -await listener.start(); -console.log('Listener started and waiting for messages...'); +await listener.start() +console.log('Listener started and waiting for messages...') ``` ### Decoding messages @@ -75,18 +75,18 @@ console.log('Listener started and waiting for messages...'); By default, callbacks receive the raw GossipSub bytes (`Uint8Array`). Pass `decodeMessages: true` to have the listener decode the two-layer JSON wire format for you. Callbacks then receive a typed `DecodedMessage` (the sender name plus a typed payload): ```typescript -import { TeranodeListener, type BlockMessage, type DecodedMessage } from '@bsv/teranode-listener'; +import { TeranodeListener, type BlockMessage, type DecodedMessage } from '@bsv/teranode-listener' const listener = new TeranodeListener( { 'bitcoin/mainnet-block': (msg: DecodedMessage, topic, from) => { - console.log(`Block #${msg.payload.Height} (${msg.payload.Hash}) from ${msg.sender}`); + console.log(`Block #${msg.payload.Height} (${msg.payload.Hash}) from ${msg.sender}`) } }, { decodeMessages: true } -); +) -await listener.start(); +await listener.start() ``` The exported `decodeMessage()` / `tryDecodeMessage()` helpers can also be used to decode a message manually. Frames that are not valid JSON (e.g. libp2p control frames) are skipped when `decodeMessages` is on. @@ -96,19 +96,18 @@ The exported `decodeMessage()` / `tryDecodeMessage()` helpers can also be used t Alternatively, you can use the original function-based API: ```typescript -import { startSubscriber } from '@bsv/teranode-listener'; +import { startSubscriber } from '@bsv/teranode-listener' -// Start with default configuration (connects to Teranode mainnet) -const { node, stop } = await startSubscriber({ - onMessage: (data, topic, from) => { - console.log(`Message on ${topic} from ${from}:`, data); - } -}); +// Start with selected topics (or omit the argument for all mainnet topics). +await startSubscriber({ + topics: ['bitcoin/mainnet-block', 'bitcoin/mainnet-subtree'] +}) -console.log('Subscriber started and listening for messages...'); +console.log('Subscriber started and listening for messages...') ``` Once started, both approaches automatically: + - Connect to the official Teranode bootstrap peer - Use the mainnet shared key - Listen on `127.0.0.1:9901` @@ -117,38 +116,36 @@ Once started, both approaches automatically: ### Custom Configuration ```typescript -import { startSubscriber } from '@bsv/teranode-listener'; +import { startSubscriber } from '@bsv/teranode-listener' const config = { - topics: ['teranode/blocks'], // Only subscribe to blocks + topics: ['bitcoin/mainnet-block'], // Only subscribe to blocks listenAddresses: ['/ip4/0.0.0.0/tcp/4000'] // Listen on a different port -}; +} // Start with custom topics and port -await startSubscriber(config); -console.log('Subscriber started with custom configuration...'); +await startSubscriber(config) +console.log('Subscriber started with custom configuration...') ``` ### Complete Custom Setup ```typescript -import { startSubscriber } from '@bsv/teranode-listener'; +import { startSubscriber } from '@bsv/teranode-listener' const config = { - bootstrapPeers: [ - '/ip4/127.0.0.1/tcp/4001/p2p/12D3KooWExample1' - ], + bootstrapPeers: ['/ip4/127.0.0.1/tcp/4001/p2p/12D3KooWExample1'], staticPeers: [ '/ip4/192.168.1.100/tcp/4003/p2p/12D3KooWStatic1', '/ip4/192.168.1.101/tcp/4003/p2p/12D3KooWStatic2' ], sharedKey: 'your-custom-hex-shared-key-here', - topics: ['custom/topic'], + topics: ['bitcoin/testnet-block', 'bitcoin/testnet-subtree'], listenAddresses: ['/ip4/0.0.0.0/tcp/4000'], dhtProtocolID: '/custom-protocol' -}; +} -await startSubscriber(config); +await startSubscriber(config) ``` For more detailed examples, check our [Examples](#examples) section. @@ -166,22 +163,24 @@ new TeranodeListener(topicCallbacks: TopicCallbacks, config?: TeranodeListenerCo ``` **Parameters:** + - `topicCallbacks` - Object mapping topic names to callback functions - `config` - Optional configuration (uses Teranode mainnet defaults) **Example:** + ```typescript const listener = new TeranodeListener({ 'bitcoin/mainnet-block': (data, topic, from) => { - console.log('Block received:', data); + console.log('Block received:', data) }, 'bitcoin/mainnet-subtree': (data, topic, from) => { - console.log('Subtree update:', data); + console.log('Subtree update:', data) } -}); +}) // Start the listener (it does not start automatically) -await listener.start(); +await listener.start() ``` #### Methods @@ -197,24 +196,24 @@ await listener.start(); ```typescript // Supported Teranode P2P topics -export type Topic = - 'bitcoin/mainnet-bestblock' | // Best block message - 'bitcoin/mainnet-block' | // When miners find a block solution - 'bitcoin/mainnet-subtree' | // When a subtree is created - 'bitcoin/mainnet-mining_on' | // When mining is enabled - 'bitcoin/mainnet-handshake' | // When a peer connects to the network - 'bitcoin/mainnet-rejected_tx'; // When a transaction is rejected +export type Topic = + | 'bitcoin/mainnet-bestblock' // Best block message + | 'bitcoin/mainnet-block' // When miners find a block solution + | 'bitcoin/mainnet-subtree' // When a subtree is created + | 'bitcoin/mainnet-mining_on' // When mining is enabled + | 'bitcoin/mainnet-handshake' // When a peer connects to the network + | 'bitcoin/mainnet-rejected_tx' // When a transaction is rejected -type MessageCallback = (data: Uint8Array, topic: Topic, from: string) => void; -type TopicCallbacks = Partial>; +type MessageCallback = (data: Uint8Array, topic: Topic, from: string) => void +type TopicCallbacks = Partial> interface TeranodeListenerConfig { - bootstrapPeers?: string[]; // Bootstrap peer multiaddrs (default: Teranode mainnet bootstrap) - staticPeers?: string[]; // Static peer multiaddrs (default: Known Teranode mainnet peers) - sharedKey?: string; // Hex string of PSK (default: Teranode mainnet key) - dhtProtocolID?: string; // DHT protocol prefix (default: '/teranode') - listenAddresses?: string[]; // Listen addresses (default: ['/ip4/127.0.0.1/tcp/9901']) - usePrivateDHT?: boolean; // Whether to use private DHT (default: true) + bootstrapPeers?: string[] // Bootstrap peer multiaddrs (default: Teranode mainnet bootstrap) + staticPeers?: string[] // Static peer multiaddrs (default: Known Teranode mainnet peers) + sharedKey?: string // Hex string of PSK (default: Teranode mainnet key) + dhtProtocolID?: string // DHT protocol prefix (default: '/teranode') + listenAddresses?: string[] // Listen addresses (default: ['/ip4/127.0.0.1/tcp/9901']) + usePrivateDHT?: boolean // Whether to use private DHT (default: true) } ``` @@ -236,13 +235,13 @@ Configuration interface for the function-based API. All parameters are optional: ```typescript interface SubscriberConfig { - bootstrapPeers?: string[]; // Bootstrap peer multiaddrs (default: Teranode mainnet bootstrap) - staticPeers?: string[]; // Static peer multiaddrs (default: Known Teranode mainnet peers) - sharedKey?: string; // Hex string of PSK (default: Teranode mainnet key) - dhtProtocolID?: string; // DHT protocol prefix (default: '/teranode') - topics?: Topic[]; // Topics to subscribe to (default: all Teranode topics) - listenAddresses?: string[]; // Listen addresses (default: ['/ip4/127.0.0.1/tcp/9901']) - usePrivateDHT?: boolean; // Whether to use private DHT (default: true) + bootstrapPeers?: string[] // Bootstrap peer multiaddrs (default: Teranode mainnet bootstrap) + staticPeers?: string[] // Static peer multiaddrs (default: Known Teranode mainnet peers) + sharedKey?: string // Hex string of PSK (default: Teranode mainnet key) + dhtProtocolID?: string // DHT protocol prefix (default: '/teranode') + topics?: Topic[] // Topics to subscribe to (default: all Teranode topics) + listenAddresses?: string[] // Listen addresses (default: ['/ip4/127.0.0.1/tcp/9901']) + usePrivateDHT?: boolean // Whether to use private DHT (default: true) } ``` @@ -255,7 +254,7 @@ The package comes with production-ready defaults for Teranode mainnet: - **`bootstrapPeers`**: `['/dns4/teranode-bootstrap.bsvb.tech/tcp/9901/p2p/12D3KooWESmhNAN8s6NPdGNvJH3zJ4wMKDxapXKNUe2DzkAwKYqK']` - **`staticPeers`**: Array of known active Teranode mainnet peers (TAAL, BSVB, etc.) - **`sharedKey`**: Teranode mainnet pre-shared key -- **`topics`**: `['teranode/blocks', 'teranode/transactions']` +- **`topics`**: all six `bitcoin/mainnet-*` topics listed in the `Topic` type - **`listenAddresses`**: `['/ip4/127.0.0.1/tcp/9901']` - **`dhtProtocolID`**: `/teranode` - **`usePrivateDHT`**: `true` @@ -287,103 +286,99 @@ The `sharedKey` should be provided as a hexadecimal string without the PSK heade ### Example 1: Basic TeranodeListener Usage ```typescript -import { TeranodeListener, type Topic } from '@bsv/teranode-listener'; +import { TeranodeListener, type Topic } from '@bsv/teranode-listener' // Simple callback-based listener const listener = new TeranodeListener({ 'bitcoin/mainnet-block': (data: Uint8Array, topic: Topic, from: string) => { - console.log(`New block from ${from}:`, data.length, 'bytes'); + console.log(`New block from ${from}:`, data.length, 'bytes') // Process block data }, 'bitcoin/mainnet-subtree': (data: Uint8Array, topic: Topic, from: string) => { - console.log(`Subtree update from ${from}:`, data.length, 'bytes'); + console.log(`Subtree update from ${from}:`, data.length, 'bytes') // Process subtree data } -}); +}) -await listener.start(); -console.log('Listener started, waiting for messages...'); +await listener.start() +console.log('Listener started, waiting for messages...') ``` ### Example 2: Advanced TeranodeListener with Custom Configuration ```typescript -import { TeranodeListener } from '@bsv/teranode-listener'; +import { TeranodeListener } from '@bsv/teranode-listener' // Create a listener with topic-specific callbacks const listener = new TeranodeListener({ 'bitcoin/mainnet-block': (data, topic, from) => { - console.log(`Received block from ${from}:`, data); + console.log(`Received block from ${from}:`, data) }, 'bitcoin/mainnet-subtree': (data, topic, from) => { - console.log(`Received subtree from ${from}:`, data); + console.log(`Received subtree from ${from}:`, data) } -}); +}) -await listener.start(); -console.log('Connected peers:', listener.getConnectedPeerCount()); +await listener.start() +console.log('Connected peers:', listener.getConnectedPeerCount()) // Add more topics dynamically -listener.addTopicCallback('bitcoin/mainnet-transaction', (data, topic, from) => { - console.log(`Received transaction from ${from}:`, data); -}); +listener.addTopicCallback('bitcoin/mainnet-rejected_tx', (data, topic, from) => { + console.log(`Received rejection from ${from}:`, data) +}) // Monitor connection status setInterval(() => { - console.log('Connected peers:', listener.getConnectedPeerCount()); -}, 30000); + console.log('Connected peers:', listener.getConnectedPeerCount()) +}, 30000) ``` ### Example 3: Function-Based API (Legacy) ```typescript -import { startSubscriber } from '@bsv/teranode-listener'; +import { startSubscriber } from '@bsv/teranode-listener' // Connect to Teranode mainnet with all defaults startSubscriber() .then(() => console.log('Connected to Teranode mainnet!')) - .catch(console.error); + .catch(console.error) ``` ### Example 4: Custom Port and Multiple Topics (Function API) ```typescript -import { startSubscriber } from '@bsv/teranode-listener'; +import { startSubscriber } from '@bsv/teranode-listener' // Use a different port and subscribe to multiple topics const config = { - topics: [ - 'teranode/blocks', - 'teranode/transactions', - 'teranode/mempool' - ], + topics: ['bitcoin/mainnet-block', 'bitcoin/mainnet-subtree', 'bitcoin/mainnet-rejected_tx'], listenAddresses: ['/ip4/0.0.0.0/tcp/4000'] -}; +} -await startSubscriber(config); -console.log('Listening on port 4000 for blocks, transactions, and mempool...'); +await startSubscriber(config) +console.log('Listening on port 4000 for blocks, subtrees, and rejected transactions...') ``` ### Example 5: Environment-Based Configuration ```typescript -import { startSubscriber } from '@bsv/teranode-listener'; +import { startSubscriber } from '@bsv/teranode-listener' const config = { topics: process.env.TOPICS?.split(',') || undefined, // Use defaults if not set listenAddresses: process.env.LISTEN_ADDRESS ? [process.env.LISTEN_ADDRESS] : undefined, sharedKey: process.env.CUSTOM_SHARED_KEY || undefined // Use default mainnet key if not set -}; +} // Start with environment overrides, falling back to defaults -await startSubscriber(config); -console.log('Started with environment configuration...'); +await startSubscriber(config) +console.log('Started with environment configuration...') ``` ### Example 6: Complete Custom Network ```typescript -import { startSubscriber } from '@bsv/teranode-listener'; +import { startSubscriber } from '@bsv/teranode-listener' // Connect to a custom private network const config = { @@ -391,18 +386,16 @@ const config = { '/ip4/10.0.0.1/tcp/4001/p2p/12D3KooWBootstrap1', '/ip4/10.0.0.2/tcp/4001/p2p/12D3KooWBootstrap2' ], - staticPeers: [ - '/ip4/10.0.0.10/tcp/4003/p2p/12D3KooWStatic1' - ], + staticPeers: ['/ip4/10.0.0.10/tcp/4003/p2p/12D3KooWStatic1'], sharedKey: 'your-custom-private-network-key', dhtProtocolID: '/custom-network', - topics: ['custom/blocks', 'custom/transactions'], + topics: ['bitcoin/testnet-block', 'bitcoin/testnet-subtree'], listenAddresses: ['/ip4/0.0.0.0/tcp/4000'], usePrivateDHT: true -}; +} -await startSubscriber(config); -console.log('Connected to custom private network...'); +await startSubscriber(config) +console.log('Connected to custom private network...') ``` ## Development @@ -411,14 +404,18 @@ console.log('Connected to custom private network...'); ```bash # Clone the repository -git clone https://github.com/bitcoin-sv/ts-p2p.git -cd ts-p2p +git clone https://github.com/bsv-blockchain/ts-stack.git +cd ts-stack # Install dependencies -npm install - -# Build the project -npm run build +pnpm install + +# Run the package contract +pnpm --filter @bsv/teranode-listener format:check +pnpm --filter @bsv/teranode-listener lint +pnpm --filter @bsv/teranode-listener typecheck +pnpm --filter @bsv/teranode-listener test:coverage +pnpm --filter @bsv/teranode-listener pack:check ``` ### Testing @@ -426,10 +423,13 @@ npm run build This package uses [Jest](https://jestjs.io/) with `ts-jest`. Run the suite with: ```bash -npm test +pnpm --filter @bsv/teranode-listener test ``` -The tests exercise the message decoder (`decodeMessage` / `tryDecodeMessage`) end to end, building real two-layer wire frames and decoding them back: the PascalCase (block) and snake_case (node_status) payload shapes, multi-byte UTF-8, every base64 padding length, and the malformed / non-JSON frame paths. +The tests exercise message decoding, subtree construction and mutation, +listener lifecycle, subscriptions, peer reconnection, callback isolation, and +clean shutdown. Coverage thresholds enforce the package's measured baseline. +`pack:check` installs the exact ESM tarball and verifies its public exports. ### Project Structure @@ -437,9 +437,12 @@ The tests exercise the message decoder (`decodeMessage` / `tryDecodeMessage`) en ts-p2p/ ├── src/ │ ├── index.ts # Main library and listener +│ ├── subtrees.ts # Subtree data structure and validation │ └── messages.ts # Wire-format types and decoder ├── test/ -│ └── messages.test.ts # Decoder test suite +│ ├── index.test.ts # Listener lifecycle and peer behavior +│ ├── messages.test.ts # Decoder test suite +│ └── subtrees.test.ts # Subtree behavior and validation ├── dist/ # Compiled JavaScript output ├── jest.config.js # Jest (ts-jest) configuration ├── package.json # Package configuration @@ -486,7 +489,7 @@ Project Maintainers: For questions, bug reports, or feature requests: -- [Open an issue](https://github.com/bitcoin-sv/ts-p2p/issues) on GitHub +- [Open an issue](https://github.com/bsv-blockchain/ts-stack/issues) on GitHub - Check existing [documentation](https://docs.bsvblockchain.org/) --- diff --git a/packages/network/ts-p2p/demo.ts b/packages/network/ts-p2p/demo.ts index 89d06705b..dbe800b0b 100644 --- a/packages/network/ts-p2p/demo.ts +++ b/packages/network/ts-p2p/demo.ts @@ -1,63 +1,67 @@ -import { TeranodeListener, type Topic } from './src/index.js'; +import { TeranodeListener, type Topic } from './src/index.js' // Demo of the new TeranodeListener API const blockCallback = (data: Uint8Array, topic: Topic, from: string) => { - console.log(`📦 New block received from ${from}:`); - console.log(` Topic: ${topic}`); - console.log(` Data size: ${data.length} bytes`); - console.log(` Data preview: ${Array.from(data.slice(0, 20)).map(b => b.toString(16).padStart(2, '0')).join(' ')}...`); -}; + console.log(`📦 New block received from ${from}:`) + console.log(` Topic: ${topic}`) + console.log(` Data size: ${data.length} bytes`) + console.log( + ` Data preview: ${Array.from(data.slice(0, 20)) + .map(b => b.toString(16).padStart(2, '0')) + .join(' ')}...` + ) +} const subtreeCallback = (data: Uint8Array, topic: Topic, from: string) => { - console.log(`🌳 Subtree update from ${from}:`); - console.log(` Topic: ${topic}`); - console.log(` Data size: ${data.length} bytes`); -}; + console.log(`🌳 Subtree update from ${from}:`) + console.log(` Topic: ${topic}`) + console.log(` Data size: ${data.length} bytes`) +} const bestBlockCallback = (data: Uint8Array, topic: Topic, from: string) => { - console.log(`🏆 Best block update from ${from}:`); - console.log(` Topic: ${topic}`); - console.log(` Data size: ${data.length} bytes`); -}; + console.log(`🏆 Best block update from ${from}:`) + console.log(` Topic: ${topic}`) + console.log(` Data size: ${data.length} bytes`) +} // Create listener with topic callbacks -console.log('🚀 Starting TeranodeListener demo...'); +console.log('🚀 Starting TeranodeListener demo...') const listener = new TeranodeListener({ 'bitcoin/mainnet-block': blockCallback, 'bitcoin/mainnet-subtree': subtreeCallback, 'bitcoin/mainnet-bestblock': bestBlockCallback -}); +}) // The listener no longer starts on construction; start it explicitly. try { - await listener.start(); + await listener.start() } catch (err) { - console.error('Failed to start listener:', err); + console.error('Failed to start listener:', err) } -console.log('✅ TeranodeListener created and starting...'); -console.log('📡 Connecting to Teranode mainnet...'); -console.log('⏳ Waiting for messages...'); +console.log('✅ TeranodeListener created and starting...') +console.log('📡 Connecting to Teranode mainnet...') +console.log('⏳ Waiting for messages...') // Add a dynamic topic after 10 seconds setTimeout(() => { - console.log('➕ Adding mining topic dynamically...'); + console.log('➕ Adding mining topic dynamically...') listener.addTopicCallback('bitcoin/mainnet-mining_on', (data, topic, from) => { - console.log(`⛏️ Mining status update from ${from}: ${data.length} bytes`); - }); -}, 10000); + console.log(`⛏️ Mining status update from ${from}: ${data.length} bytes`) + }) +}, 10000) // Log peer count every 30 seconds setInterval(() => { - const peerCount = listener.getConnectedPeerCount(); - console.log(`👥 Connected peers: ${peerCount}`); -}, 30000); + const peerCount = listener.getConnectedPeerCount() + console.log(`👥 Connected peers: ${peerCount}`) +}, 30000) // Graceful shutdown process.on('SIGINT', async () => { - console.log('\n🛑 Shutting down TeranodeListener...'); - await listener.stop(); - console.log('✅ TeranodeListener stopped'); - process.exit(0); -}); + console.log('\n🛑 Shutting down TeranodeListener...') + await listener.stop() + console.log('✅ TeranodeListener stopped') + process.exit(0) +}) diff --git a/packages/network/ts-p2p/jest.config.js b/packages/network/ts-p2p/jest.config.js index 58a3685d9..e7262634e 100644 --- a/packages/network/ts-p2p/jest.config.js +++ b/packages/network/ts-p2p/jest.config.js @@ -6,25 +6,28 @@ export default { '^(\\.{1,2}/.*)\\.js$': '$1' }, transform: { - '^.+\\.ts$': ['ts-jest', { - useESM: true, - tsconfig: { - module: 'ESNext', - moduleResolution: 'bundler', - esModuleInterop: true, - allowSyntheticDefaultImports: true + '^.+\\.ts$': [ + 'ts-jest', + { + useESM: true, + tsconfig: { + module: 'ESNext', + moduleResolution: 'bundler', + esModuleInterop: true, + allowSyntheticDefaultImports: true + } } - }] + ] }, - transformIgnorePatterns: [ - 'node_modules/(?!(@bsv)/)' - ], - testMatch: [ - '**/__tests__/**/*.test.ts', - '**/?(*.)+(spec|test).ts' - ], - collectCoverageFrom: [ - 'src/**/*.ts', - '!src/**/*.d.ts' - ] + transformIgnorePatterns: ['node_modules/(?!(@bsv)/)'], + testMatch: ['**/__tests__/**/*.test.ts', '**/?(*.)+(spec|test).ts'], + collectCoverageFrom: ['src/**/*.ts', '!src/**/*.d.ts'], + coverageThreshold: { + global: { + branches: 80, + functions: 85, + lines: 85, + statements: 85 + } + } } diff --git a/packages/network/ts-p2p/package.json b/packages/network/ts-p2p/package.json index 4829a44f0..7ced5fd93 100644 --- a/packages/network/ts-p2p/package.json +++ b/packages/network/ts-p2p/package.json @@ -25,8 +25,13 @@ "scripts": { "build": "tsc", "demo": "tsx demo.ts", - "test": "node --experimental-vm-modules node_modules/jest/bin/jest.js --runInBand", - "test:coverage": "node --experimental-vm-modules node_modules/jest/bin/jest.js --coverage" + "format:check": "pnpm --workspace-root exec prettier --check \"packages/network/ts-p2p/{README.md,demo.ts,jest.config.js,package.json,tsconfig.json}\" \"packages/network/ts-p2p/{src,test}/**/*.ts\"", + "lint": "oxlint demo.ts jest.config.js src test --deny-warnings", + "pack:check": "pnpm build && node ../../../scripts/check-package-artifact.mjs . --modes esm --exports TeranodeListener,decodeMessage,startSubscriber,tryDecodeMessage", + "test": "node --experimental-vm-modules node_modules/jest/bin/jest.js --runInBand --watchman=false", + "test:coverage": "node --experimental-vm-modules node_modules/jest/bin/jest.js --coverage --runInBand --watchman=false", + "typecheck": "tsc --noEmit", + "prepublishOnly": "pnpm build" }, "dependencies": { "@chainsafe/libp2p-gossipsub": "^14.1.2", @@ -53,7 +58,8 @@ "jest": "^30.4.2", "ts-jest": "^29.4.12", "tsx": "^4.23.1", - "typescript": "^6.0.3" + "typescript": "^6.0.3", + "oxlint": "^1.75.0" }, "keywords": [ "teranode", diff --git a/packages/network/ts-p2p/src/index.ts b/packages/network/ts-p2p/src/index.ts index faa0c2b78..54147187c 100644 --- a/packages/network/ts-p2p/src/index.ts +++ b/packages/network/ts-p2p/src/index.ts @@ -1,36 +1,36 @@ -import { createLibp2p, type Libp2p } from 'libp2p'; -import { tcp } from '@libp2p/tcp'; -import { noise } from '@chainsafe/libp2p-noise'; -import { yamux } from '@chainsafe/libp2p-yamux'; -import { bootstrap } from '@libp2p/bootstrap'; -import { kadDHT } from '@libp2p/kad-dht'; -import { gossipsub } from '@chainsafe/libp2p-gossipsub'; - -import { preSharedKey } from '@libp2p/pnet'; -import { pubsubPeerDiscovery } from '@libp2p/pubsub-peer-discovery'; -import { identify } from '@libp2p/identify'; -import { ping } from '@libp2p/ping'; -import { multiaddr } from '@multiformats/multiaddr'; -import { generateKeyPair } from '@libp2p/crypto/keys'; -import type { PrivateKey } from '@libp2p/interface'; - -import { tryDecodeMessage, type DecodedMessage } from './messages.js'; +import { createLibp2p, type Libp2p } from 'libp2p' +import { tcp } from '@libp2p/tcp' +import { noise } from '@chainsafe/libp2p-noise' +import { yamux } from '@chainsafe/libp2p-yamux' +import { bootstrap } from '@libp2p/bootstrap' +import { kadDHT } from '@libp2p/kad-dht' +import { gossipsub } from '@chainsafe/libp2p-gossipsub' + +import { preSharedKey } from '@libp2p/pnet' +import { pubsubPeerDiscovery } from '@libp2p/pubsub-peer-discovery' +import { identify } from '@libp2p/identify' +import { ping } from '@libp2p/ping' +import { multiaddr } from '@multiformats/multiaddr' +import { generateKeyPair } from '@libp2p/crypto/keys' +import type { PrivateKey } from '@libp2p/interface' + +import { tryDecodeMessage, type DecodedMessage } from './messages.js' // Re-export the wire-format message types and decoders for consumers. -export * from './messages.js'; +export * from './messages.js' // Type definitions -type MessageCallback = (data: Uint8Array, topic: Topic, from: string) => void; +export type MessageCallback = (data: Uint8Array, topic: Topic, from: string) => void /** * Callback that receives a fully decoded message instead of raw bytes. * Used when `decodeMessages: true` is set in the listener config. */ -type DecodedMessageCallback = (message: DecodedMessage, topic: Topic, from: string) => void; +export type DecodedMessageCallback = (message: DecodedMessage, topic: Topic, from: string) => void /** * Topic types for Teranode P2P messages - * + * * 'bitcoin/mainnet-bestblock' is for the best block message * 'bitcoin/mainnet-block' is for when miners find a block solution * 'bitcoin/mainnet-subtree' is for when a subtree is created @@ -38,30 +38,30 @@ type DecodedMessageCallback = (message: DecodedMessage, topic: Topic, from: stri * 'bitcoin/mainnet-handshake' is for when a peer connects to the network * 'bitcoin/mainnet-rejected_tx' is for when a transaction is rejected */ -export type Topic = -'bitcoin/mainnet-bestblock' | -'bitcoin/mainnet-block' | -'bitcoin/mainnet-subtree' | -'bitcoin/mainnet-mining_on' | -'bitcoin/mainnet-handshake' | -'bitcoin/mainnet-rejected_tx' | -'bitcoin/testnet-bestblock' | -'bitcoin/testnet-block' | -'bitcoin/testnet-subtree' | -'bitcoin/testnet-mining_on' | -'bitcoin/testnet-handshake' | -'bitcoin/testnet-rejected_tx' - -type TopicCallbacks = Partial>; - -interface SubscriberConfig { - bootstrapPeers?: string[]; // Array of bootstrap peer multiaddrs - staticPeers?: string[]; // Optional array of static peer multiaddrs - sharedKey?: string; // Hex string of the shared PSK (without headers) - dhtProtocolID?: string; // DHT protocol prefix, default '/teranode' - topics?: Topic[]; // Array of topics to subscribe to - listenAddresses?: string[]; // Listening addresses - usePrivateDHT?: boolean; // Whether to use private DHT +export type Topic = + | 'bitcoin/mainnet-bestblock' + | 'bitcoin/mainnet-block' + | 'bitcoin/mainnet-subtree' + | 'bitcoin/mainnet-mining_on' + | 'bitcoin/mainnet-handshake' + | 'bitcoin/mainnet-rejected_tx' + | 'bitcoin/testnet-bestblock' + | 'bitcoin/testnet-block' + | 'bitcoin/testnet-subtree' + | 'bitcoin/testnet-mining_on' + | 'bitcoin/testnet-handshake' + | 'bitcoin/testnet-rejected_tx' + +export type TopicCallbacks = Partial> + +export interface SubscriberConfig { + bootstrapPeers?: string[] // Array of bootstrap peer multiaddrs + staticPeers?: string[] // Optional array of static peer multiaddrs + sharedKey?: string // Hex string of the shared PSK (without headers) + dhtProtocolID?: string // DHT protocol prefix, default '/teranode' + topics?: Topic[] // Array of topics to subscribe to + listenAddresses?: string[] // Listening addresses + usePrivateDHT?: boolean // Whether to use private DHT /** * When true, raw GossipSub bytes are decoded from the two-layer JSON wire * format before being handed to callbacks. Callbacks then receive a @@ -69,10 +69,10 @@ interface SubscriberConfig { * Frames that fail to decode (e.g. libp2p control frames) are skipped. * Defaults to false for backward compatibility. */ - decodeMessages?: boolean; + decodeMessages?: boolean } -interface TeranodeListenerConfig extends Omit { +export interface TeranodeListenerConfig extends Omit { // Inherits all SubscriberConfig options except topics } @@ -81,11 +81,14 @@ interface TeranodeListenerConfig extends Omit { * Each topic can have its own callback function for handling messages. */ export class TeranodeListener { - private node: Libp2p | null = null; - private readonly topicCallbacks: TopicCallbacks; - private readonly config: TeranodeListenerConfig; - private reconnectionInterval?: NodeJS.Timeout; - private readonly decodeMessages: boolean; + private node: Libp2p | null = null + private readonly topicCallbacks: TopicCallbacks + private readonly config: TeranodeListenerConfig + private reconnectionInterval?: NodeJS.Timeout + private readonly decodeMessages: boolean + private readonly shutdownHandler = (): void => { + void this.stop() + } /** * Creates a new TeranodeListener instance. @@ -102,9 +105,9 @@ export class TeranodeListener { * @param config - Optional configuration (uses Teranode mainnet defaults) */ constructor(topicCallbacks: TopicCallbacks, config: TeranodeListenerConfig = {}) { - this.topicCallbacks = topicCallbacks; - this.config = config; - this.decodeMessages = config.decodeMessages ?? false; + this.topicCallbacks = topicCallbacks + this.config = config + this.decodeMessages = config.decodeMessages ?? false } /** @@ -112,19 +115,21 @@ export class TeranodeListener { */ async start(): Promise { if (this.node) { - console.warn('TeranodeListener is already started'); - return; + console.warn('TeranodeListener is already started') + return } - const topics = Object.keys(this.topicCallbacks) as Topic[]; + const topics = Object.keys(this.topicCallbacks) as Topic[] const fullConfig: SubscriberConfig = { ...this.config, topics - }; + } // Create the libp2p node using the same logic as startSubscriber const { - bootstrapPeers = ['/dns4/teranode-bootstrap.bsvb.tech/tcp/9901/p2p/12D3KooWESmhNAN8s6NPdGNvJH3zJ4wMKDxapXKNUe2DzkAwKYqK'], + bootstrapPeers = [ + '/dns4/teranode-bootstrap.bsvb.tech/tcp/9901/p2p/12D3KooWESmhNAN8s6NPdGNvJH3zJ4wMKDxapXKNUe2DzkAwKYqK' + ], staticPeers = [ '/dns4/teranode-mainnet-peer.taal.com/tcp/9905/p2p/12D3KooWJGPdPPw72GU6gFF4LqUjeFF7qmPCS2bZK8ywMvybYfXD', '/dns4/teranode-mainnet-us-01.bsvb.tech/tcp/9905/p2p/12D3KooWPJAHHaNy5BsViK1B5iTQmz5cLaUheAKEuNkHqMbwZ8jd', @@ -134,20 +139,19 @@ export class TeranodeListener { ], sharedKey = '285b49e6d910726a70f205086c39cbac6d8dcc47839053a21b1f614773bbc137', dhtProtocolID = '/teranode', - listenAddresses = ['/ip4/127.0.0.1/tcp/9901'], - usePrivateDHT = true, - } = fullConfig; + listenAddresses = ['/ip4/127.0.0.1/tcp/9901'] + } = fullConfig // Format the PSK - const pskText = `/key/swarm/psk/1.0.0/\n/base16/\n${sharedKey}`; - const psk = new TextEncoder().encode(pskText); - const connectionProtector = preSharedKey({ psk }); - const privateKey: PrivateKey = await generateKeyPair('Ed25519'); + const pskText = `/key/swarm/psk/1.0.0/\n/base16/\n${sharedKey}` + const psk = new TextEncoder().encode(pskText) + const connectionProtector = preSharedKey({ psk }) + const privateKey: PrivateKey = await generateKeyPair('Ed25519') this.node = await createLibp2p({ privateKey, addresses: { - listen: listenAddresses, + listen: listenAddresses }, transports: [tcp()], connectionEncrypters: [noise()], @@ -157,48 +161,48 @@ export class TeranodeListener { bootstrap({ list: bootstrapPeers }), pubsubPeerDiscovery({ topics, - interval: 5000, - }), + interval: 5000 + }) ], services: { dht: kadDHT({ protocol: `${dhtProtocolID}/kad/1.0.0`, clientMode: false, validators: {}, - selectors: {}, + selectors: {} }), pubsub: gossipsub({ allowPublishToZeroTopicPeers: true, emitSelf: false, fallbackToFloodsub: true, floodPublish: true, - doPX: true, + doPX: true // Cast: gossipsub's GossipSubComponents factory type drifted from the // createLibp2p service-factory component type after libp2p bumps (#222). // Runtime is unaffected; realign package types to remove this. }) as any, identify: identify(), - ping: ping(), - }, - }); + ping: ping() + } + }) - await this.node.start(); - console.log('TeranodeListener started with Peer ID:', this.node.peerId.toString()); + await this.node.start() + console.log('TeranodeListener started with Peer ID:', this.node.peerId.toString()) // Set up event listeners - this.setupEventListeners(); + this.setupEventListeners() // Subscribe to topics with callbacks - this.setupTopicSubscriptions(); + this.setupTopicSubscriptions() // Connect to static peers if provided if (staticPeers.length > 0) { - await this.connectToStaticPeers(staticPeers); - this.reconnectionInterval = this.startStaticPeerMonitoring(staticPeers); + await this.connectToStaticPeers(staticPeers) + this.reconnectionInterval = this.startStaticPeerMonitoring(staticPeers) } // Handle graceful shutdown - process.on('SIGINT', () => this.stop()); + process.once('SIGINT', this.shutdownHandler) } /** @@ -206,29 +210,31 @@ export class TeranodeListener { */ async stop(): Promise { if (!this.node) { - return; + return } - console.log('Stopping TeranodeListener...'); - + console.log('Stopping TeranodeListener...') + if (this.reconnectionInterval) { - clearInterval(this.reconnectionInterval); + clearInterval(this.reconnectionInterval) + this.reconnectionInterval = undefined } - await this.node.stop(); - this.node = null; - console.log('TeranodeListener stopped'); + await this.node.stop() + this.node = null + process.removeListener('SIGINT', this.shutdownHandler) + console.log('TeranodeListener stopped') } /** * Add a new topic callback */ addTopicCallback(topic: Topic, callback: MessageCallback | DecodedMessageCallback): void { - this.topicCallbacks[topic] = callback; - + this.topicCallbacks[topic] = callback + if (this.node) { - (this.node.services.pubsub as any).subscribe(topic); - console.log(`Subscribed to new topic: ${topic}`); + ;(this.node.services.pubsub as any).subscribe(topic) + console.log(`Subscribed to new topic: ${topic}`) } } @@ -236,11 +242,11 @@ export class TeranodeListener { * Remove a topic callback */ removeTopicCallback(topic: Topic): void { - delete this.topicCallbacks[topic]; - + delete this.topicCallbacks[topic] + if (this.node) { - (this.node.services.pubsub as any).unsubscribe(topic); - console.log(`Unsubscribed from topic: ${topic}`); + ;(this.node.services.pubsub as any).unsubscribe(topic) + console.log(`Unsubscribed from topic: ${topic}`) } } @@ -248,130 +254,123 @@ export class TeranodeListener { * Get the current libp2p node instance */ getNode(): Libp2p | null { - return this.node; + return this.node } /** * Get connected peer count */ getConnectedPeerCount(): number { - return this.node ? this.node.getPeers().length : 0; + return this.node ? this.node.getPeers().length : 0 } private setupEventListeners(): void { - if (!this.node) return; + if (!this.node) return this.node.addEventListener('peer:discovery', (evt: any) => { - console.log('Peer discovered:', evt.detail.id.toString()); - }); + console.log('Peer discovered:', evt.detail.id.toString()) + }) this.node.addEventListener('peer:connect', (evt: any) => { - console.log('✅ Peer connected:', evt.detail.toString()); - console.log('Total connected peers:', this.node!.getPeers().length); - }); + console.log('✅ Peer connected:', evt.detail.toString()) + console.log('Total connected peers:', this.node!.getPeers().length) + }) this.node.addEventListener('peer:disconnect', (evt: any) => { - console.log('❌ Peer disconnected:', evt.detail.toString()); - console.log('Remaining connected peers:', this.node!.getPeers().length); - }); + console.log('❌ Peer disconnected:', evt.detail.toString()) + console.log('Remaining connected peers:', this.node!.getPeers().length) + }) } private setupTopicSubscriptions(): void { - if (!this.node) return; + if (!this.node) return // Subscribe to topics and handle messages with callbacks - (this.node.services.pubsub as any).addEventListener('gossipsub:message', (evt: any) => { - const msg = evt.detail.msg; - const topicKey = msg.topic as Topic; - const callback = this.topicCallbacks[topicKey]; - + ;(this.node.services.pubsub as any).addEventListener('gossipsub:message', (evt: any) => { + const msg = evt.detail.msg + const topicKey = msg.topic as Topic + const callback = this.topicCallbacks[topicKey] + if (callback) { try { - const from = evt.detail.propagationSource.toString(); + const from = evt.detail.propagationSource.toString() if (this.decodeMessages) { // Decode the two-layer JSON wire format before dispatch. Non-JSON // frames (e.g. libp2p discovery probes) decode to null and are skipped. - const decoded = tryDecodeMessage(msg.data); + const decoded = tryDecodeMessage(msg.data) if (decoded) { - (callback as DecodedMessageCallback)(decoded, topicKey, from); + ;(callback as DecodedMessageCallback)(decoded, topicKey, from) } } else { - (callback as MessageCallback)(msg.data, topicKey, from); + ;(callback as MessageCallback)(msg.data, topicKey, from) } } catch (error) { - console.error(`Error in callback for topic ${topicKey}:`, error); + console.error(`Error in callback for topic ${topicKey}:`, error) } } else { - console.log(`Received message on unhandled topic "${msg.topic}"`); + console.log(`Received message on unhandled topic "${msg.topic}"`) } - }); + }) // Subscribe to all topics for (const topic of Object.keys(this.topicCallbacks) as Topic[]) { - (this.node.services.pubsub as any).subscribe(topic); - console.log(`Subscribed to topic: ${topic}`); + ;(this.node.services.pubsub as any).subscribe(topic) + console.log(`Subscribed to topic: ${topic}`) } } private async connectToStaticPeers(staticPeers: string[]): Promise { - if (!this.node) return; + if (!this.node) return - const connectionPromises = staticPeers.map(async (peerAddr) => { + const connectionPromises = staticPeers.map(async peerAddr => { try { - console.log(`Attempting to connect to static peer: ${peerAddr}`); - await this.node!.dial(multiaddr(peerAddr)); - console.log(`✅ Successfully connected to static peer: ${peerAddr}`); + console.log(`Attempting to connect to static peer: ${peerAddr}`) + await this.node!.dial(multiaddr(peerAddr)) + console.log(`✅ Successfully connected to static peer: ${peerAddr}`) } catch (error) { - console.error(`❌ Failed to connect to static peer ${peerAddr}:`, error); + console.error(`❌ Failed to connect to static peer ${peerAddr}:`, error) } - }); + }) - await Promise.allSettled(connectionPromises); - console.log(`Static peer connection complete. Total connected peers: ${this.node.getPeers().length}`); + await Promise.allSettled(connectionPromises) + console.log( + `Static peer connection complete. Total connected peers: ${this.node.getPeers().length}` + ) } private startStaticPeerMonitoring(staticPeers: string[]): NodeJS.Timeout { return setInterval(async () => { - if (!this.node) return; + if (!this.node) return - const connectedPeerIds = new Set(this.node.getPeers().map(p => p.toString())); - const disconnectedStaticPeers = []; + const connectedPeerIds = new Set(this.node.getPeers().map(p => p.toString())) + const disconnectedStaticPeers = [] for (const staticPeer of staticPeers) { try { - const peerIdMatch = /\/p2p\/([^/]+)$/.exec(staticPeer); + const peerIdMatch = /\/p2p\/([^/]+)$/.exec(staticPeer) if (peerIdMatch) { - const peerId = peerIdMatch[1]; + const peerId = peerIdMatch[1] if (!connectedPeerIds.has(peerId)) { - disconnectedStaticPeers.push(staticPeer); + disconnectedStaticPeers.push(staticPeer) } } } catch (error) { - console.error(`Error checking static peer ${staticPeer}:`, error); + console.error(`Error checking static peer ${staticPeer}:`, error) } } if (disconnectedStaticPeers.length > 0) { - console.log(`Reconnecting to ${disconnectedStaticPeers.length} disconnected static peers...`); - await this.connectToStaticPeers(disconnectedStaticPeers); + console.log( + `Reconnecting to ${disconnectedStaticPeers.length} disconnected static peers...` + ) + await this.connectToStaticPeers(disconnectedStaticPeers) } - }, 30000); // 30 seconds + }, 30000) // 30 seconds } } export async function startSubscriber(config: SubscriberConfig = {}): Promise { const { - bootstrapPeers = ['/dns4/teranode-bootstrap.bsvb.tech/tcp/9901/p2p/12D3KooWESmhNAN8s6NPdGNvJH3zJ4wMKDxapXKNUe2DzkAwKYqK'], - staticPeers = [ - // Active Teranode peers discovered from Go implementation - '/dns4/teranode-mainnet-peer.taal.com/tcp/9905/p2p/12D3KooWJGPdPPw72GU6gFF4LqUjeFF7qmPCS2bZK8ywMvybYfXD', - '/dns4/teranode-mainnet-us-01.bsvb.tech/tcp/9905/p2p/12D3KooWPJAHHaNy5BsViK1B5iTQmz5cLaUheAKEuNkHqMbwZ8jd', - '/dns4/teranode-eks-mainnet-us-1-peer.bsvb.tech/tcp/9911/p2p/12D3KooWFjGChbwVteGsqH6NfHtKbtdW5XgnvmQRpem2kUAQjsGq', - '/dns4/bsva-ovh-teranode-eu-1.bsvb.tech/tcp/9905/p2p/12D3KooWAdBeSVue71DTmfMEKyBG2s1hg91zJnze85rt2uKCZWbW', - '/dns4/teranode-eks-mainnet-eu-1-peer.bsvb.tech/tcp/9911/p2p/12D3KooWRioUF2AYvC6ofiXhjE5V3MLiVrRKMAEyHiz5iYQgnB5f' - ], - sharedKey = '285b49e6d910726a70f205086c39cbac6d8dcc47839053a21b1f614773bbc137', - dhtProtocolID = '/teranode', topics = [ 'bitcoin/mainnet-bestblock', 'bitcoin/mainnet-block', @@ -380,139 +379,9 @@ export async function startSubscriber(config: SubscriberConfig = {}): Promise { - console.log('Peer discovered:', evt.detail.id.toString()); - console.log('Peer multiaddrs:', evt.detail.multiaddrs.map(ma => ma.toString())); - }); - - node.addEventListener('peer:connect', (evt) => { - console.log('✅ Peer connected:', evt.detail.toString()); - console.log('Total connected peers:', node.getPeers().length); - }); - - node.addEventListener('peer:disconnect', (evt) => { - console.log('❌ Peer disconnected:', evt.detail.toString()); - console.log('Remaining connected peers:', node.getPeers().length); - }); - - // Subscribe to topics and handle messages - // node.services.pubsub.addEventListener('gossipsub:message', (evt) => { - // const msg = evt.detail.msg; - // console.log(`[${msg.topic}] ${msg.data} - from: ${evt.detail.propagationSource}`); - // }); - - for (const topic of topics) { - (node.services.pubsub as any).subscribe(topic); - console.log(`Subscribed to topic: ${topic}`); - } - - // Connect to static peers if provided - let reconnectionInterval: NodeJS.Timeout | undefined; - if (staticPeers.length > 0) { - await connectToStaticPeers(node, staticPeers); - reconnectionInterval = startStaticPeerMonitoring(node, staticPeers); - } - - // Handle graceful shutdown - process.on('SIGINT', async () => { - console.log('Shutting down...'); - if (reconnectionInterval) clearInterval(reconnectionInterval); - await node.stop(); - process.exit(0); - }); -} - -async function connectToStaticPeers(node: Libp2p, staticPeers: string[]) { - const connectionPromises = staticPeers.map(async (peerAddr) => { - try { - console.log(`Attempting to connect to static peer: ${peerAddr}`); - await node.dial(multiaddr(peerAddr)); - console.log(`✅ Successfully connected to static peer: ${peerAddr}`); - } catch (error) { - console.error(`❌ Failed to connect to static peer ${peerAddr}:`, error); - } - }); - - await Promise.allSettled(connectionPromises); - console.log(`Static peer connection complete. Total connected peers: ${node.getPeers().length}`); + ...listenerConfig + } = config + const callbacks = Object.fromEntries(topics.map(topic => [topic, () => {}])) as TopicCallbacks + const listener = new TeranodeListener(callbacks, listenerConfig) + await listener.start() } - -function startStaticPeerMonitoring(node: Libp2p, staticPeers: string[]): NodeJS.Timeout { - return setInterval(async () => { - const connectedPeerIds = new Set(node.getPeers().map(p => p.toString())); - const disconnectedStaticPeers: string[] = []; - - for (const staticPeer of staticPeers) { - try { - const peerIdMatch = /\/p2p\/([^/]+)$/.exec(staticPeer); - if (peerIdMatch) { - const peerId = peerIdMatch[1]; - if (!connectedPeerIds.has(peerId)) { - disconnectedStaticPeers.push(staticPeer); - } - } - } catch (error) { - console.error(`Error checking static peer ${staticPeer}:`, error); - } - } - - if (disconnectedStaticPeers.length > 0) { - console.log(`Reconnecting to ${disconnectedStaticPeers.length} disconnected static peers...`); - await connectToStaticPeers(node, disconnectedStaticPeers); - } - }, 30000); // 30 seconds -} \ No newline at end of file diff --git a/packages/network/ts-p2p/src/messages.ts b/packages/network/ts-p2p/src/messages.ts index 2fb2f974b..696ca484d 100644 --- a/packages/network/ts-p2p/src/messages.ts +++ b/packages/network/ts-p2p/src/messages.ts @@ -115,11 +115,7 @@ export interface NodeStatusMessage { // Union type for any decoded message // --------------------------------------------------------------------------- -export type TeranodeMessage = - | BlockMessage - | SubtreeMessage - | RejectedTxMessage - | NodeStatusMessage +export type TeranodeMessage = BlockMessage | SubtreeMessage | RejectedTxMessage | NodeStatusMessage // --------------------------------------------------------------------------- // Decoded result (envelope + typed payload) diff --git a/packages/network/ts-p2p/src/subtree-test.ts b/packages/network/ts-p2p/src/subtree-test.ts deleted file mode 100644 index 2dc6d50f6..000000000 --- a/packages/network/ts-p2p/src/subtree-test.ts +++ /dev/null @@ -1,105 +0,0 @@ -import { Subtree } from './subtrees.js' - -import { Hash, Utils } from '@bsv/sdk'; - -// Test the Subtree serialization and deserialization -function testSubtreeSerialization() { - console.log('Testing Subtree serialization and deserialization...'); - - // Create a new subtree with height 3 (can hold up to 8 nodes) - const subtree = Subtree.newTree(3); - - // Add some test nodes - const node1Hash = Hash.sha256(Utils.toArray('test1', 'utf8')); - const node2Hash = Hash.sha256(Utils.toArray('test2', 'utf8')); - const node3Hash = Hash.sha256(Utils.toArray('test3', 'utf8')); - - subtree.addNode(node1Hash, BigInt(1000), BigInt(250)); - subtree.addNode(node2Hash, BigInt(2000), BigInt(500)); - subtree.addNode(node3Hash, BigInt(1500), BigInt(300)); - - console.log(`Original subtree has ${subtree.length()} nodes`); - console.log(`Total fees: ${subtree.fees}`); - console.log(`Total size: ${subtree.sizeInBytes}`); - - // Add a conflicting node - subtree.addConflictingNode(node2Hash); - console.log(`Added conflicting node, now has ${subtree.conflictingNodes.length} conflicting nodes`); - - // Serialize the subtree - const serialized = subtree.serialize(); - console.log(`Serialized subtree size: ${serialized.length} bytes`); - - // Deserialize into a new subtree - const deserializedSubtree = Subtree.fromBytes(serialized); - - console.log(`Deserialized subtree has ${deserializedSubtree.length()} nodes`); - console.log(`Total fees: ${deserializedSubtree.fees}`); - console.log(`Total size: ${deserializedSubtree.sizeInBytes}`); - console.log(`Conflicting nodes: ${deserializedSubtree.conflictingNodes.length}`); - - // Test node lookup - const foundNode = deserializedSubtree.getNode(node1Hash); - if (foundNode) { - console.log(`Found node with fee: ${foundNode.fee}`); - } - - // Test node existence - console.log(`Has node1: ${deserializedSubtree.hasNode(node1Hash)}`); - console.log(`Has node2: ${deserializedSubtree.hasNode(node2Hash)}`); - - // Test serialization of just nodes - const nodesSerialized = subtree.serializeNodes(); - console.log(`Nodes-only serialization size: ${nodesSerialized.length} bytes`); - - console.log('Subtree serialization test completed successfully!'); -} - -// Test factory methods -function testFactoryMethods() { - console.log('\\nTesting factory methods...'); - - // Test newTreeByLeafCount - const subtree1 = Subtree.newTreeByLeafCount(16); // Must be power of 2 - console.log(`Tree by leaf count (16): height=${subtree1.height}, size=${subtree1.size()}`); - - // Test newIncompleteTreeByLeafCount - const subtree2 = Subtree.newIncompleteTreeByLeafCount(10); // Doesn't need to be power of 2 - console.log(`Incomplete tree by leaf count (10): height=${subtree2.height}, size=${subtree2.size()}`); - - console.log('Factory methods test completed!'); -} - -// Test TxMap functionality -function testTxMap() { - console.log('\\nTesting TxMap functionality...'); - - const subtree = Subtree.newTree(2); - const hash1 = Hash.sha256(Utils.toArray('txmap1', 'utf8')); - const hash2 = Hash.sha256(Utils.toArray('txmap2', 'utf8')); - - subtree.addNode(hash1, BigInt(100), BigInt(50)); - subtree.addNode(hash2, BigInt(200), BigInt(75)); - - const txMap = subtree.getMap(); - console.log(`TxMap length: ${txMap.length()}`); - console.log(`Hash1 index: ${txMap.get(hash1)}`); - console.log(`Hash2 exists: ${txMap.exists(hash2)}`); - - // Test difference - const otherMap = subtree.getMap(); - const hash3 = Hash.sha256(Utils.toArray('txmap3', 'utf8')); - otherMap.put(hash3, BigInt(2)); // Add a hash that's not in the subtree - - const diff = subtree.difference(otherMap); - console.log(`Difference found ${diff.length} nodes not in the map`); - - console.log('TxMap test completed!'); -} - -// Run all tests -if (import.meta.url === `file://${process.argv[1]}`) { - testSubtreeSerialization(); - testFactoryMethods(); - testTxMap(); -} diff --git a/packages/network/ts-p2p/src/subtrees.ts b/packages/network/ts-p2p/src/subtrees.ts index d994f5da1..a9527ad30 100644 --- a/packages/network/ts-p2p/src/subtrees.ts +++ b/packages/network/ts-p2p/src/subtrees.ts @@ -2,406 +2,413 @@ import { Hash, Utils } from '@bsv/sdk' const { Reader, Writer } = Utils // Constants -export const HASH_SIZE = 32; -export const COINBASE_PLACEHOLDER = new Array(32).fill(0); // All zeros for coinbase placeholder +export const HASH_SIZE = 32 +export const COINBASE_PLACEHOLDER = Array.from({ length: 32 }, () => 0) // All zeros for coinbase placeholder // SubtreeNode represents a node in the subtree export interface SubtreeNode { - hash: number[]; // 32-byte transaction hash (called txid in JSON for UI compatibility) - fee: bigint; // Fee amount - sizeInBytes: bigint; // Size in bytes + hash: number[] // 32-byte transaction hash (called txid in JSON for UI compatibility) + fee: bigint // Fee amount + sizeInBytes: bigint // Size in bytes } // TxMap interface for transaction hash mapping export interface TxMap { - put(hash: number[], value: bigint): void; - get(hash: number[]): bigint | undefined; - exists(hash: number[]): boolean; - length(): number; - keys(): number[][]; + put(hash: number[], value: bigint): void + get(hash: number[]): bigint | undefined + exists(hash: number[]): boolean + length(): number + keys(): number[][] } // Simple TxMap implementation using Map with hex string keys export class SimpleTxMap implements TxMap { - private readonly map = new Map(); + private readonly map = new Map() - private hashToKey(hash: number[]): string { - return Utils.toHex(hash); - } + private hashToKey(hash: number[]): string { + return Utils.toHex(hash) + } - put(hash: number[], value: bigint): void { - this.map.set(this.hashToKey(hash), value); - } + put(hash: number[], value: bigint): void { + this.map.set(this.hashToKey(hash), value) + } - get(hash: number[]): bigint | undefined { - return this.map.get(this.hashToKey(hash)); - } + get(hash: number[]): bigint | undefined { + return this.map.get(this.hashToKey(hash)) + } - exists(hash: number[]): boolean { - return this.map.has(this.hashToKey(hash)); - } + exists(hash: number[]): boolean { + return this.map.has(this.hashToKey(hash)) + } - length(): number { - return this.map.size; - } + length(): number { + return this.map.size + } - keys(): number[][] { - return Array.from(this.map.keys()).map(key => Utils.toArray(key, 'hex')); - } + keys(): number[][] { + return Array.from(this.map.keys()).map(key => Utils.toArray(key, 'hex')) + } } // Utility functions function isPowerOfTwo(n: number): boolean { - return n > 0 && (n & (n - 1)) === 0; + return n > 0 && (n & (n - 1)) === 0 } // Using BSV SDK Writer and Reader instead of custom functions function arraysEqual(a: number[], b: number[]): boolean { - if (a.length !== b.length) return false; - for (let i = 0; i < a.length; i++) { - if (a[i] !== b[i]) return false; - } - return true; + if (a.length !== b.length) return false + for (let i = 0; i < a.length; i++) { + if (a[i] !== b[i]) return false + } + return true } export class Subtree { - height: number; - fees: bigint; - sizeInBytes: bigint; - feeHash: number[]; - nodes: SubtreeNode[]; - conflictingNodes: number[][]; - - // Private fields - private rootHash: number[] | null = null; - private treeSize: number = 0; - private nodeIndex: Map | null = null; - - constructor(height: number = 0) { - this.height = height; - this.fees = BigInt(0); - this.sizeInBytes = BigInt(0); - this.feeHash = new Array(32).fill(0); - this.nodes = []; - this.conflictingNodes = []; - this.treeSize = Math.pow(2, height); + height: number + fees: bigint + sizeInBytes: bigint + feeHash: number[] + nodes: SubtreeNode[] + conflictingNodes: number[][] + + // Private fields + private rootHash: number[] | null = null + private treeSize: number = 0 + private nodeIndex: Map | null = null + + constructor(height: number = 0) { + this.height = height + this.fees = BigInt(0) + this.sizeInBytes = BigInt(0) + this.feeHash = Array.from({ length: 32 }, () => 0) + this.nodes = [] + this.conflictingNodes = [] + this.treeSize = Math.pow(2, height) + } + + // Static factory methods + static newTree(height: number): Subtree { + if (!Number.isInteger(height) || height < 0) { + throw new Error('height must be a non-negative integer') } + return new Subtree(height) + } - // Static factory methods - static newTree(height: number): Subtree { - if (height < 0) { - throw new Error('height must be at least 0'); - } - return new Subtree(height); + static newTreeByLeafCount(maxNumberOfLeaves: number): Subtree { + if (!isPowerOfTwo(maxNumberOfLeaves)) { + throw new Error('numberOfLeaves must be a power of two') } + const height = Math.ceil(Math.log2(maxNumberOfLeaves)) + return new Subtree(height) + } - static newTreeByLeafCount(maxNumberOfLeaves: number): Subtree { - if (!isPowerOfTwo(maxNumberOfLeaves)) { - throw new Error('numberOfLeaves must be a power of two'); - } - const height = Math.ceil(Math.log2(maxNumberOfLeaves)); - return new Subtree(height); + static newIncompleteTreeByLeafCount(maxNumberOfLeaves: number): Subtree { + if (!Number.isInteger(maxNumberOfLeaves) || maxNumberOfLeaves < 1) { + throw new Error('numberOfLeaves must be a positive integer') } - - static newIncompleteTreeByLeafCount(maxNumberOfLeaves: number): Subtree { - const height = Math.ceil(Math.log2(maxNumberOfLeaves)); - return new Subtree(height); + const height = Math.ceil(Math.log2(maxNumberOfLeaves)) + return new Subtree(height) + } + + static fromBytes(bytes: number[]): Subtree { + const subtree = new Subtree() + subtree.deserialize(bytes) + return subtree + } + + // Core methods + duplicate(): Subtree { + const newSubtree = new Subtree(this.height) + newSubtree.fees = this.fees + newSubtree.sizeInBytes = this.sizeInBytes + newSubtree.feeHash = [...this.feeHash] + newSubtree.nodes = this.nodes.map(node => ({ + hash: [...node.hash], + fee: node.fee, + sizeInBytes: node.sizeInBytes + })) + newSubtree.conflictingNodes = this.conflictingNodes.map(hash => [...hash]) + newSubtree.rootHash = this.rootHash ? [...this.rootHash] : null + newSubtree.treeSize = this.treeSize + return newSubtree + } + + size(): number { + return this.treeSize + } + + length(): number { + return this.nodes.length + } + + isComplete(): boolean { + return this.nodes.length === this.treeSize + } + + addNode(hash: number[], fee: bigint, sizeInBytes: bigint): void { + if (this.nodes.length + 1 > this.treeSize) { + throw new Error('subtree is full') } - static fromBytes(bytes: number[]): Subtree { - const subtree = new Subtree(); - subtree.deserialize(bytes); - return subtree; + if (arraysEqual(hash, COINBASE_PLACEHOLDER)) { + throw new Error('[AddNode] coinbase placeholder node should be added with AddCoinbaseNode') } - // Core methods - duplicate(): Subtree { - const newSubtree = new Subtree(this.height); - newSubtree.fees = this.fees; - newSubtree.sizeInBytes = this.sizeInBytes; - newSubtree.feeHash = [...this.feeHash]; - newSubtree.nodes = this.nodes.map(node => ({ - hash: [...node.hash], - fee: node.fee, - sizeInBytes: node.sizeInBytes - })); - newSubtree.conflictingNodes = this.conflictingNodes.map(hash => [...hash]); - newSubtree.rootHash = this.rootHash ? [...this.rootHash] : null; - newSubtree.treeSize = this.treeSize; - return newSubtree; + const node: SubtreeNode = { + hash: [...hash], + fee, + sizeInBytes } - size(): number { - return this.treeSize; - } + this.nodes.push(node) + this.rootHash = null // reset rootHash + this.fees += fee + this.sizeInBytes += sizeInBytes - length(): number { - return this.nodes.length; + if (this.nodeIndex) { + this.nodeIndex.set(Utils.toHex(hash), this.nodes.length - 1) } + } - isComplete(): boolean { - return this.nodes.length === this.treeSize; + addSubtreeNode(node: SubtreeNode): void { + if (this.nodes.length + 1 > this.treeSize) { + throw new Error('subtree is full') } - addNode(hash: number[], fee: bigint, sizeInBytes: bigint): void { - if (this.nodes.length + 1 > this.treeSize) { - throw new Error('subtree is full'); - } - - if (arraysEqual(hash, COINBASE_PLACEHOLDER)) { - throw new Error('[AddNode] coinbase placeholder node should be added with AddCoinbaseNode'); - } - - const node: SubtreeNode = { - hash: [...hash], - fee, - sizeInBytes - }; - - this.nodes.push(node); - this.rootHash = null; // reset rootHash - this.fees += fee; - this.sizeInBytes += sizeInBytes; - - if (this.nodeIndex) { - this.nodeIndex.set(Utils.toHex(hash), this.nodes.length - 1); - } + if (arraysEqual(node.hash, COINBASE_PLACEHOLDER)) { + throw new Error( + '[AddSubtreeNode] coinbase placeholder node should be added with AddCoinbaseNode' + ) } - addSubtreeNode(node: SubtreeNode): void { - if (this.nodes.length + 1 > this.treeSize) { - throw new Error('subtree is full'); - } - - if (arraysEqual(node.hash, COINBASE_PLACEHOLDER)) { - throw new Error('[AddSubtreeNode] coinbase placeholder node should be added with AddCoinbaseNode'); - } - - this.nodes.push({ - hash: [...node.hash], - fee: node.fee, - sizeInBytes: node.sizeInBytes - }); - this.rootHash = null; - this.fees += node.fee; - this.sizeInBytes += node.sizeInBytes; - - if (this.nodeIndex) { - this.nodeIndex.set(Utils.toHex(node.hash), this.nodes.length - 1); - } + this.nodes.push({ + hash: [...node.hash], + fee: node.fee, + sizeInBytes: node.sizeInBytes + }) + this.rootHash = null + this.fees += node.fee + this.sizeInBytes += node.sizeInBytes + + if (this.nodeIndex) { + this.nodeIndex.set(Utils.toHex(node.hash), this.nodes.length - 1) } + } - addCoinbaseNode(): void { - if (this.nodes.length !== 0) { - throw new Error('subtree should be empty before adding a coinbase node'); - } - - this.nodes.push({ - hash: COINBASE_PLACEHOLDER, - fee: BigInt(0), - sizeInBytes: BigInt(0) - }); - this.rootHash = null; - this.fees = BigInt(0); - this.sizeInBytes = BigInt(0); + addCoinbaseNode(): void { + if (this.nodes.length !== 0) { + throw new Error('subtree should be empty before adding a coinbase node') } - addConflictingNode(newConflictingNode: number[]): void { - // Check if the conflicting node is actually in the subtree - let found = false; - for (const node of this.nodes) { - if (arraysEqual(node.hash, newConflictingNode)) { - found = true; - break; - } - } - - if (!found) { - throw new Error('conflicting node is not in the subtree'); - } - - // Check if already added - for (const conflictingNode of this.conflictingNodes) { - if (arraysEqual(conflictingNode, newConflictingNode)) { - return; // Already exists - } - } - - this.conflictingNodes.push([...newConflictingNode]); + this.nodes.push({ + hash: COINBASE_PLACEHOLDER, + fee: BigInt(0), + sizeInBytes: BigInt(0) + }) + this.rootHash = null + this.fees = BigInt(0) + this.sizeInBytes = BigInt(0) + } + + addConflictingNode(newConflictingNode: number[]): void { + // Check if the conflicting node is actually in the subtree + let found = false + for (const node of this.nodes) { + if (arraysEqual(node.hash, newConflictingNode)) { + found = true + break + } } - removeNodeAtIndex(index: number): void { - if (index >= this.nodes.length) { - throw new Error('index out of range'); - } + if (!found) { + throw new Error('conflicting node is not in the subtree') + } - const node = this.nodes[index]; - this.fees -= node.fee; - this.sizeInBytes -= node.sizeInBytes; + // Check if already added + for (const conflictingNode of this.conflictingNodes) { + if (arraysEqual(conflictingNode, newConflictingNode)) { + return // Already exists + } + } - const hashKey = Utils.toHex(Array.from(node.hash)); - this.nodes.splice(index, 1); - this.rootHash = null; + this.conflictingNodes.push([...newConflictingNode]) + } - if (this.nodeIndex) { - this.nodeIndex.delete(hashKey); - } + removeNodeAtIndex(index: number): void { + if (!Number.isInteger(index) || index < 0 || index >= this.nodes.length) { + throw new Error('index out of range') } - nodeIndexLookup(hash: number[]): number { - if (!this.nodeIndex) { - // Create the node index map - this.nodeIndex = new Map(); - for (let i = 0; i < this.nodes.length; i++) { - const key = Utils.toHex(this.nodes[i].hash); - this.nodeIndex.set(key, i); - } - } - - const key = Utils.toHex(hash); - return this.nodeIndex.get(key) ?? -1; - } + const node = this.nodes[index] + this.fees -= node.fee + this.sizeInBytes -= node.sizeInBytes - hasNode(hash: number[]): boolean { - return this.nodeIndexLookup(hash) !== -1; - } + this.nodes.splice(index, 1) + this.rootHash = null - getNode(hash: number[]): SubtreeNode | null { - const index = this.nodeIndexLookup(hash); - if (index !== -1) { - return this.nodes[index]; - } - return null; + if (this.nodeIndex) { + this.nodeIndex = new Map() + for (let i = 0; i < this.nodes.length; i++) { + this.nodeIndex.set(Utils.toHex(this.nodes[i].hash), i) + } + } + } + + nodeIndexLookup(hash: number[]): number { + if (!this.nodeIndex) { + // Create the node index map + this.nodeIndex = new Map() + for (let i = 0; i < this.nodes.length; i++) { + const key = Utils.toHex(this.nodes[i].hash) + this.nodeIndex.set(key, i) + } } - // Serialization methods - serialize(): number[] { - const writer = new Writer(); + const key = Utils.toHex(hash) + return this.nodeIndex.get(key) ?? -1 + } - // Write root hash - const rootHash = this.getRootHash(); - if (rootHash) { - writer.write(rootHash); - } else { - writer.write(new Array(32).fill(0)); - } + hasNode(hash: number[]): boolean { + return this.nodeIndexLookup(hash) !== -1 + } - // Write fees - writer.writeUInt64LE(Number(this.fees)); + getNode(hash: number[]): SubtreeNode | null { + const index = this.nodeIndexLookup(hash) + if (index !== -1) { + return this.nodes[index] + } + return null + } + + // Serialization methods + serialize(): number[] { + const writer = new Writer() + + // Write root hash + const rootHash = this.getRootHash() + if (rootHash) { + writer.write(rootHash) + } else { + writer.write(Array.from({ length: 32 }, () => 0)) + } - // Write size - writer.writeUInt64LE(Number(this.sizeInBytes)); + // Write fees + writer.writeUInt64LE(Number(this.fees)) - // Write number of nodes - writer.writeUInt64LE(this.nodes.length); + // Write size + writer.writeUInt64LE(Number(this.sizeInBytes)) - // Write nodes - for (const node of this.nodes) { - writer.write(node.hash); - writer.writeUInt64LE(Number(node.fee)); - writer.writeUInt64LE(Number(node.sizeInBytes)); - } + // Write number of nodes + writer.writeUInt64LE(this.nodes.length) - // Write number of conflicting nodes - writer.writeUInt64LE(this.conflictingNodes.length); + // Write nodes + for (const node of this.nodes) { + writer.write(node.hash) + writer.writeUInt64LE(Number(node.fee)) + writer.writeUInt64LE(Number(node.sizeInBytes)) + } - // Write conflicting nodes - for (const conflictingNode of this.conflictingNodes) { - writer.write(conflictingNode); - } + // Write number of conflicting nodes + writer.writeUInt64LE(this.conflictingNodes.length) - return writer.toArray(); + // Write conflicting nodes + for (const conflictingNode of this.conflictingNodes) { + writer.write(conflictingNode) } - serializeNodes(): Uint8Array { - const buffer = new Uint8Array(this.nodes.length * 32); - let offset = 0; + return writer.toArray() + } - for (const node of this.nodes) { - buffer.set(node.hash, offset); - offset += 32; - } + serializeNodes(): Uint8Array { + const buffer = new Uint8Array(this.nodes.length * 32) + let offset = 0 - return buffer; + for (const node of this.nodes) { + buffer.set(node.hash, offset) + offset += 32 } - deserialize(bytes: number[]): void { - const reader = new Reader(bytes); + return buffer + } - // Read root hash - this.rootHash = reader.read(32); + deserialize(bytes: number[]): void { + const reader = new Reader(bytes) - // Read fees - this.fees = BigInt(reader.readUInt64LEBn().toString()); + // Read root hash + this.rootHash = reader.read(32) - // Read sizeInBytes - this.sizeInBytes = BigInt(reader.readUInt64LEBn().toString()); + // Read fees + this.fees = BigInt(reader.readUInt64LEBn().toString()) - // Read number of nodes - const numNodes = Number(reader.readUInt64LEBn()); + // Read sizeInBytes + this.sizeInBytes = BigInt(reader.readUInt64LEBn().toString()) - // Calculate height and tree size - this.treeSize = numNodes; - this.height = Math.ceil(Math.log2(numNodes)); + // Read number of nodes + const numNodes = Number(reader.readUInt64LEBn()) - // Read nodes - this.nodes = []; - for (let i = 0; i < numNodes; i++) { - const hash = reader.read(32); - const fee = BigInt(reader.readUInt64LEBn().toString()); - const sizeInBytes = BigInt(reader.readUInt64LEBn().toString()); + // Calculate height and tree size + this.treeSize = numNodes + this.height = Math.ceil(Math.log2(numNodes)) - this.nodes.push({ hash, fee, sizeInBytes }); - } + // Read nodes + this.nodes = [] + for (let i = 0; i < numNodes; i++) { + const hash = reader.read(32) + const fee = BigInt(reader.readUInt64LEBn().toString()) + const sizeInBytes = BigInt(reader.readUInt64LEBn().toString()) - // Read number of conflicting nodes - const numConflictingNodes = Number(reader.readUInt64LEBn()); - - // Read conflicting nodes - this.conflictingNodes = []; - for (let i = 0; i < numConflictingNodes; i++) { - const conflictingNode = reader.read(32); - this.conflictingNodes.push(conflictingNode); - } + this.nodes.push({ hash, fee, sizeInBytes }) } - // Placeholder for root hash calculation - would need merkle tree implementation - getRootHash(): number[] | null { - if (this.rootHash) { - return this.rootHash; - } + // Read number of conflicting nodes + const numConflictingNodes = Number(reader.readUInt64LEBn()) - if (this.nodes.length === 0) { - return null; - } + // Read conflicting nodes + this.conflictingNodes = [] + for (let i = 0; i < numConflictingNodes; i++) { + const conflictingNode = reader.read(32) + this.conflictingNodes.push(conflictingNode) + } + } - // For now, return a simple hash of the first node - // In a complete implementation, this would build a merkle tree - if (this.nodes.length > 0) { - this.rootHash = Hash.sha256(this.nodes[0].hash); - return this.rootHash; - } + // Placeholder for root hash calculation - would need merkle tree implementation + getRootHash(): number[] | null { + if (this.rootHash) { + return this.rootHash + } - return null; + if (this.nodes.length === 0) { + return null } - // Utility methods - getMap(): TxMap { - const map = new SimpleTxMap(); - for (let i = 0; i < this.nodes.length; i++) { - map.put(this.nodes[i].hash, BigInt(i)); - } - return map; + // For now, return a simple hash of the first node + // In a complete implementation, this would build a merkle tree + if (this.nodes.length > 0) { + this.rootHash = Hash.sha256(this.nodes[0].hash) + return this.rootHash } - difference(ids: TxMap): SubtreeNode[] { - const diff: SubtreeNode[] = []; - for (const node of this.nodes) { - if (!ids.exists(node.hash)) { - diff.push(node); - } - } - return diff; + return null + } + + // Utility methods + getMap(): TxMap { + const map = new SimpleTxMap() + for (let i = 0; i < this.nodes.length; i++) { + map.put(this.nodes[i].hash, BigInt(i)) + } + return map + } + + difference(ids: TxMap): SubtreeNode[] { + const diff: SubtreeNode[] = [] + for (const node of this.nodes) { + if (!ids.exists(node.hash)) { + diff.push(node) + } } + return diff + } } diff --git a/packages/network/ts-p2p/test/index.test.ts b/packages/network/ts-p2p/test/index.test.ts new file mode 100644 index 000000000..11795713b --- /dev/null +++ b/packages/network/ts-p2p/test/index.test.ts @@ -0,0 +1,252 @@ +import { beforeAll, beforeEach, describe, expect, it, jest } from '@jest/globals' + +const createLibp2p = jest.fn() +const generateKeyPair = jest.fn(async () => ({ type: 'Ed25519' })) +const multiaddr = jest.fn((address: string) => address) +const preSharedKey = jest.fn(() => 'connection-protector') +const tcp = jest.fn(() => 'tcp') +const noise = jest.fn(() => 'noise') +const yamux = jest.fn(() => 'yamux') +const bootstrap = jest.fn(() => 'bootstrap') +const pubsubPeerDiscovery = jest.fn(() => 'pubsub-discovery') +const kadDHT = jest.fn(() => 'dht') +const gossipsub = jest.fn(() => 'pubsub') +const identify = jest.fn(() => 'identify') +const ping = jest.fn(() => 'ping') + +jest.unstable_mockModule('libp2p', () => ({ createLibp2p })) +jest.unstable_mockModule('@libp2p/crypto/keys', () => ({ generateKeyPair })) +jest.unstable_mockModule('@multiformats/multiaddr', () => ({ multiaddr })) +jest.unstable_mockModule('@libp2p/pnet', () => ({ preSharedKey })) +jest.unstable_mockModule('@libp2p/tcp', () => ({ tcp })) +jest.unstable_mockModule('@chainsafe/libp2p-noise', () => ({ noise })) +jest.unstable_mockModule('@chainsafe/libp2p-yamux', () => ({ yamux })) +jest.unstable_mockModule('@libp2p/bootstrap', () => ({ bootstrap })) +jest.unstable_mockModule('@libp2p/pubsub-peer-discovery', () => ({ pubsubPeerDiscovery })) +jest.unstable_mockModule('@libp2p/kad-dht', () => ({ kadDHT })) +jest.unstable_mockModule('@chainsafe/libp2p-gossipsub', () => ({ gossipsub })) +jest.unstable_mockModule('@libp2p/identify', () => ({ identify })) +jest.unstable_mockModule('@libp2p/ping', () => ({ ping })) + +type IndexModule = typeof import('../src/index.js') +let TeranodeListener: IndexModule['TeranodeListener'] +let startSubscriber: IndexModule['startSubscriber'] + +interface MockNode { + addEventListener: jest.Mock + dial: jest.Mock + getPeers: jest.Mock + peerId: { toString: () => string } + services: { + pubsub: { + addEventListener: jest.Mock + subscribe: jest.Mock + unsubscribe: jest.Mock + } + } + start: jest.Mock + stop: jest.Mock +} + +function mockNode(): { + eventHandlers: Record void> + messageHandlers: Record void> + node: MockNode +} { + const eventHandlers: Record void> = {} + const messageHandlers: Record void> = {} + const node: MockNode = { + addEventListener: jest.fn((name: string, handler: (event: any) => void) => { + eventHandlers[name] = handler + }), + dial: jest.fn().mockResolvedValue(undefined), + getPeers: jest.fn(() => [{ toString: () => 'connected-peer' }]), + peerId: { toString: () => 'local-peer' }, + services: { + pubsub: { + addEventListener: jest.fn((name: string, handler: (event: any) => void) => { + messageHandlers[name] = handler + }), + subscribe: jest.fn(), + unsubscribe: jest.fn() + } + }, + start: jest.fn().mockResolvedValue(undefined), + stop: jest.fn().mockResolvedValue(undefined) + } + return { eventHandlers, messageHandlers, node } +} + +const topic = 'bitcoin/mainnet-bestblock' as const +const frame = (payload: unknown): Uint8Array => { + const data = Buffer.from(JSON.stringify(payload), 'utf8').toString('base64') + return new TextEncoder().encode(JSON.stringify({ name: 'sender', data })) +} + +beforeAll(async () => { + ;({ TeranodeListener, startSubscriber } = await import('../src/index.js')) +}) + +beforeEach(() => { + jest.clearAllMocks() +}) + +describe('TeranodeListener', () => { + it('starts, dispatches raw messages, manages subscriptions, and stops cleanly', async () => { + const { eventHandlers, messageHandlers, node } = mockNode() + createLibp2p.mockResolvedValue(node) + const callback = jest.fn() + const listener = new TeranodeListener( + { [topic]: callback }, + { + bootstrapPeers: ['/dns4/bootstrap.example/tcp/1'], + staticPeers: [], + sharedKey: 'abcd', + dhtProtocolID: '/custom', + listenAddresses: ['/ip4/127.0.0.1/tcp/1'] + } + ) + const initialSigintListeners = process.listenerCount('SIGINT') + + await listener.start() + + expect(listener.getNode()).toBe(node) + expect(listener.getConnectedPeerCount()).toBe(1) + expect(node.start).toHaveBeenCalledTimes(1) + expect(createLibp2p).toHaveBeenCalledWith( + expect.objectContaining({ + addresses: { listen: ['/ip4/127.0.0.1/tcp/1'] } + }) + ) + expect(kadDHT).toHaveBeenCalledWith( + expect.objectContaining({ + protocol: '/custom/kad/1.0.0' + }) + ) + expect(process.listenerCount('SIGINT')).toBe(initialSigintListeners + 1) + + messageHandlers['gossipsub:message']({ + detail: { + msg: { topic, data: Uint8Array.from([1, 2, 3]) }, + propagationSource: { toString: () => 'remote-peer' } + } + }) + expect(callback).toHaveBeenCalledWith(Uint8Array.from([1, 2, 3]), topic, 'remote-peer') + + eventHandlers['peer:discovery']({ detail: { id: { toString: () => 'found-peer' } } }) + eventHandlers['peer:connect']({ detail: { toString: () => 'joined-peer' } }) + eventHandlers['peer:disconnect']({ detail: { toString: () => 'left-peer' } }) + + const secondTopic = 'bitcoin/mainnet-block' + listener.addTopicCallback(secondTopic, jest.fn()) + expect(node.services.pubsub.subscribe).toHaveBeenCalledWith(secondTopic) + listener.removeTopicCallback(secondTopic) + expect(node.services.pubsub.unsubscribe).toHaveBeenCalledWith(secondTopic) + + await listener.start() + expect(node.start).toHaveBeenCalledTimes(1) + + await listener.stop() + expect(node.stop).toHaveBeenCalledTimes(1) + expect(listener.getNode()).toBeNull() + expect(listener.getConnectedPeerCount()).toBe(0) + expect(process.listenerCount('SIGINT')).toBe(initialSigintListeners) + await listener.stop() + }) + + it('decodes valid messages, skips invalid frames, and isolates callback errors', async () => { + const { messageHandlers, node } = mockNode() + createLibp2p.mockResolvedValue(node) + const callback = jest.fn() + const listener = new TeranodeListener( + { [topic]: callback }, + { decodeMessages: true, staticPeers: [] } + ) + await listener.start() + const dispatch = messageHandlers['gossipsub:message'] + + dispatch({ + detail: { + msg: { topic, data: frame({ Height: 42 }) }, + propagationSource: { toString: () => 'remote-peer' } + } + }) + dispatch({ + detail: { + msg: { topic, data: Uint8Array.from([0xff]) }, + propagationSource: { toString: () => 'remote-peer' } + } + }) + dispatch({ + detail: { + msg: { topic: 'bitcoin/testnet-block', data: Uint8Array.from([1]) }, + propagationSource: { toString: () => 'remote-peer' } + } + }) + callback.mockImplementationOnce(() => { + throw new Error('consumer failure') + }) + dispatch({ + detail: { + msg: { topic, data: frame({ Height: 43 }) }, + propagationSource: { toString: () => 'remote-peer' } + } + }) + + expect(callback).toHaveBeenCalledTimes(2) + expect(callback).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ sender: 'sender', payload: { Height: 42 } }), + topic, + 'remote-peer' + ) + await listener.stop() + }) + + it('connects static peers independently and retries disconnected peers', async () => { + jest.useFakeTimers() + const { node } = mockNode() + node.dial.mockImplementation(async (address: string) => { + if (address.includes('unreachable')) throw new Error('offline') + }) + node.getPeers.mockReturnValue([]) + createLibp2p.mockResolvedValue(node) + const listener = new TeranodeListener( + { [topic]: jest.fn() }, + { + staticPeers: [ + '/dns4/reachable.example/tcp/1/p2p/reachable', + '/dns4/unreachable.example/tcp/1/p2p/unreachable', + '/dns4/no-peer-id.example/tcp/1' + ] + } + ) + + await listener.start() + expect(node.dial).toHaveBeenCalledTimes(3) + + await jest.advanceTimersByTimeAsync(30_000) + expect(node.dial).toHaveBeenCalledTimes(5) + + await listener.stop() + jest.useRealTimers() + }) +}) + +describe('startSubscriber', () => { + it('adapts the legacy topic list to a listener lifecycle', async () => { + const { node } = mockNode() + createLibp2p.mockResolvedValue(node) + const before = new Set(process.listeners('SIGINT')) + + await startSubscriber({ topics: [topic], staticPeers: [] }) + + expect(node.services.pubsub.subscribe).toHaveBeenCalledWith(topic) + const shutdown = process.listeners('SIGINT').find(listener => !before.has(listener)) + expect(shutdown).toBeDefined() + shutdown?.() + await Promise.resolve() + await Promise.resolve() + expect(node.stop).toHaveBeenCalledTimes(1) + }) +}) diff --git a/packages/network/ts-p2p/test/messages.test.ts b/packages/network/ts-p2p/test/messages.test.ts index e4fdbfdef..4ae27c83b 100644 --- a/packages/network/ts-p2p/test/messages.test.ts +++ b/packages/network/ts-p2p/test/messages.test.ts @@ -16,7 +16,7 @@ const textEncoder = new TextEncoder() * side genuinely exercises the package's own hand-rolled base64 decoder rather * than round-tripping through the same implementation. */ -function frame (sender: string, payload: unknown): Uint8Array { +function frame(sender: string, payload: unknown): Uint8Array { const data = Buffer.from(JSON.stringify(payload), 'utf8').toString('base64') return textEncoder.encode(JSON.stringify({ name: sender, data })) } diff --git a/packages/network/ts-p2p/test/subtrees.test.ts b/packages/network/ts-p2p/test/subtrees.test.ts new file mode 100644 index 000000000..1a3da7ace --- /dev/null +++ b/packages/network/ts-p2p/test/subtrees.test.ts @@ -0,0 +1,146 @@ +import { describe, expect, it } from '@jest/globals' +import { Hash } from '@bsv/sdk' +import { COINBASE_PLACEHOLDER, SimpleTxMap, Subtree, type SubtreeNode } from '../src/subtrees.js' + +const hash = (value: number): number[] => Array(32).fill(value) +const node = (value: number, fee = 10n, sizeInBytes = 100n): SubtreeNode => ({ + hash: hash(value), + fee, + sizeInBytes +}) + +describe('SimpleTxMap', () => { + it('stores hashes by value and enumerates independent key arrays', () => { + const map = new SimpleTxMap() + const original = hash(1) + + map.put(original, 7n) + original[0] = 2 + + expect(map.length()).toBe(1) + expect(map.exists(hash(1))).toBe(true) + expect(map.get(hash(1))).toBe(7n) + expect(map.get(hash(2))).toBeUndefined() + expect(map.keys()).toEqual([hash(1)]) + }) +}) + +describe('Subtree construction and mutation', () => { + it('validates tree dimensions', () => { + expect(() => Subtree.newTree(-1)).toThrow('non-negative integer') + expect(() => Subtree.newTree(1.5)).toThrow('non-negative integer') + expect(() => Subtree.newTreeByLeafCount(3)).toThrow('power of two') + expect(() => Subtree.newIncompleteTreeByLeafCount(0)).toThrow('positive integer') + + expect(Subtree.newTreeByLeafCount(4).size()).toBe(4) + expect(Subtree.newIncompleteTreeByLeafCount(3).size()).toBe(4) + }) + + it('adds regular and coinbase nodes while maintaining totals and capacity', () => { + const tree = Subtree.newTree(1) + tree.addCoinbaseNode() + tree.addSubtreeNode(node(1, 5n, 50n)) + + expect(tree.length()).toBe(2) + expect(tree.isComplete()).toBe(true) + expect(tree.fees).toBe(5n) + expect(tree.sizeInBytes).toBe(50n) + expect(() => tree.addNode(hash(2), 1n, 1n)).toThrow('subtree is full') + + const nonEmpty = Subtree.newTree(1) + nonEmpty.addNode(hash(1), 1n, 1n) + expect(() => nonEmpty.addCoinbaseNode()).toThrow('should be empty') + expect(() => Subtree.newTree(0).addNode(COINBASE_PLACEHOLDER, 0n, 0n)).toThrow( + 'AddCoinbaseNode' + ) + expect(() => Subtree.newTree(0).addSubtreeNode(node(0, 0n, 0n))).toThrow('AddCoinbaseNode') + }) + + it('duplicates all mutable data without sharing arrays', () => { + const original = Subtree.newTree(1) + original.addNode(hash(1), 2n, 3n) + original.addConflictingNode(hash(1)) + original.getRootHash() + + const copy = original.duplicate() + copy.nodes[0].hash[0] = 9 + copy.conflictingNodes[0][0] = 9 + copy.feeHash[0] = 9 + + expect(original.nodes[0].hash).toEqual(hash(1)) + expect(original.conflictingNodes[0]).toEqual(hash(1)) + expect(original.feeHash[0]).toBe(0) + }) + + it('tracks conflicts once and rejects hashes outside the tree', () => { + const tree = Subtree.newTree(1) + tree.addNode(hash(1), 1n, 1n) + + tree.addConflictingNode(hash(1)) + tree.addConflictingNode(hash(1)) + + expect(tree.conflictingNodes).toEqual([hash(1)]) + expect(() => tree.addConflictingNode(hash(2))).toThrow('not in the subtree') + }) + + it('rebuilds its lookup index after removal', () => { + const tree = Subtree.newTree(2) + tree.addNode(hash(1), 1n, 10n) + tree.addNode(hash(2), 2n, 20n) + tree.addNode(hash(3), 3n, 30n) + expect(tree.nodeIndexLookup(hash(3))).toBe(2) + + tree.removeNodeAtIndex(1) + + expect(tree.hasNode(hash(2))).toBe(false) + expect(tree.nodeIndexLookup(hash(3))).toBe(1) + expect(tree.getNode(hash(3))).toEqual(node(3, 3n, 30n)) + expect(tree.fees).toBe(4n) + expect(tree.sizeInBytes).toBe(40n) + expect(() => tree.removeNodeAtIndex(-1)).toThrow('index out of range') + expect(() => tree.removeNodeAtIndex(2)).toThrow('index out of range') + }) +}) + +describe('Subtree serialization and queries', () => { + it('round-trips a complete tree, including conflicts and totals', () => { + const tree = Subtree.newTree(1) + tree.addNode(hash(1), 2n, 20n) + tree.addNode(hash(2), 3n, 30n) + tree.addConflictingNode(hash(2)) + + const restored = Subtree.fromBytes(tree.serialize()) + + expect(restored.size()).toBe(2) + expect(restored.height).toBe(1) + expect(restored.fees).toBe(5n) + expect(restored.sizeInBytes).toBe(50n) + expect(restored.nodes).toEqual(tree.nodes) + expect(restored.conflictingNodes).toEqual([hash(2)]) + expect(Array.from(restored.serializeNodes())).toEqual([...hash(1), ...hash(2)]) + }) + + it('characterizes the current root hash and invalidates it after mutation', () => { + const tree = Subtree.newTree(1) + expect(tree.getRootHash()).toBeNull() + tree.addNode(hash(1), 1n, 1n) + + const firstRoot = tree.getRootHash() + expect(firstRoot).toEqual(Hash.sha256(hash(1))) + expect(tree.getRootHash()).toBe(firstRoot) + + tree.removeNodeAtIndex(0) + expect(tree.getRootHash()).toBeNull() + }) + + it('builds maps and returns nodes missing from another map', () => { + const tree = Subtree.newTree(1) + tree.addNode(hash(1), 1n, 1n) + tree.addNode(hash(2), 2n, 2n) + const ids = new SimpleTxMap() + ids.put(hash(1), 0n) + + expect(tree.getMap().get(hash(2))).toBe(1n) + expect(tree.difference(ids)).toEqual([node(2, 2n, 2n)]) + }) +}) diff --git a/packages/network/ts-p2p/tsconfig.json b/packages/network/ts-p2p/tsconfig.json index 33fd7596e..abd8ec1c4 100644 --- a/packages/network/ts-p2p/tsconfig.json +++ b/packages/network/ts-p2p/tsconfig.json @@ -13,11 +13,6 @@ "forceConsistentCasingInFileNames": true, "types": ["node"] }, - "include": [ - "src/**/*.ts" - ], - "exclude": [ - "node_modules", - "dist" - ] + "include": ["src/**/*.ts"], + "exclude": ["node_modules", "dist"] } diff --git a/packages/overlays/gasp-core/AGENTS.md b/packages/overlays/gasp-core/AGENTS.md index 457636b3a..63b721fb7 100644 --- a/packages/overlays/gasp-core/AGENTS.md +++ b/packages/overlays/gasp-core/AGENTS.md @@ -9,11 +9,13 @@ Graph Aware Sync Protocol (GASP) — a powerful protocol for synchronizing BSV t From `mod.ts` (re-exports from `src/GASP.ts`): **Main class:** + - `GASP` — Orchestrator for graph-aware sync - Constructor: `new GASP(storage, remote, lastInteraction?, logPrefix?, log?, unidirectional?, logLevel?, sequential?)` - Methods: `sync()` → `Promise` **Interfaces:** + - `GASPStorage` — Local database layer for UTXOs, transactions, metadata, graph management - `findKnownUTXOs(since, limit?)` → `Promise` - `hydrateGASPNode(graphID, txid, outputIndex, metadata)` → `Promise` @@ -30,6 +32,7 @@ From `mod.ts` (re-exports from `src/GASP.ts`): - `submitNode(node)` → `Promise` **Types:** + - `GASPInitialRequest` — { version, since, limit? } - `GASPInitialResponse` — { UTXOList, since } - `GASPInitialReply` — { UTXOList } @@ -38,6 +41,7 @@ From `mod.ts` (re-exports from `src/GASP.ts`): - `GASPNodeResponse` — { requestedInputs: { txid.index: { metadata: boolean } } } **Enums/Constants:** + - `LogLevel` — { ERROR, WARN, INFO, DEBUG } - `GASPVersionMismatchError` — Custom error for version conflicts @@ -151,12 +155,12 @@ const myRemote = new MyRemote() const gasp = new GASP( myStorage, myRemote, - 0, // lastInteraction (UNIX seconds) - '[GASP] ', // logPrefix - false, // legacy log toggle - false, // unidirectional? false = bidirectional - LogLevel.INFO, // logLevel - false // sequential? false = parallel operations + 0, // lastInteraction (UNIX seconds) + '[GASP] ', // logPrefix + false, // legacy log toggle + false, // unidirectional? false = bidirectional + LogLevel.INFO, // logLevel + false // sequential? false = parallel operations ) await gasp.sync() @@ -169,7 +173,7 @@ const gaspPullOnly = new GASP( 0, '[GASP-Pull] ', false, - true, // unidirectional = true (pull only, no push) + true, // unidirectional = true (pull only, no push) LogLevel.DEBUG, false ) @@ -186,7 +190,7 @@ const gaspSequential = new GASP( false, false, LogLevel.WARN, - true // sequential = true (one operation at a time) + true // sequential = true (one operation at a time) ) await gaspSequential.sync() @@ -207,9 +211,11 @@ await gaspSequential.sync() ## Dependencies **Runtime:** + - `@bsv/sdk` — Transaction, utils for encoding/decoding **Dev:** + - jest, ts-jest, typescript, Oxlint ## Common pitfalls / gotchas diff --git a/packages/overlays/gasp-core/API.md b/packages/overlays/gasp-core/API.md index fc3528efc..639d7092f 100644 --- a/packages/overlays/gasp-core/API.md +++ b/packages/overlays/gasp-core/API.md @@ -4,9 +4,9 @@ Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes), [Types](#typ ## Interfaces -| | -| --- | -| [GASPRemote](#interface-gaspremote) | +| | +| ------------------------------------- | +| [GASPRemote](#interface-gaspremote) | | [GASPStorage](#interface-gaspstorage) | Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes), [Types](#types) @@ -19,10 +19,15 @@ The communications mechanism between a local GASP instance and a foreign GASP in ```ts export interface GASPRemote { - getInitialResponse: (request: GASPInitialRequest) => Promise; - getInitialReply: (response: GASPInitialResponse) => Promise; - requestNode: (graphID: string, txid: string, outputIndex: number, metadata: boolean) => Promise; - submitNode: (node: GASPNode) => Promise; + getInitialResponse: (request: GASPInitialRequest) => Promise + getInitialReply: (response: GASPInitialResponse) => Promise + requestNode: ( + graphID: string, + txid: string, + outputIndex: number, + metadata: boolean + ) => Promise + submitNode: (node: GASPNode) => Promise } ``` @@ -39,6 +44,7 @@ Given an outgoing initial response, obtain the reply from the foreign instance. ```ts getInitialReply: (response: GASPInitialResponse) => Promise ``` + See also: [GASPInitialReply](#type-gaspinitialreply), [GASPInitialResponse](#type-gaspinitialresponse) #### Property getInitialResponse @@ -48,6 +54,7 @@ Given an outgoing initial request, send the request to the foreign instance and ```ts getInitialResponse: (request: GASPInitialRequest) => Promise ``` + See also: [GASPInitialRequest](#type-gaspinitialrequest), [GASPInitialResponse](#type-gaspinitialresponse) #### Property requestNode @@ -55,8 +62,10 @@ See also: [GASPInitialRequest](#type-gaspinitialrequest), [GASPInitialResponse]( Given an outgoing txid, outputIndex and optional metadata, request the associated GASP node from the foreign instane. ```ts -requestNode: (graphID: string, txid: string, outputIndex: number, metadata: boolean) => Promise +requestNode: (graphID: string, txid: string, outputIndex: number, metadata: boolean) => + Promise ``` + See also: [GASPNode](#type-gaspnode) #### Property submitNode @@ -66,6 +75,7 @@ Given an outgoing node, send the node to the foreign instance and determine whic ```ts submitNode: (node: GASPNode) => Promise ``` + See also: [GASPNode](#type-gaspnode), [GASPNodeResponse](#type-gaspnoderesponse) @@ -73,22 +83,30 @@ See also: [GASPNode](#type-gaspnode), [GASPNodeResponse](#type-gaspnoderesponse) Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes), [Types](#types) --- + ### Interface: GASPStorage Facilitates the finding of UTXOs, determination of needed inputs, temporary graph management, and eventual graph finalization. ```ts export interface GASPStorage { - findKnownUTXOs: (since: number) => Promise>; - hydrateGASPNode: (graphID: string, txid: string, outputIndex: number, metadata: boolean) => Promise; - findNeededInputs: (tx: GASPNode) => Promise; - appendToGraph: (tx: GASPNode, spentBy?: string) => Promise; - validateGraphAnchor: (graphID: string) => Promise; - discardGraph: (graphID: string) => Promise; - finalizeGraph: (graphID: string) => Promise; + findKnownUTXOs: (since: number) => Promise< + Array<{ + txid: string + outputIndex: number + }> + > + hydrateGASPNode: ( + graphID: string, + txid: string, + outputIndex: number, + metadata: boolean + ) => Promise + findNeededInputs: (tx: GASPNode) => Promise + appendToGraph: (tx: GASPNode, spentBy?: string) => Promise + validateGraphAnchor: (graphID: string) => Promise + discardGraph: (graphID: string) => Promise + finalizeGraph: (graphID: string) => Promise } ``` @@ -105,6 +123,7 @@ Appends a new node to a temporary graph. ```ts appendToGraph: (tx: GASPNode, spentBy?: string) => Promise ``` + See also: [GASPNode](#type-gaspnode) #### Property discardGraph @@ -129,10 +148,13 @@ Returns an array of transaction outpoints that are currently known to be unspent Non-confirmed (non-timestamped) outputs should always be returned, regardless of the timestamp. ```ts -findKnownUTXOs: (since: number) => Promise> +findKnownUTXOs: (since: number) => + Promise< + Array<{ + txid: string + outputIndex: number + }> + > ``` #### Property findNeededInputs @@ -142,6 +164,7 @@ For a given node, returns the inputs needed to complete the graph, including whe ```ts findNeededInputs: (tx: GASPNode) => Promise ``` + See also: [GASPNode](#type-gaspnode), [GASPNodeResponse](#type-gaspnoderesponse) #### Property hydrateGASPNode @@ -149,8 +172,10 @@ See also: [GASPNode](#type-gaspnode), [GASPNodeResponse](#type-gaspnoderesponse) For a given txid and output index, returns the associated transaction, a merkle proof if the transaction is in a block, and metadata if if requested. If no metadata is requested, metadata hashes on inputs are not returned. ```ts -hydrateGASPNode: (graphID: string, txid: string, outputIndex: number, metadata: boolean) => Promise +hydrateGASPNode: (graphID: string, txid: string, outputIndex: number, metadata: boolean) => + Promise ``` + See also: [GASPNode](#type-gaspnode) #### Property validateGraphAnchor @@ -166,11 +191,12 @@ validateGraphAnchor: (graphID: string) => Promise Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes), [Types](#types) --- + ## Classes -| | -| --- | -| [GASP](#class-gasp) | +| | +| ----------------------------------------------------------- | +| [GASP](#class-gasp) | | [GASPVersionMismatchError](#class-gaspversionmismatcherror) | Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes), [Types](#types) @@ -183,21 +209,33 @@ Main class implementing the Graph Aware Sync Protocol. ```ts export class GASP implements GASPRemote { - version: number; - storage: GASPStorage; - remote: GASPRemote; - lastInteraction: number; - logPrefix: string; - log: boolean; - unidirectional: boolean; - constructor(storage: GASPStorage, remote: GASPRemote, lastInteraction = 0, logPrefix = "[GASP] ", log = false, unidirectional = false) - async sync(): Promise - async buildInitialRequest(since: number): Promise - async getInitialResponse(request: GASPInitialRequest): Promise - async getInitialReply(response: GASPInitialResponse): Promise - async requestNode(graphID: string, txid: string, outputIndex: number, metadata: boolean): Promise - async submitNode(node: GASPNode): Promise - async completeGraph(graphID: string): Promise + version: number + storage: GASPStorage + remote: GASPRemote + lastInteraction: number + logPrefix: string + log: boolean + unidirectional: boolean + constructor( + storage: GASPStorage, + remote: GASPRemote, + lastInteraction = 0, + logPrefix = '[GASP] ', + log = false, + unidirectional = false + ) + async sync(): Promise + async buildInitialRequest(since: number): Promise + async getInitialResponse(request: GASPInitialRequest): Promise + async getInitialReply(response: GASPInitialResponse): Promise + async requestNode( + graphID: string, + txid: string, + outputIndex: number, + metadata: boolean + ): Promise + async submitNode(node: GASPNode): Promise + async completeGraph(graphID: string): Promise } ``` @@ -210,32 +248,34 @@ See also: [GASPInitialReply](#type-gaspinitialreply), [GASPInitialRequest](#type #### Constructor ```ts -constructor(storage: GASPStorage, remote: GASPRemote, lastInteraction = 0, logPrefix = "[GASP] ", log = false, unidirectional = false) +constructor(storage: GASPStorage, remote: GASPRemote, lastInteraction = 0, logPrefix = "[GASP] ", log = false, unidirectional = false) ``` + See also: [GASPRemote](#interface-gaspremote), [GASPStorage](#interface-gaspstorage) Argument Details -+ **storage** - + The GASP Storage interface to use -+ **remote** - + The GASP Remote interface to use -+ **lastInteraction** - + The timestamp when we last interacted with this remote party -+ **logPrefix** - + Optional prefix for log messages -+ **log** - + Whether to log messages -+ **unidirectional** - + Whether to disable the "reply" side and do pull-only +- **storage** + - The GASP Storage interface to use +- **remote** + - The GASP Remote interface to use +- **lastInteraction** + - The timestamp when we last interacted with this remote party +- **logPrefix** + - Optional prefix for log messages +- **log** + - Whether to log messages +- **unidirectional** + - Whether to disable the "reply" side and do pull-only #### Method buildInitialRequest Builds the initial request for the sync process. ```ts -async buildInitialRequest(since: number): Promise +async buildInitialRequest(since: number): Promise ``` + See also: [GASPInitialRequest](#type-gaspinitialrequest) Returns @@ -247,21 +287,22 @@ A promise for the initial request object. Handles the completion of a newly-synced graph ```ts -async completeGraph(graphID: string): Promise +async completeGraph(graphID: string): Promise ``` Argument Details -+ **graphID** - + The ID of the newly-synced graph +- **graphID** + - The ID of the newly-synced graph #### Method getInitialReply Builds the initial reply based on the received response. ```ts -async getInitialReply(response: GASPInitialResponse): Promise +async getInitialReply(response: GASPInitialResponse): Promise ``` + See also: [GASPInitialReply](#type-gaspinitialreply), [GASPInitialResponse](#type-gaspinitialresponse) Returns @@ -270,16 +311,17 @@ A promise for an initial reply Argument Details -+ **response** - + The initial response object. +- **response** + - The initial response object. #### Method getInitialResponse Builds the initial response based on the received request. ```ts -async getInitialResponse(request: GASPInitialRequest): Promise +async getInitialResponse(request: GASPInitialRequest): Promise ``` + See also: [GASPInitialRequest](#type-gaspinitialrequest), [GASPInitialResponse](#type-gaspinitialresponse) Returns @@ -288,16 +330,17 @@ A promise for an initial response Argument Details -+ **request** - + The initial request object. +- **request** + - The initial request object. #### Method requestNode Provides a requested node to a foreign instance who requested it. ```ts -async requestNode(graphID: string, txid: string, outputIndex: number, metadata: boolean): Promise +async requestNode(graphID: string, txid: string, outputIndex: number, metadata: boolean): Promise ``` + See also: [GASPNode](#type-gaspnode) #### Method submitNode @@ -306,8 +349,9 @@ Provides a set of inputs we care about after processing a new incoming node. Also finalizes or discards a graph if no additional data is requested from the foreign instance. ```ts -async submitNode(node: GASPNode): Promise +async submitNode(node: GASPNode): Promise ``` + See also: [GASPNode](#type-gaspnode), [GASPNodeResponse](#type-gaspnoderesponse) #### Method sync @@ -315,7 +359,7 @@ See also: [GASPNode](#type-gaspnode), [GASPNodeResponse](#type-gaspnoderesponse) Synchronizes the transaction data between the local and remote participants. ```ts -async sync(): Promise +async sync(): Promise ``` @@ -323,29 +367,31 @@ async sync(): Promise Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes), [Types](#types) --- + ### Class: GASPVersionMismatchError ```ts export class GASPVersionMismatchError extends Error { - code: "ERR_GASP_VERSION_MISMATCH"; - currentVersion: number; - foreignVersion: number; - constructor(message: string, currentVersion: number, foreignVersion: number) + code: 'ERR_GASP_VERSION_MISMATCH' + currentVersion: number + foreignVersion: number + constructor(message: string, currentVersion: number, foreignVersion: number) } ``` Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes), [Types](#types) --- + ## Types -| | -| --- | -| [GASPInitialReply](#type-gaspinitialreply) | -| [GASPInitialRequest](#type-gaspinitialrequest) | +| | +| ------------------------------------------------ | +| [GASPInitialReply](#type-gaspinitialreply) | +| [GASPInitialRequest](#type-gaspinitialrequest) | | [GASPInitialResponse](#type-gaspinitialresponse) | -| [GASPNode](#type-gaspnode) | -| [GASPNodeResponse](#type-gaspnoderesponse) | +| [GASPNode](#type-gaspnode) | +| [GASPNodeResponse](#type-gaspnoderesponse) | Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes), [Types](#types) @@ -357,77 +403,87 @@ Represents the subsequent message sent in reply to the initial response. ```ts export type GASPInitialReply = { - UTXOList: Array<{ - txid: string; - outputIndex: number; - }>; + UTXOList: Array<{ + txid: string + outputIndex: number + }> } ``` Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes), [Types](#types) --- + ### Type: GASPInitialRequest Represents the initial request made under the Graph Aware Sync Protocol. ```ts export type GASPInitialRequest = { - version: number; - since: number; + version: number + since: number } ``` Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes), [Types](#types) --- + ### Type: GASPInitialResponse Represents the initial response made under the Graph Aware Sync Protocol. ```ts export type GASPInitialResponse = { - UTXOList: Array<{ - txid: string; - outputIndex: number; - }>; - since: number; + UTXOList: Array<{ + txid: string + outputIndex: number + }> + since: number } ``` Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes), [Types](#types) --- + ### Type: GASPNode Represents an output, its encompassing transaction, and the associated metadata, together with references to inputs and their metadata. ```ts export type GASPNode = { - graphID: string; - rawTx: string; - outputIndex: number; - proof?: string; - txMetadata?: string; - outputMetadata?: string; - inputs?: Record; + graphID: string + rawTx: string + outputIndex: number + proof?: string + txMetadata?: string + outputMetadata?: string + inputs?: Record< + string, + { + hash: string + } + > } ``` Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes), [Types](#types) --- + ### Type: GASPNodeResponse Denotes which input transactions are requested, and whether metadata needs to be sent. ```ts export type GASPNodeResponse = { - requestedInputs: Record; + requestedInputs: Record< + string, + { + metadata: boolean + } + > } ``` diff --git a/packages/overlays/gasp-core/BASELINE.md b/packages/overlays/gasp-core/BASELINE.md index 44bf0731d..8909571c1 100644 --- a/packages/overlays/gasp-core/BASELINE.md +++ b/packages/overlays/gasp-core/BASELINE.md @@ -3,48 +3,55 @@ > Captured: 2026-04-24. Reflects state at time of ts-stack migration. ## Identity -| Field | Value | -|-------|-------| -| Package | `@bsv/gasp` | -| Path | `packages/overlays/gasp-core` | -| npm | [@bsv/gasp](https://www.npmjs.com/package/@bsv/gasp) | -| Version | 1.2.2 | -| Criticality | **Tier 3** — GASP (Graph Aware Sync Protocol) core; failure isolated to overlay sync | -| Reliability Level | **RL1** — 1 test file, coverage tooling present | -| Owner | @sirdeggen | -| Backup owner | — | + +| Field | Value | +| ----------------- | ------------------------------------------------------------------------------------ | +| Package | `@bsv/gasp` | +| Path | `packages/overlays/gasp-core` | +| npm | [@bsv/gasp](https://www.npmjs.com/package/@bsv/gasp) | +| Version | 1.2.2 | +| Criticality | **Tier 3** — GASP (Graph Aware Sync Protocol) core; failure isolated to overlay sync | +| Reliability Level | **RL1** — 1 test file, coverage tooling present | +| Owner | @sirdeggen | +| Backup owner | — | ## Build -| Field | Value | -|-------|-------| + +| Field | Value | +| ------------- | ------------------------------------------------------ | | Build command | `tsc -b && tsconfig-to-dual-package tsconfig.cjs.json` | -| Build status | ✅ Passing (assumed — not yet verified in ts-stack CI) | -| Outputs | Dual ESM + CJS via tsconfig-to-dual-package | +| Build status | ✅ Passing (assumed — not yet verified in ts-stack CI) | +| Outputs | Dual ESM + CJS via tsconfig-to-dual-package | ## Tests -| Field | Value | -|-------|-------| -| Test command | `npm run build && jest` | -| Test files | 1 | + +| Field | Value | +| ---------------- | ---------------------------------- | +| Test command | `npm run build && jest` | +| Test files | 1 | | Coverage command | `npm run build && jest --coverage` | -| Coverage | Not yet captured as baseline | -| Known flaky | None identified | +| Coverage | Not yet captured as baseline | +| Known flaky | None identified | ## Lint -| Field | Value | -|-------|-------| -| Linter | Oxlint | + +| Field | Value | +| ------------ | ------------ | +| Linter | Oxlint | | Lint command | `oxlint src` | ## Dependencies -| Type | Count | Packages | -|------|-------|---------| -| Production | 1 | @bsv/sdk | + +| Type | Count | Packages | +| ---------- | ----- | -------- | +| Production | 1 | @bsv/sdk | ## Known Issues & Incidents + None recorded at migration time. ## Migration Gate Checklist (MBGA §13.3) + - [x] BASELINE.md captured - [ ] Conformance runner vectors passing - [ ] Contract tests green diff --git a/packages/overlays/gasp-core/README.md b/packages/overlays/gasp-core/README.md index d0f25d45e..f526312ea 100644 --- a/packages/overlays/gasp-core/README.md +++ b/packages/overlays/gasp-core/README.md @@ -1,52 +1,53 @@ # GASP — Graph Aware Sync Protocol -The **Graph Aware Sync Protocol** (GASP) is a powerful protocol for synchronizing BSV transaction data between two or more parties. Unlike simplistic “UTXO list” or “transaction pushing” mechanisms, GASP allows each participant to incrementally build a *graph* of transaction ancestors and descendants. This ensures: +The **Graph Aware Sync Protocol** (GASP) is a powerful protocol for synchronizing BSV transaction data between two or more parties. Unlike simplistic “UTXO list” or “transaction pushing” mechanisms, GASP allows each participant to incrementally build a _graph_ of transaction ancestors and descendants. This ensures: -1. **Legitimacy**: Parties only finalize data they can validate, using Merkle proofs, script evaluation, and the other rules of SPV. -2. **Completeness**: Recursively, each party pulls in the inputs needed to prove correctness—avoiding partial or “broken” transaction data. -3. **Efficiency**: Each participant only fetches and transmits data it *doesn’t* already have, minimizing bandwidth. +1. **Legitimacy**: Parties only finalize data they can validate, using Merkle proofs, script evaluation, and the other rules of SPV. +2. **Completeness**: Recursively, each party pulls in the inputs needed to prove correctness—avoiding partial or “broken” transaction data. +3. **Efficiency**: Each participant only fetches and transmits data it _doesn’t_ already have, minimizing bandwidth. 4. **Flexibility**: Custom storage, custom remote mechanisms, **unidirectional** sync, concurrency options, and more. ## Table of Contents -- [Key Features](#key-features) -- [How it Works](#how-it-works) -- [Installation](#installation) -- [Quick Start](#quick-start) - - [1. Implement the \`GASPStorage\` Interface](#1-implement-the-gaspstorage-interface) - - [2. Implement (or Obtain) a \`GASPRemote\`](#2-implement-or-obtain-a-gaspremote) - - [3. Initialize and Sync](#3-initialize-and-sync) -- [Examples](#examples) - - [Minimal Example](#minimal-example) - - [Advanced Example: \`sequential\` and Log Levels](#advanced-example-sequential-and-log-levels) - - [Unidirectional Pull-Only Sync](#unidirectional-pull-only-sync) - - [Dealing With “Deep” Transactions and Metadata](#dealing-with-deep-transactions-and-metadata) -- [Testing and Verification](#testing-and-verification) -- [Useful Links](#useful-links) -- [License](#license) +- [Key Features](#key-features) +- [How it Works](#how-it-works) +- [Installation](#installation) +- [Quick Start](#quick-start) + - [1. Implement the \`GASPStorage\` Interface](#1-implement-the-gaspstorage-interface) + - [2. Implement (or Obtain) a \`GASPRemote\`](#2-implement-or-obtain-a-gaspremote) + - [3. Initialize and Sync](#3-initialize-and-sync) +- [Examples](#examples) + - [Minimal Example](#minimal-example) + - [Advanced Example: \`sequential\` and Log Levels](#advanced-example-sequential-and-log-levels) + - [Unidirectional Pull-Only Sync](#unidirectional-pull-only-sync) + - [Dealing With “Deep” Transactions and Metadata](#dealing-with-deep-transactions-and-metadata) +- [Useful Links](#useful-links) +- [Development and Distribution](#development-and-distribution) +- [FAQ](#faq) +- [License](#license) --- ## Key Features -- **Recursive Sync**: Fetches only the needed transaction outputs and *recursively* fetches input data on demand. -- **Metadata Support**: Optionally exchange metadata (e.g., invoice data, descriptions, basket or topical membership, etc.) for each transaction or output. -- **Proof Anchoring**: Merkle proofs can be attached to each transaction, ensuring on-chain verifiability. -- **Unidirectional**: If desired, you can configure “pull only” mode—where you fetch data from a remote but never push your own. -- **Selective Concurrency**: Use fully parallel fetches (`Promise.all`) or sequential fetches (one at a time) to avoid potential DB locking. +- **Recursive Sync**: Fetches only the needed transaction outputs and _recursively_ fetches input data on demand. +- **Metadata Support**: Optionally exchange metadata (e.g., invoice data, descriptions, basket or topical membership, etc.) for each transaction or output. +- **Proof Anchoring**: Merkle proofs can be attached to each transaction, ensuring on-chain verifiability. +- **Unidirectional**: If desired, you can configure “pull only” mode—where you fetch data from a remote but never push your own. +- **Selective Concurrency**: Use fully parallel fetches (`Promise.all`) or sequential fetches (one at a time) to avoid potential DB locking. - **Flexible Integration**: The `GASPStorage` and `GASPRemote` interfaces let you integrate with your own storage logic or remote transport. --- ## How it Works -1. **Initial Request**: One peer initiates a request, including a timestamp for when the two parties last synchronized. -2. **Initial Response**: The other peer returns a set of UTXOs that it has observed since that timestamp, plus a “since” timestamp for a potential “reply.” -3. **Recursive Graph Building**: - - Each side requests the transaction data (optionally including metadata) for each unknown UTXO. - - Each newly-received transaction might contain additional unknown inputs, which triggers further fetches. -4. **Graph Finalization**: Once all required inputs are fetched, each peer finalizes the newly-validated transaction data into its own store. -5. **Optional “Reply”**: In a **bidirectional** scenario, the second peer then does the same, ensuring both end up with a consistent set of data. +1. **Initial Request**: One peer initiates a request, including a timestamp for when the two parties last synchronized. +2. **Initial Response**: The other peer returns a set of UTXOs that it has observed since that timestamp, plus a “since” timestamp for a potential “reply.” +3. **Recursive Graph Building**: + - Each side requests the transaction data (optionally including metadata) for each unknown UTXO. + - Each newly-received transaction might contain additional unknown inputs, which triggers further fetches. +4. **Graph Finalization**: Once all required inputs are fetched, each peer finalizes the newly-validated transaction data into its own store. +5. **Optional “Reply”**: In a **bidirectional** scenario, the second peer then does the same, ensuring both end up with a consistent set of data. If you set GASP to **unidirectional**, step 5 is skipped: your local node simply pulls data from the remote, but never sends data back. @@ -78,13 +79,27 @@ The **GASPStorage** interface is your local “database layer.” It controls ho import { GASPNode, GASPNodeResponse, GASPStorage } from '@bsv/gasp' export class MyCustomStorage implements GASPStorage { - async findKnownUTXOs(since: number) { /* return an array of unspent TXID-outputIndices since `since` timestamp */ } - async hydrateGASPNode(graphID: string, txid: string, outputIndex: number, metadata: boolean) { /* return the GASPNode with rawTx, proof, metadata, etc. */ } - async findNeededInputs(tx: GASPNode): Promise { /* optionally request more inputs if needed*/ } - async appendToGraph(tx: GASPNode, spentBy?: string) { /* store the node in some temporary graph structure*/ } - async validateGraphAnchor(graphID: string) { /* confirm the graph is anchored in the blockchain or otherwise valid*/ } - async discardGraph(graphID: string) { /* if invalid, discard it */ } - async finalizeGraph(graphID: string) { /* finalize the validated graph into local storage*/ } + async findKnownUTXOs(since: number) { + /* return an array of unspent TXID-outputIndices since `since` timestamp */ + } + async hydrateGASPNode(graphID: string, txid: string, outputIndex: number, metadata: boolean) { + /* return the GASPNode with rawTx, proof, metadata, etc. */ + } + async findNeededInputs(tx: GASPNode): Promise { + /* optionally request more inputs if needed*/ + } + async appendToGraph(tx: GASPNode, spentBy?: string) { + /* store the node in some temporary graph structure*/ + } + async validateGraphAnchor(graphID: string) { + /* confirm the graph is anchored in the blockchain or otherwise valid*/ + } + async discardGraph(graphID: string) { + /* if invalid, discard it */ + } + async finalizeGraph(graphID: string) { + /* finalize the validated graph into local storage*/ + } } ``` @@ -104,7 +119,12 @@ export class MyRemote implements GASPRemote { // Only needed if doing bidirectional sync // ... } - async requestNode(graphID: string, txid: string, outputIndex: number, metadata: boolean): Promise { + async requestNode( + graphID: string, + txid: string, + outputIndex: number, + metadata: boolean + ): Promise { // Request a node from the remote // ... } @@ -134,14 +154,15 @@ const gasp = new GASP( myRemote, /* lastInteraction= */ 0, /* logPrefix= */ '[GASP] ', - /* log= */ false, // legacy logging toggle + /* log= */ false, // legacy logging toggle /* unidirectional= */ false, // if true, we only fetch from the remote, never push data /* logLevel= */ LogLevel.INFO, - /* sequential= */ false // if true, tasks run one-at-a-time rather than in parallel + /* sequential= */ false // if true, tasks run one-at-a-time rather than in parallel ) // 4. Trigger the sync -gasp.sync() +gasp + .sync() .then(() => console.log('GASP sync complete!')) .catch(err => console.error('GASP sync error:', err)) ``` @@ -176,14 +197,14 @@ Sometimes, performing too many concurrent operations (e.g., writes to a database import { GASP, LogLevel } from '@bsv/gasp' const gasp = new GASP( - myStorage, // Implementation of GASPStorage - myRemote, // Implementation of GASPRemote - 0, // lastInteraction timestamp - '[GASP Demo] ', // logPrefix - false, // old boolean log toggle, for backwards-compat - false, // unidirectional? No, do full sync - LogLevel.DEBUG, // Use DEBUG or WARN/ERROR - true // sequential? If true, GASP will do tasks in sequence + myStorage, // Implementation of GASPStorage + myRemote, // Implementation of GASPRemote + 0, // lastInteraction timestamp + '[GASP Demo] ', // logPrefix + false, // old boolean log toggle, for backwards-compat + false, // unidirectional? No, do full sync + LogLevel.DEBUG, // Use DEBUG or WARN/ERROR + true // sequential? If true, GASP will do tasks in sequence ) await gasp.sync() ``` @@ -201,7 +222,7 @@ const gaspAlice = new GASP( 0, '[GASP-Alice] ', false, - true // unidirectional is set to true + true // unidirectional is set to true ) await gaspAlice.sync() @@ -234,27 +255,52 @@ Your remote peer’s `requestNode(...)` method will deliver these missing pieces ## Useful Links -- **Comprehensive Tests**: The [test suite](./src/__tests/GASP.test.ts) covers everything from version mismatches to recursion edge cases. -- **Real-World Integrations**: See the “OverlayGASPStorage” and “OverlayGASPRemote” classes (in the [Overlay Services](https://github.com/bitcoin-sv/overlay-services/tree/master/src/GASP) repo) for a real-world application. +- **Comprehensive Tests**: The [test suite](./src/__tests/GASP.test.ts) covers everything from version mismatches to recursion edge cases. +- **Real-World Integrations**: See + [`OverlayGASPStorage`](../overlay/src/GASP/OverlayGASPStorage.ts) and + [`OverlayGASPRemote`](../overlay/src/GASP/OverlayGASPRemote.ts) in this + workspace for production adapters. + +--- + +## Development and Distribution + +Run package work from the `ts-stack` repository root with Node.js 24.11 or +newer and pnpm 10: + +```bash +pnpm install +pnpm --filter @bsv/gasp format:check +pnpm --filter @bsv/gasp lint +pnpm --filter @bsv/gasp typecheck +pnpm --filter @bsv/gasp test:coverage +pnpm --filter @bsv/gasp pack:check +``` + +The coverage gate ratchets statements, branches, functions, and lines. +`pack:check` builds and installs the exact npm tarball in ESM and CommonJS +consumer projects, then verifies exports and conditional types. Publishing is +owned by the repository release workflow; local checks do not change versions +or publish. --- ## FAQ 1. **Does GASP handle conflicting transactions?** - GASP is *agnostic* about conflicts. It’s up to your `GASPStorage` implementation to decide how to handle double spends or conflicting states. + GASP is _agnostic_ about conflicts. It’s up to your `GASPStorage` implementation to decide how to handle double spends or conflicting states. -3. **How do I do only pure “SPV proof” validation?** +2. **How do I do only pure “SPV proof” validation?** GASP includes optional Merkle proofs via the `proof` field. If your `validateGraphAnchor(...)` checks them, you effectively get SPV-level validation. -4. **What about specialized metadata or policies?** - GASP was *built* for that. Use `txMetadata`, `outputMetadata`, or `inputs` to store and propagate any custom data. Your code can then gather additional inputs if needed. +3. **What about specialized metadata or policies?** + GASP was _built_ for that. Use `txMetadata`, `outputMetadata`, or `inputs` to store and propagate any custom data. Your code can then gather additional inputs if needed. -5. **What if a remote fails to provide data?** +4. **What if a remote fails to provide data?** GASP’s recursion stops. If you never receive inputs you request, you never finalize that transaction. This ensures consistent partial or full finalization. --- ## License -The license for the code in this repository is the Open BSV License. \ No newline at end of file +The license for the code in this repository is the Open BSV License. diff --git a/packages/overlays/gasp-core/jest.config.js b/packages/overlays/gasp-core/jest.config.js index aba1747e8..caa704338 100644 --- a/packages/overlays/gasp-core/jest.config.js +++ b/packages/overlays/gasp-core/jest.config.js @@ -2,9 +2,21 @@ export default { preset: 'ts-jest', testEnvironment: 'node', testPathIgnorePatterns: ['/node_modules/', '/dist/'], - globals: { - 'ts-jest': { - tsconfig: 'tsconfig.cjs.json', - }, + collectCoverageFrom: ['src/**/*.ts', '!src/**/__tests/**'], + coverageThreshold: { + global: { + branches: 80, + functions: 85, + lines: 85, + statements: 85 + } }, -}; \ No newline at end of file + transform: { + '^.+\\.tsx?$': [ + 'ts-jest', + { + tsconfig: 'tsconfig.cjs.json' + } + ] + } +} diff --git a/packages/overlays/gasp-core/mod.ts b/packages/overlays/gasp-core/mod.ts index a199bbb2c..355cc4535 100644 --- a/packages/overlays/gasp-core/mod.ts +++ b/packages/overlays/gasp-core/mod.ts @@ -1 +1 @@ -export * from './src/GASP.js'; \ No newline at end of file +export * from './src/GASP.js' diff --git a/packages/overlays/gasp-core/package.json b/packages/overlays/gasp-core/package.json index ccea3d439..f70f78d24 100644 --- a/packages/overlays/gasp-core/package.json +++ b/packages/overlays/gasp-core/package.json @@ -24,30 +24,43 @@ "types": "dist/types/mod.d.ts", "files": [ "dist", - "src", - "mod.ts", + "!dist/**/*.tsbuildinfo", + "README.md", "LICENSE.txt" ], "exports": { ".": { - "types": "./dist/types/mod.d.ts", - "import": "./dist/esm/mod.js", - "require": "./dist/cjs/mod.js" + "import": { + "types": "./dist/types/mod.d.ts", + "default": "./dist/esm/mod.js" + }, + "require": { + "types": "./dist/cjs/mod.d.ts", + "default": "./dist/cjs/mod.js" + } }, "./*.ts": { - "types": "./dist/types/src/*.d.ts", - "import": "./dist/esm/src/*.js", - "require": "./dist/cjs/src/*.js" + "import": { + "types": "./dist/types/src/*.d.ts", + "default": "./dist/esm/src/*.js" + }, + "require": { + "types": "./dist/cjs/src/*.d.ts", + "default": "./dist/cjs/src/*.js" + } } }, "scripts": { - "test": "npm run build && jest", - "test:watch": "npm run build && jest --watch", - "test:coverage": "npm run build && jest --coverage", - "lint": "oxlint src", + "test": "pnpm build && jest --watchman=false", + "test:watch": "pnpm build && jest --watch", + "test:coverage": "pnpm build && jest --coverage --watchman=false", + "format:check": "pnpm --workspace-root exec prettier --check \"packages/overlays/gasp-core/**/*.{js,json,md,ts}\"", + "lint": "oxlint mod.ts src jest.config.js --deny-warnings", + "pack:check": "pnpm build && node ../../../scripts/check-package-artifact.mjs . --exports GASP,GASPVersionMismatchError,LogLevel", + "typecheck": "tsc --project tsconfig.base.json --noEmit --incremental false", "build": "tsc -b && tsconfig-to-dual-package tsconfig.cjs.json", "dev": "tsc -b -w", - "prepublish": "npm run build", + "prepublishOnly": "pnpm build", "doc": "ts2md --inputFilename=mod.ts --outputFilename=API.md --filenameSubstring=API --firstHeadingLevel=1" }, "keywords": [ @@ -76,5 +89,15 @@ "@bsv/sdk": { "optional": false } + }, + "typesVersions": { + "*": { + "*.ts": [ + "dist/types/src/*.d.ts" + ], + "*": [ + "dist/types/mod.d.ts" + ] + } } } diff --git a/packages/overlays/gasp-core/src/GASP.ts b/packages/overlays/gasp-core/src/GASP.ts index 4bc50c262..5582ba89b 100644 --- a/packages/overlays/gasp-core/src/GASP.ts +++ b/packages/overlays/gasp-core/src/GASP.ts @@ -80,7 +80,7 @@ export type GASPRawTransactionRequest = { * wrapping. Existing GASP behavior remains unchanged. */ export type GASPRawTransactionResponse = { - transactions: Array<{ txid: string, rawTx: string }> + transactions: Array<{ txid: string; rawTx: string }> missing?: string[] } @@ -103,19 +103,24 @@ export interface GASPStorage { * @param metadata Whether transaction and output metadata should be returned. * @returns The hydrated GASP node, with or without metadata. */ - hydrateGASPNode: (graphID: string, txid: string, outputIndex: number, metadata: boolean) => Promise + hydrateGASPNode: ( + graphID: string, + txid: string, + outputIndex: number, + metadata: boolean + ) => Promise /** * For a given node, returns the inputs needed to complete the graph, including whether updated metadata is requested for those inputs. * @param tx The node for which needed inputs should be found. * @returns A promise for a mapping of requested input transactions and whether metadata should be provided for each. - */ + */ findNeededInputs: (tx: GASPNode) => Promise /** * Appends a new node to a temporary graph. * @param tx The node to append to this graph. * @param spentBy Unless this is the same node identified by the graph ID, denotes the TXID and input index for the node which spent this one, in 36-byte format. * @throws If the node cannot be appended to the graph, either because the graph ID is for a graph the recipient does not want or because the graph has grown to be too large before being finalized. - */ + */ appendToGraph: (tx: GASPNode, spentBy?: string) => Promise /** * Checks whether the given graph, in its current state, makes reference only to transactions that are proven in the blockchain, or already known by the recipient to be valid. @@ -144,7 +149,12 @@ export interface GASPRemote { /** Given an outgoing initial response, obtain the reply from the foreign instance. */ getInitialReply: (response: GASPInitialResponse) => Promise /** Given an outgoing txid, outputIndex and optional metadata, request the associated GASP node from the foreign instane. */ - requestNode: (graphID: string, txid: string, outputIndex: number, metadata: boolean) => Promise + requestNode: ( + graphID: string, + txid: string, + outputIndex: number, + metadata: boolean + ) => Promise /** Given an outgoing node, send the node to the foreign instance and determine which additional inputs (if any) they request in response. */ submitNode: (node: GASPNode) => Promise } @@ -230,11 +240,13 @@ export class GASP implements GASPRemote { this.sequential = sequential this.validateTimestamp(this.lastInteraction) - this.logData(`GASP initialized with version: ${this.version}, lastInteraction: ${this.lastInteraction}, unidirectional: ${this.unidirectional}, logLevel: ${LogLevel[this.logLevel]}, sequential: ${this.sequential}`) + this.logData( + `GASP initialized with version: ${this.version}, lastInteraction: ${this.lastInteraction}, unidirectional: ${this.unidirectional}, logLevel: ${LogLevel[this.logLevel]}, sequential: ${this.sequential}` + ) } /** - * Helper method to execute callbacks either in parallel or sequentially, + * Helper method to execute callbacks either in parallel or sequentially, * depending on the `sequential` flag. */ private async runConcurrently( @@ -290,7 +302,12 @@ export class GASP implements GASPRemote { } private validateTimestamp(timestamp: number): void { - if (typeof timestamp !== 'number' || Number.isNaN(timestamp) || timestamp < 0 || !Number.isInteger(timestamp)) { + if ( + typeof timestamp !== 'number' || + Number.isNaN(timestamp) || + timestamp < 0 || + !Number.isInteger(timestamp) + ) { throw new Error('Invalid timestamp format') } } @@ -312,13 +329,15 @@ export class GASP implements GASPRemote { * @param outpoint The 36-byte structure. * @returns An object containing the transaction ID and output index. */ - private deconstruct36ByteStructure(outpoint: string): { txid: string, outputIndex: number } { + private deconstruct36ByteStructure(outpoint: string): { txid: string; outputIndex: number } { const [txid, index] = outpoint.split('.') const result = { txid, outputIndex: Number.parseInt(index, 10) } - this.debugLog(`Deconstructed 36-byte structure: ${outpoint} into txid: ${txid}, outputIndex: ${result.outputIndex}`) + this.debugLog( + `Deconstructed 36-byte structure: ${outpoint} into txid: ${txid}, outputIndex: ${result.outputIndex}` + ) return result } @@ -367,37 +386,39 @@ export class GASP implements GASPRemote { ingestQueue.push(utxo) } } - this.infoLog(`Processing page with ${initialResponse.UTXOList.length} UTXOs (since: ${initialResponse.since})`) + this.infoLog( + `Processing page with ${initialResponse.UTXOList.length} UTXOs (since: ${initialResponse.since})` + ) - await this.runConcurrently( - ingestQueue, - async UTXO => { - try { - this.infoLog(`Requesting node for UTXO: ${JSON.stringify(UTXO)}`) - const outpoint = this.compute36ByteStructure(UTXO.txid, UTXO.outputIndex) - const resolvedNode = await this.remote.requestNode( - outpoint, - UTXO.txid, - UTXO.outputIndex, - true - ) - this.debugLog(`Received unspent graph node from remote: ${JSON.stringify(resolvedNode)}`) - await this.processIncomingNode(resolvedNode) - await this.completeGraph(resolvedNode.graphID) - sharedOutpoints.add(outpoint) - } catch (e) { - this.warnLog(`Error with incoming UTXO ${UTXO.txid}.${UTXO.outputIndex}: ${(e as Error).message}`) - } + await this.runConcurrently(ingestQueue, async UTXO => { + try { + this.infoLog(`Requesting node for UTXO: ${JSON.stringify(UTXO)}`) + const outpoint = this.compute36ByteStructure(UTXO.txid, UTXO.outputIndex) + const resolvedNode = await this.remote.requestNode( + outpoint, + UTXO.txid, + UTXO.outputIndex, + true + ) + this.debugLog(`Received unspent graph node from remote: ${JSON.stringify(resolvedNode)}`) + await this.processIncomingNode(resolvedNode) + await this.completeGraph(resolvedNode.graphID) + sharedOutpoints.add(outpoint) + } catch (e) { + this.warnLog( + `Error with incoming UTXO ${UTXO.txid}.${UTXO.outputIndex}: ${(e as Error).message}` + ) } - ) + }) } while (limit && initialResponse.UTXOList.length >= limit) // 2. Only do the “reply” half if unidirectional is disabled if (!this.unidirectional) { await this.runConcurrently( - localUTXOs.filter(utxo => - utxo.score >= initialResponse.since && - !sharedOutpoints.has(this.compute36ByteStructure(utxo.txid, utxo.outputIndex)) + localUTXOs.filter( + utxo => + utxo.score >= initialResponse.since && + !sharedOutpoints.has(this.compute36ByteStructure(utxo.txid, utxo.outputIndex)) ), async UTXO => { try { @@ -411,9 +432,12 @@ export class GASP implements GASPRemote { this.debugLog(`Sending unspent graph node for remote: ${JSON.stringify(outgoingNode)}`) await this.processOutgoingNode(outgoingNode) } catch (e) { - this.warnLog(`Error with outgoing UTXO ${UTXO.txid}.${UTXO.outputIndex}: ${(e as Error).message}`) + this.warnLog( + `Error with outgoing UTXO ${UTXO.txid}.${UTXO.outputIndex}: ${(e as Error).message}` + ) } - }) + } + ) } this.infoLog('Sync completed!') } @@ -480,8 +504,15 @@ export class GASP implements GASPRemote { /** * Provides a requested node to a foreign instance who requested it. */ - async requestNode(graphID: string, txid: string, outputIndex: number, metadata: boolean): Promise { - this.infoLog(`Remote is requesting node with graphID: ${graphID}, txid: ${txid}, outputIndex: ${outputIndex}, metadata: ${metadata}`) + async requestNode( + graphID: string, + txid: string, + outputIndex: number, + metadata: boolean + ): Promise { + this.infoLog( + `Remote is requesting node with graphID: ${graphID}, txid: ${txid}, outputIndex: ${outputIndex}, metadata: ${metadata}` + ) const node = await this.storage.hydrateGASPNode(graphID, txid, outputIndex, metadata) this.debugLog(`Returning node: ${JSON.stringify(node)}`) return node @@ -514,7 +545,9 @@ export class GASP implements GASPRemote { await this.storage.finalizeGraph(graphID) this.infoLog(`Graph finalized for node: ${graphID}`) } catch (e) { - this.warnLog(`Error validating graph: ${(e as Error).message}. Discarding graph for node: ${graphID}`) + this.warnLog( + `Error validating graph: ${(e as Error).message}. Discarding graph for node: ${graphID}` + ) await this.storage.discardGraph(graphID) } } @@ -524,7 +557,11 @@ export class GASP implements GASPRemote { * @param node The incoming GASP node. * @param spentBy The 36-byte structure of the node that spent this one, if applicable. */ - private async processIncomingNode(node: GASPNode, spentBy?: string, seenNodes = new Set()): Promise { + private async processIncomingNode( + node: GASPNode, + spentBy?: string, + seenNodes = new Set() + ): Promise { const nodeId = `${this.computeTXID(node.rawTx)}.${node.outputIndex}` this.debugLog(`Processing incoming node: ${JSON.stringify(node)}, spentBy: ${spentBy}`) if (seenNodes.has(nodeId)) { @@ -540,7 +577,9 @@ export class GASP implements GASPRemote { Object.entries(neededInputs.requestedInputs), async ([outpoint, { metadata }]) => { const { txid, outputIndex } = this.deconstruct36ByteStructure(outpoint) - this.infoLog(`Requesting new node for txid: ${txid}, outputIndex: ${outputIndex}, metadata: ${metadata}`) + this.infoLog( + `Requesting new node for txid: ${txid}, outputIndex: ${outputIndex}, metadata: ${metadata}` + ) const newNode = await this.remote.requestNode(node.graphID, txid, outputIndex, metadata) this.debugLog(`Received new node: ${JSON.stringify(newNode)}`) await this.processIncomingNode( @@ -580,8 +619,15 @@ export class GASP implements GASPRemote { async ([outpoint, { metadata }]) => { const { txid, outputIndex } = this.deconstruct36ByteStructure(outpoint) try { - this.infoLog(`Hydrating node for txid: ${txid}, outputIndex: ${outputIndex}, metadata: ${metadata}`) - const hydratedNode = await this.storage.hydrateGASPNode(node.graphID, txid, outputIndex, metadata) + this.infoLog( + `Hydrating node for txid: ${txid}, outputIndex: ${outputIndex}, metadata: ${metadata}` + ) + const hydratedNode = await this.storage.hydrateGASPNode( + node.graphID, + txid, + outputIndex, + metadata + ) this.debugLog(`Hydrated node: ${JSON.stringify(hydratedNode)}`) await this.processOutgoingNode(hydratedNode, seenNodes) } catch (e) { diff --git a/packages/overlays/gasp-core/src/__tests/GASP.test.ts b/packages/overlays/gasp-core/src/__tests/GASP.test.ts index 11490ad1e..2dad87694 100644 --- a/packages/overlays/gasp-core/src/__tests/GASP.test.ts +++ b/packages/overlays/gasp-core/src/__tests/GASP.test.ts @@ -2,975 +2,1084 @@ jest.mock('@bsv/sdk', () => { // Simple hash function to generate a consistent "txid" from a string const mockHash = (input: string): string => { - let hash = 0; + let hash = 0 for (let i = 0; i < input.length; i++) { - const char = input.codePointAt(i) ?? 0; - hash = ((hash << 5) - hash) + char; - hash = hash & hash; // Convert to 32-bit integer + const char = input.codePointAt(i) ?? 0 + hash = (hash << 5) - hash + char + hash = hash & hash // Convert to 32-bit integer } // Convert to 32-byte hex string (64 chars), padded with zeros - return hash.toString(16).padStart(64, '0'); - }; + return hash.toString(16).padStart(64, '0') + } // Mock Transaction class class MockTransaction { - private rawTx: string; + private rawTx: string constructor(rawTx: string) { - this.rawTx = rawTx; + this.rawTx = rawTx } // Mock the id method to return a hex "txid" based on rawTx id(format: 'hex' | 'binary' = 'hex'): string { if (format !== 'hex') { - throw new Error('Only hex format is mocked'); + throw new Error('Only hex format is mocked') } // Use the rawTx value to generate a consistent txid - return mockHash(this.rawTx); + return mockHash(this.rawTx) } // Static method to create a Transaction from hex static fromHex(hex: string): MockTransaction { - return new MockTransaction(hex); + return new MockTransaction(hex) } } return { - Transaction: MockTransaction, - }; -}); + Transaction: MockTransaction + } +}) -import { GASP, GASPInitialRequest, GASPNode, GASPNodeResponse, GASPStorage, GASPRemote, GASPInitialReply, GASPInitialResponse, GASPOutput } from '../GASP' +import { + GASP, + GASPInitialRequest, + GASPNode, + GASPNodeResponse, + GASPStorage, + GASPRemote, + GASPInitialReply, + GASPInitialResponse, + GASPOutput, + LogLevel +} from '../GASP' type Graph = { - graphID: string, - time: number, - txid: string, - outputIndex: number, - rawTx: string, - inputs: Record + graphID: string + time: number + txid: string + outputIndex: number + rawTx: string + inputs: Record } // Used to construct a non-functional remote that will be replaced after being constructed. // Useful when directly using another GASP instance as a remote. const throwawayRemote: GASPRemote = { - getInitialResponse: function (request: GASPInitialRequest): Promise { - throw new Error('Function not implemented.') - }, - getInitialReply: function (response: GASPInitialResponse): Promise { - throw new Error('Function not implemented.') - }, - requestNode: function (graphID: string, txid: string, outputIndex: number, metadata: boolean): Promise { - throw new Error('Function not implemented.') - }, - submitNode: function (node: GASPNode): Promise { - throw new Error('Function not implemented.') - } + getInitialResponse: function (_request: GASPInitialRequest): Promise { + throw new Error('Function not implemented.') + }, + getInitialReply: function (_response: GASPInitialResponse): Promise { + throw new Error('Function not implemented.') + }, + requestNode: function ( + _graphID: string, + _txid: string, + _outputIndex: number, + _metadata: boolean + ): Promise { + throw new Error('Function not implemented.') + }, + submitNode: function (_node: GASPNode): Promise { + throw new Error('Function not implemented.') + } } class MockStorage implements GASPStorage { - knownStore: Array - tempGraphStore: Record - updateCallback: Function - logPrefix: string - log: boolean - - constructor(knownStore: Array = [], tempGraphStore: Record = {}, updateCallback: Function = () => { }, logPrefix = '[Storage] ', log = false) { - this.knownStore = knownStore - this.tempGraphStore = tempGraphStore - this.updateCallback = updateCallback - this.logPrefix = logPrefix - this.log = log - - // Initialize methods with default implementations - this.findKnownUTXOs = jest.fn(this.findKnownUTXOs.bind(this)) - this.hydrateGASPNode = jest.fn(this.hydrateGASPNode.bind(this)) - this.findNeededInputs = jest.fn(this.findNeededInputs.bind(this)) - this.appendToGraph = jest.fn(this.appendToGraph.bind(this)) - this.validateGraphAnchor = jest.fn(this.validateGraphAnchor.bind(this)) - this.discardGraph = jest.fn(this.discardGraph.bind(this)) - this.finalizeGraph = jest.fn(this.finalizeGraph.bind(this)) - } + knownStore: Array + tempGraphStore: Record + updateCallback: Function + logPrefix: string + log: boolean + + constructor( + knownStore: Array = [], + tempGraphStore: Record = {}, + updateCallback: Function = () => {}, + logPrefix = '[Storage] ', + log = false + ) { + this.knownStore = knownStore + this.tempGraphStore = tempGraphStore + this.updateCallback = updateCallback + this.logPrefix = logPrefix + this.log = log + + // Initialize methods with default implementations + this.findKnownUTXOs = jest.fn(this.findKnownUTXOs.bind(this)) + this.hydrateGASPNode = jest.fn(this.hydrateGASPNode.bind(this)) + this.findNeededInputs = jest.fn(this.findNeededInputs.bind(this)) + this.appendToGraph = jest.fn(this.appendToGraph.bind(this)) + this.validateGraphAnchor = jest.fn(this.validateGraphAnchor.bind(this)) + this.discardGraph = jest.fn(this.discardGraph.bind(this)) + this.finalizeGraph = jest.fn(this.finalizeGraph.bind(this)) + } - private logData(...data: any): void { - if (this.log) { - console.log(this.logPrefix, ...data) - } + private logData(...data: any): void { + if (this.log) { + console.log(this.logPrefix, ...data) } + } - async findKnownUTXOs(since: number, limit?: number): Promise { - const utxos = this.knownStore - .filter(x => !x.time || x.time > since) // Include UTXOs with no timestamp or timestamps greater than 'since' - .sort((a, b) => (a.time || 0) - (b.time || 0)) // Sort by time ascending - .map(x => ({ txid: x.txid, outputIndex: x.outputIndex, score: x.time })) - this.logData('findKnownUTXOs', since, utxos) - return limit ? utxos.slice(0, limit) : utxos - } + async findKnownUTXOs(since: number, limit?: number): Promise { + const utxos = this.knownStore + .filter(x => !x.time || x.time > since) // Include UTXOs with no timestamp or timestamps greater than 'since' + .sort((a, b) => (a.time || 0) - (b.time || 0)) // Sort by time ascending + .map(x => ({ txid: x.txid, outputIndex: x.outputIndex, score: x.time })) + this.logData('findKnownUTXOs', since, utxos) + return limit ? utxos.slice(0, limit) : utxos + } - async hydrateGASPNode(graphID: string, txid: string, outputIndex: number, metadata: boolean): Promise { - const found = this.knownStore.find(x => x.txid === txid && x.outputIndex === outputIndex) - if (!found) { - throw new Error('Not found') - } - this.logData('hydrateGASPNode', graphID, txid, outputIndex, metadata, found) - return { - graphID, - rawTx: found.rawTx, - outputIndex: found.outputIndex, - proof: 'mock_proof', // Mock proof - txMetadata: metadata ? 'mock_tx_metadata' : undefined, - outputMetadata: metadata ? 'mock_output_metadata' : undefined, - inputs: metadata ? { 'mock_input': { hash: 'mock_hash' } } : undefined - } + async hydrateGASPNode( + graphID: string, + txid: string, + outputIndex: number, + metadata: boolean + ): Promise { + const found = this.knownStore.find(x => x.txid === txid && x.outputIndex === outputIndex) + if (!found) { + throw new Error('Not found') } - - async findNeededInputs(tx: GASPNode): Promise { - this.logData('findNeededInputs', tx) - // For testing, assume no additional inputs are needed, unless specified - if (tx.graphID.includes('recursive')) { - return { - requestedInputs: { - 'recursive_txid.1': { metadata: true } - } - } - } - return + this.logData('hydrateGASPNode', graphID, txid, outputIndex, metadata, found) + return { + graphID, + rawTx: found.rawTx, + outputIndex: found.outputIndex, + proof: 'mock_proof', // Mock proof + txMetadata: metadata ? 'mock_tx_metadata' : undefined, + outputMetadata: metadata ? 'mock_output_metadata' : undefined, + inputs: metadata ? { mock_input: { hash: 'mock_hash' } } : undefined } + } - async appendToGraph(tx: GASPNode, spentBy?: string | undefined): Promise { - this.logData('appendToGraph', tx, spentBy) - this.tempGraphStore[tx.graphID] = { - ...tx, - time: Date.now(), - txid: tx.graphID.split('.')[0], - inputs: {} + async findNeededInputs(tx: GASPNode): Promise { + this.logData('findNeededInputs', tx) + // For testing, assume no additional inputs are needed, unless specified + if (tx.graphID.includes('recursive')) { + return { + requestedInputs: { + 'recursive_txid.1': { metadata: true } } + } } + return + } - async validateGraphAnchor(graphID: string): Promise { - this.logData('validateGraphAnchor', graphID) - // Allow validation to pass + async appendToGraph(tx: GASPNode, spentBy?: string | undefined): Promise { + this.logData('appendToGraph', tx, spentBy) + this.tempGraphStore[tx.graphID] = { + ...tx, + time: Date.now(), + txid: tx.graphID.split('.')[0], + inputs: {} } + } - async discardGraph(graphID: string): Promise { - this.logData('discardGraph', graphID) - delete this.tempGraphStore[graphID] - } + async validateGraphAnchor(graphID: string): Promise { + this.logData('validateGraphAnchor', graphID) + // Allow validation to pass + } - async finalizeGraph(graphID: string): Promise { - const tempGraph = this.tempGraphStore[graphID] - if (tempGraph) { - this.logData('finalizeGraph', graphID, tempGraph) - // Check if UTXO already exists to prevent duplicates - const exists = this.knownStore.some(k => - k.txid === tempGraph.txid && k.outputIndex === tempGraph.outputIndex - ) - if (!exists) { - this.knownStore.push(tempGraph) - } - this.updateCallback() - delete this.tempGraphStore[graphID] - } else { - this.logData('no graph to finalize', graphID, tempGraph) - } + async discardGraph(graphID: string): Promise { + this.logData('discardGraph', graphID) + delete this.tempGraphStore[graphID] + } + + async finalizeGraph(graphID: string): Promise { + const tempGraph = this.tempGraphStore[graphID] + if (tempGraph) { + this.logData('finalizeGraph', graphID, tempGraph) + // Check if UTXO already exists to prevent duplicates + const exists = this.knownStore.some( + k => k.txid === tempGraph.txid && k.outputIndex === tempGraph.outputIndex + ) + if (!exists) { + this.knownStore.push(tempGraph) + } + this.updateCallback() + delete this.tempGraphStore[graphID] + } else { + this.logData('no graph to finalize', graphID, tempGraph) } + } - - // Mock topic property for testing - topic: string = 'test-topic' + // Mock topic property for testing + topic: string = 'test-topic' } const mockUTXO = { - graphID: 'mock_sender1_txid1.0', - rawTx: 'mock_sender1_rawtx1', - outputIndex: 0, - time: 111, - txid: 'mock_sender1_txid1', - inputs: {} + graphID: 'mock_sender1_txid1.0', + rawTx: 'mock_sender1_rawtx1', + outputIndex: 0, + time: 111, + txid: 'mock_sender1_txid1', + inputs: {} } const mockInputNode = { - graphID: 'mock_sender1_txid1.0', - rawTx: 'deadbeef01010101', - outputIndex: 0, - time: 222, - txid: 'mock_sender1_txid2', - inputs: {} + graphID: 'mock_sender1_txid1.0', + rawTx: 'deadbeef01010101', + outputIndex: 0, + time: 222, + txid: 'mock_sender1_txid2', + inputs: {} } const mockUTXOWithInput = { - ...mockUTXO, - inputs: { - 'mock_sender1_txid2.0': mockInputNode - } + ...mockUTXO, + inputs: { + 'mock_sender1_txid2.0': mockInputNode + } } // Helper to compare UTXOs by txid and outputIndex only const compareUTXOs = (utxos1: GASPOutput[], utxos2: GASPOutput[]) => { - const normalized1 = utxos1.map(u => ({ txid: u.txid, outputIndex: u.outputIndex })) - const normalized2 = utxos2.map(u => ({ txid: u.txid, outputIndex: u.outputIndex })) - return expect(normalized1).toEqual(normalized2) + const normalized1 = utxos1.map(u => ({ txid: u.txid, outputIndex: u.outputIndex })) + const normalized2 = utxos2.map(u => ({ txid: u.txid, outputIndex: u.outputIndex })) + return expect(normalized1).toEqual(normalized2) } describe('GASP', () => { - afterEach(() => { - jest.resetAllMocks() - }) - it('Fails to sync if versions are wrong', async () => { - const originalError = console.error - console.error = jest.fn() - const storage1 = new MockStorage() - const storage2 = new MockStorage() - const gasp1 = new GASP(storage1, throwawayRemote, 0, '[GASP #1] ') - const gasp2 = new GASP(storage2, gasp1, 0, '[GASP #2] ') - gasp1.remote = gasp2 - gasp1.version = 2 - await expect(gasp1.sync('test-host')).rejects.toThrow(new Error('GASP version mismatch. Current version: 1, foreign version: 2')) - expect(console.error).toHaveBeenCalledWith('[GASP #2] ', '[ERROR]', 'GASP version mismatch error: GASP version mismatch. Current version: 1, foreign version: 2') - console.error = originalError - }) - it('Synchronizes a single UTXO from Alice to Bob', async () => { - const storage1 = new MockStorage([mockUTXO]) - const storage2 = new MockStorage() - const gasp1 = new GASP(storage1, throwawayRemote, 0, '[GASP #1] ') - const gasp2 = new GASP(storage2, gasp1, 0, '[GASP #2] ') - gasp1.remote = gasp2 - await gasp1.sync('test-host') - expect((await storage2.findKnownUTXOs(0)).length).toBe(1) - compareUTXOs(await storage2.findKnownUTXOs(0), await storage1.findKnownUTXOs(0)) - }) - it('Synchronizes a single UTXO from Bob to Alice', async () => { - const storage1 = new MockStorage() - const storage2 = new MockStorage([mockUTXO]) - const gasp1 = new GASP(storage1, throwawayRemote, 0, '[GASP #1] ') - const gasp2 = new GASP(storage2, gasp1, 0, '[GASP #2] ') - gasp1.remote = gasp2 - await gasp1.sync('test-host') - expect((await storage1.findKnownUTXOs(0)).length).toBe(1) - compareUTXOs(await storage1.findKnownUTXOs(0), await storage2.findKnownUTXOs(0)) - }) - it('Discards graphs that do not validate from Alice to Bob', async () => { - const storage1 = new MockStorage([mockUTXO]) - const storage2 = new MockStorage() - storage2.validateGraphAnchor = jest.fn().mockImplementation((graphID: string) => { - throw new Error('Invalid graph anchor.') - }) - const gasp1 = new GASP(storage1, throwawayRemote, 0, '[GASP #1] ') - const gasp2 = new GASP(storage2, gasp1, 0, '[GASP #2] ') - gasp1.remote = gasp2 - await gasp1.sync('test-host') - expect((await storage2.findKnownUTXOs(0)).length).toBe(0) - expect(storage2.discardGraph).toHaveBeenCalledWith('mock_sender1_txid1.0') + afterEach(() => { + jest.resetAllMocks() + }) + it('supports sequential execution and fully quiet logging', async () => { + const storage = new MockStorage() + const remote: GASPRemote = { + ...throwawayRemote, + getInitialResponse: jest.fn().mockResolvedValue({ since: 0, UTXOList: [] }) + } + const info = jest.spyOn(console, 'info').mockImplementation(() => {}) + const debug = jest.spyOn(console, 'debug').mockImplementation(() => {}) + const gasp = new GASP(storage, remote, 0, '[quiet] ', true, false, 0 as LogLevel, true) + + await gasp.sync('test-host') + + expect(remote.getInitialResponse).toHaveBeenCalledWith({ version: 1, since: 0 }) + expect(info).not.toHaveBeenCalled() + expect(debug).not.toHaveBeenCalled() + info.mockRestore() + debug.mockRestore() + }) + + it('Fails to sync if versions are wrong', async () => { + const originalError = console.error + console.error = jest.fn() + const storage1 = new MockStorage() + const storage2 = new MockStorage() + const gasp1 = new GASP(storage1, throwawayRemote, 0, '[GASP #1] ') + const gasp2 = new GASP(storage2, gasp1, 0, '[GASP #2] ') + gasp1.remote = gasp2 + gasp1.version = 2 + await expect(gasp1.sync('test-host')).rejects.toThrow( + new Error('GASP version mismatch. Current version: 1, foreign version: 2') + ) + expect(console.error).toHaveBeenCalledWith( + '[GASP #2] ', + '[ERROR]', + 'GASP version mismatch error: GASP version mismatch. Current version: 1, foreign version: 2' + ) + console.error = originalError + }) + it('Synchronizes a single UTXO from Alice to Bob', async () => { + const storage1 = new MockStorage([mockUTXO]) + const storage2 = new MockStorage() + const gasp1 = new GASP(storage1, throwawayRemote, 0, '[GASP #1] ') + const gasp2 = new GASP(storage2, gasp1, 0, '[GASP #2] ') + gasp1.remote = gasp2 + await gasp1.sync('test-host') + expect((await storage2.findKnownUTXOs(0)).length).toBe(1) + compareUTXOs(await storage2.findKnownUTXOs(0), await storage1.findKnownUTXOs(0)) + }) + it('Synchronizes a single UTXO from Bob to Alice', async () => { + const storage1 = new MockStorage() + const storage2 = new MockStorage([mockUTXO]) + const gasp1 = new GASP(storage1, throwawayRemote, 0, '[GASP #1] ') + const gasp2 = new GASP(storage2, gasp1, 0, '[GASP #2] ') + gasp1.remote = gasp2 + await gasp1.sync('test-host') + expect((await storage1.findKnownUTXOs(0)).length).toBe(1) + compareUTXOs(await storage1.findKnownUTXOs(0), await storage2.findKnownUTXOs(0)) + }) + it('Discards graphs that do not validate from Alice to Bob', async () => { + const storage1 = new MockStorage([mockUTXO]) + const storage2 = new MockStorage() + storage2.validateGraphAnchor = jest.fn().mockImplementation((_graphID: string) => { + throw new Error('Invalid graph anchor.') }) - it('Discards graphs that do not validate from Bob to Alice', async () => { - const storage1 = new MockStorage() - const storage2 = new MockStorage([mockUTXO]) - storage1.validateGraphAnchor = jest.fn().mockImplementation((graphID: string) => { - throw new Error('Invalid graph anchor.') - }) - const gasp1 = new GASP(storage1, throwawayRemote, 0, '[GASP #1] ') - const gasp2 = new GASP(storage2, gasp1, 0, '[GASP #2] ') - gasp1.remote = gasp2 - await gasp1.sync('test-host') - expect((await storage1.findKnownUTXOs(0)).length).toBe(0) - expect(storage1.discardGraph).toHaveBeenCalledWith('mock_sender1_txid1.0') + const gasp1 = new GASP(storage1, throwawayRemote, 0, '[GASP #1] ') + const gasp2 = new GASP(storage2, gasp1, 0, '[GASP #2] ') + gasp1.remote = gasp2 + await gasp1.sync('test-host') + expect((await storage2.findKnownUTXOs(0)).length).toBe(0) + expect(storage2.discardGraph).toHaveBeenCalledWith('mock_sender1_txid1.0') + }) + it('Discards graphs that do not validate from Bob to Alice', async () => { + const storage1 = new MockStorage() + const storage2 = new MockStorage([mockUTXO]) + storage1.validateGraphAnchor = jest.fn().mockImplementation((_graphID: string) => { + throw new Error('Invalid graph anchor.') }) - it('Synchronizes a deep UTXO from Bob to Alice', async () => { - const storage1 = new MockStorage() - storage1.findNeededInputs = jest.fn().mockImplementationOnce(async (n: GASPNode): Promise => { - return { - requestedInputs: { - 'mock_sender1_txid2.0': { - metadata: true - } - } + const gasp1 = new GASP(storage1, throwawayRemote, 0, '[GASP #1] ') + const gasp2 = new GASP(storage2, gasp1, 0, '[GASP #2] ') + gasp1.remote = gasp2 + await gasp1.sync('test-host') + expect((await storage1.findKnownUTXOs(0)).length).toBe(0) + expect(storage1.discardGraph).toHaveBeenCalledWith('mock_sender1_txid1.0') + }) + it('Synchronizes a deep UTXO from Bob to Alice', async () => { + const storage1 = new MockStorage() + storage1.findNeededInputs = jest + .fn() + .mockImplementationOnce(async (_n: GASPNode): Promise => { + return { + requestedInputs: { + 'mock_sender1_txid2.0': { + metadata: true } - }) - const storage2 = new MockStorage([mockUTXOWithInput]) - storage2.hydrateGASPNode = jest.fn().mockReturnValueOnce(mockUTXO).mockReturnValueOnce(mockInputNode) - const gasp1 = new GASP(storage1, throwawayRemote, 0, '[GASP #1] ') - const gasp2 = new GASP(storage2, gasp1, 0, '[GASP #2] ') - gasp1.remote = gasp2 - await gasp1.sync('test-host') - expect((await storage1.findKnownUTXOs(0)).length).toBe(1) - compareUTXOs(await storage1.findKnownUTXOs(0), await storage2.findKnownUTXOs(0)) - }) - it('Synchronizes a deep UTXO from Alice to Bob', async () => { - const storage2 = new MockStorage() - storage2.findNeededInputs = jest.fn().mockImplementationOnce(async (n: GASPNode): Promise => { - return { - requestedInputs: { - 'mock_sender1_txid2.0': { - metadata: true - } - } + } + } + }) + const storage2 = new MockStorage([mockUTXOWithInput]) + storage2.hydrateGASPNode = jest + .fn() + .mockReturnValueOnce(mockUTXO) + .mockReturnValueOnce(mockInputNode) + const gasp1 = new GASP(storage1, throwawayRemote, 0, '[GASP #1] ') + const gasp2 = new GASP(storage2, gasp1, 0, '[GASP #2] ') + gasp1.remote = gasp2 + await gasp1.sync('test-host') + expect((await storage1.findKnownUTXOs(0)).length).toBe(1) + compareUTXOs(await storage1.findKnownUTXOs(0), await storage2.findKnownUTXOs(0)) + }) + it('Synchronizes a deep UTXO from Alice to Bob', async () => { + const storage2 = new MockStorage() + storage2.findNeededInputs = jest + .fn() + .mockImplementationOnce(async (_n: GASPNode): Promise => { + return { + requestedInputs: { + 'mock_sender1_txid2.0': { + metadata: true } - }) - const storage1 = new MockStorage([mockUTXOWithInput]) - storage1.hydrateGASPNode = jest.fn().mockReturnValueOnce(mockUTXO).mockReturnValueOnce(mockInputNode) - const gasp1 = new GASP(storage1, throwawayRemote, 0, '[GASP #1] ') - const gasp2 = new GASP(storage2, gasp1, 0, '[GASP #2] ') - gasp1.remote = gasp2 - await gasp1.sync('test-host') - expect((await storage2.findKnownUTXOs(0)).length).toBe(1) - compareUTXOs(await storage2.findKnownUTXOs(0), await storage1.findKnownUTXOs(0)) - }) - it('Synchronizes multiple graphs from Alice to Bob', async () => { - const mockUTXO2 = { - graphID: 'mock_sender2_txid1.0', - rawTx: 'mock_sender2_rawtx1', - outputIndex: 0, - time: 222, - txid: 'mock_sender2_txid1', - inputs: {} + } } + }) + const storage1 = new MockStorage([mockUTXOWithInput]) + storage1.hydrateGASPNode = jest + .fn() + .mockReturnValueOnce(mockUTXO) + .mockReturnValueOnce(mockInputNode) + const gasp1 = new GASP(storage1, throwawayRemote, 0, '[GASP #1] ') + const gasp2 = new GASP(storage2, gasp1, 0, '[GASP #2] ') + gasp1.remote = gasp2 + await gasp1.sync('test-host') + expect((await storage2.findKnownUTXOs(0)).length).toBe(1) + compareUTXOs(await storage2.findKnownUTXOs(0), await storage1.findKnownUTXOs(0)) + }) + it('Synchronizes multiple graphs from Alice to Bob', async () => { + const mockUTXO2 = { + graphID: 'mock_sender2_txid1.0', + rawTx: 'mock_sender2_rawtx1', + outputIndex: 0, + time: 222, + txid: 'mock_sender2_txid1', + inputs: {} + } - const storage1 = new MockStorage([mockUTXO, mockUTXO2]) - const storage2 = new MockStorage() - const gasp1 = new GASP(storage1, throwawayRemote, 0, '[GASP #1] ') - const gasp2 = new GASP(storage2, gasp1, 0, '[GASP #2] ') - gasp1.remote = gasp2 - await gasp1.sync('test-host') - expect((await storage2.findKnownUTXOs(0)).length).toBe(2) - compareUTXOs(await storage2.findKnownUTXOs(0), await storage1.findKnownUTXOs(0)) - }) - it('Synchronizes a graph with recursive inputs from Bob to Alice', async () => { - const recursiveInputNode = { - graphID: 'recursive_txid.1', - rawTx: 'recursive_rawtx', - outputIndex: 1, - time: 333, - txid: 'recursive_txid', - inputs: {} - } + const storage1 = new MockStorage([mockUTXO, mockUTXO2]) + const storage2 = new MockStorage() + const gasp1 = new GASP(storage1, throwawayRemote, 0, '[GASP #1] ') + const gasp2 = new GASP(storage2, gasp1, 0, '[GASP #2] ') + gasp1.remote = gasp2 + await gasp1.sync('test-host') + expect((await storage2.findKnownUTXOs(0)).length).toBe(2) + compareUTXOs(await storage2.findKnownUTXOs(0), await storage1.findKnownUTXOs(0)) + }) + it('Synchronizes a graph with recursive inputs from Bob to Alice', async () => { + const recursiveInputNode = { + graphID: 'recursive_txid.1', + rawTx: 'recursive_rawtx', + outputIndex: 1, + time: 333, + txid: 'recursive_txid', + inputs: {} + } - const complexUTXOWithInput = { - ...mockUTXOWithInput, - inputs: { - ...mockUTXOWithInput.inputs, - 'recursive_txid.1': recursiveInputNode - } - } + const complexUTXOWithInput = { + ...mockUTXOWithInput, + inputs: { + ...mockUTXOWithInput.inputs, + 'recursive_txid.1': recursiveInputNode + } + } - const storage1 = new MockStorage() - storage1.findNeededInputs = jest.fn().mockImplementationOnce(async (n: GASPNode): Promise => { - return { - requestedInputs: { - 'mock_sender1_txid2.0': { - metadata: true - } - } + const storage1 = new MockStorage() + storage1.findNeededInputs = jest + .fn() + .mockImplementationOnce(async (_n: GASPNode): Promise => { + return { + requestedInputs: { + 'mock_sender1_txid2.0': { + metadata: true } - }).mockImplementationOnce(async (n: GASPNode): Promise => { - return { - requestedInputs: { - 'recursive_txid.1': { - metadata: true - } - } + } + } + }) + .mockImplementationOnce(async (_n: GASPNode): Promise => { + return { + requestedInputs: { + 'recursive_txid.1': { + metadata: true } - }) - const storage2 = new MockStorage([complexUTXOWithInput]) - storage2.hydrateGASPNode = jest.fn() - .mockReturnValueOnce(mockUTXO) - .mockReturnValueOnce(mockInputNode) - .mockReturnValueOnce(recursiveInputNode) - const gasp1 = new GASP(storage1, throwawayRemote, 0, '[GASP #1] ') - const gasp2 = new GASP(storage2, gasp1, 0, '[GASP #2] ') - gasp1.remote = gasp2 - await gasp1.sync('test-host') - expect((await storage1.findKnownUTXOs(0)).length).toBe(1) - compareUTXOs(await storage1.findKnownUTXOs(0), await storage2.findKnownUTXOs(0)) - }) - it('Synchronizes only UTXOs created after the specified since timestamp', async () => { - const oldUTXO = { - graphID: 'old_txid.0', - rawTx: 'old_rawtx', - outputIndex: 0, - time: 100, - txid: 'old_txid', - inputs: {} + } } + }) + const storage2 = new MockStorage([complexUTXOWithInput]) + storage2.hydrateGASPNode = jest + .fn() + .mockReturnValueOnce(mockUTXO) + .mockReturnValueOnce(mockInputNode) + .mockReturnValueOnce(recursiveInputNode) + const gasp1 = new GASP(storage1, throwawayRemote, 0, '[GASP #1] ') + const gasp2 = new GASP(storage2, gasp1, 0, '[GASP #2] ') + gasp1.remote = gasp2 + await gasp1.sync('test-host') + expect((await storage1.findKnownUTXOs(0)).length).toBe(1) + compareUTXOs(await storage1.findKnownUTXOs(0), await storage2.findKnownUTXOs(0)) + }) + it('Synchronizes only UTXOs created after the specified since timestamp', async () => { + const oldUTXO = { + graphID: 'old_txid.0', + rawTx: 'old_rawtx', + outputIndex: 0, + time: 100, + txid: 'old_txid', + inputs: {} + } - const newUTXO = { - graphID: 'new_txid.1', - rawTx: 'new_rawtx', - outputIndex: 1, - time: 200, - txid: 'new_txid', - inputs: {} - } + const newUTXO = { + graphID: 'new_txid.1', + rawTx: 'new_rawtx', + outputIndex: 1, + time: 200, + txid: 'new_txid', + inputs: {} + } - const storage1 = new MockStorage([oldUTXO, newUTXO]) - const storage2 = new MockStorage() - const gasp1 = new GASP(storage1, throwawayRemote, 0, '[GASP #1] ') - const gasp2 = new GASP(storage2, gasp1, 150, '[GASP #2] ') // Setting the `since` timestamp to 150 - gasp1.remote = gasp2 - await gasp1.sync('test-host') - - // Ensure only the new UTXO is synchronized - const syncedUTXOs = await storage2.findKnownUTXOs(0) - expect(syncedUTXOs.length).toBe(1) - expect(syncedUTXOs.map(u => ({ txid: u.txid, outputIndex: u.outputIndex }))).toEqual([{ txid: 'new_txid', outputIndex: 1 }]) - }) - it('Will not sync unnecessary graphs', async () => { - const storage1 = new MockStorage([mockUTXO]) - const storage2 = new MockStorage([mockUTXO]) - const gasp1 = new GASP(storage1, throwawayRemote, 0, '[GASP #1] ') - const gasp2 = new GASP(storage2, gasp1, 0, '[GASP #2] ') - gasp1.remote = gasp2 - await gasp1.sync('test-host') - expect((await storage1.findKnownUTXOs(0)).length).toBe(1) - expect((await storage2.findKnownUTXOs(0)).length).toBe(1) - expect(storage1.finalizeGraph).not.toHaveBeenCalled() - expect(storage2.finalizeGraph).not.toHaveBeenCalled() - compareUTXOs(await storage2.findKnownUTXOs(0), await storage1.findKnownUTXOs(0)) - }) - it('Handles invalid timestamp format gracefully', async () => { - const storage1 = new MockStorage() - expect(() => new GASP(storage1, throwawayRemote, -1)).toThrow('Invalid timestamp format') - }) + const storage1 = new MockStorage([oldUTXO, newUTXO]) + const storage2 = new MockStorage() + const gasp1 = new GASP(storage1, throwawayRemote, 0, '[GASP #1] ') + const gasp2 = new GASP(storage2, gasp1, 150, '[GASP #2] ') // Setting the `since` timestamp to 150 + gasp1.remote = gasp2 + await gasp1.sync('test-host') + + // Ensure only the new UTXO is synchronized + const syncedUTXOs = await storage2.findKnownUTXOs(0) + expect(syncedUTXOs.length).toBe(1) + expect(syncedUTXOs.map(u => ({ txid: u.txid, outputIndex: u.outputIndex }))).toEqual([ + { txid: 'new_txid', outputIndex: 1 } + ]) + }) + it('Will not sync unnecessary graphs', async () => { + const storage1 = new MockStorage([mockUTXO]) + const storage2 = new MockStorage([mockUTXO]) + const gasp1 = new GASP(storage1, throwawayRemote, 0, '[GASP #1] ') + const gasp2 = new GASP(storage2, gasp1, 0, '[GASP #2] ') + gasp1.remote = gasp2 + await gasp1.sync('test-host') + expect((await storage1.findKnownUTXOs(0)).length).toBe(1) + expect((await storage2.findKnownUTXOs(0)).length).toBe(1) + expect(storage1.finalizeGraph).not.toHaveBeenCalled() + expect(storage2.finalizeGraph).not.toHaveBeenCalled() + compareUTXOs(await storage2.findKnownUTXOs(0), await storage1.findKnownUTXOs(0)) + }) + it('Handles invalid timestamp format gracefully', async () => { + const storage1 = new MockStorage() + expect(() => new GASP(storage1, throwawayRemote, -1)).toThrow('Invalid timestamp format') + }) + + it('Handles missing UTXO during node hydration', async () => { + const storage1 = new MockStorage() + const storage2 = new MockStorage([mockUTXO]) + storage2.hydrateGASPNode = jest.fn().mockRejectedValueOnce(new Error('Not found')) + const gasp1 = new GASP(storage1, throwawayRemote, 0, '[GASP #1] ') + const gasp2 = new GASP(storage2, gasp1, 0, '[GASP #2] ') + gasp1.remote = gasp2 + await gasp1.sync('test-host') + expect((await storage2.findKnownUTXOs(0)).length).not.toEqual( + (await storage1.findKnownUTXOs(0)).length + ) + }) + it('Handles multiple UTXOs with mixed success and failure', async () => { + const invalidUTXO = { + graphID: 'invalid_txid.0', + rawTx: 'invalid_rawtx', + outputIndex: 0, + time: 150, + txid: 'invalid_txid', + inputs: {} + } - it('Handles missing UTXO during node hydration', async () => { - const storage1 = new MockStorage() - const storage2 = new MockStorage([mockUTXO]) - storage2.hydrateGASPNode = jest.fn().mockRejectedValueOnce(new Error('Not found')) - const gasp1 = new GASP(storage1, throwawayRemote, 0, '[GASP #1] ') - const gasp2 = new GASP(storage2, gasp1, 0, '[GASP #2] ') - gasp1.remote = gasp2 - await gasp1.sync('test-host') - expect((await storage2.findKnownUTXOs(0)).length).not.toEqual((await storage1.findKnownUTXOs(0)).length) - }) - it('Handles multiple UTXOs with mixed success and failure', async () => { - const invalidUTXO = { - graphID: 'invalid_txid.0', - rawTx: 'invalid_rawtx', - outputIndex: 0, - time: 150, - txid: 'invalid_txid', - inputs: {} + const storage1 = new MockStorage([mockUTXO, invalidUTXO]) + const storage2 = new MockStorage() + storage1.hydrateGASPNode = jest + .fn() + .mockImplementation( + async (graphID: string, txid: string, outputIndex: number, metadata: boolean) => { + if (txid === 'invalid_txid') { + throw new Error('Invalid transaction') + } + return { + graphID, + rawTx: mockUTXO.rawTx, + outputIndex, + proof: 'mock_proof', + txMetadata: metadata ? 'mock_tx_metadata' : undefined, + outputMetadata: metadata ? 'mock_output_metadata' : undefined, + inputs: metadata ? { mock_input: { hash: 'mock_hash' } } : undefined + } } - - const storage1 = new MockStorage([mockUTXO, invalidUTXO]) - const storage2 = new MockStorage() - storage1.hydrateGASPNode = jest.fn().mockImplementation(async (graphID: string, txid: string, outputIndex: number, metadata: boolean) => { - if (txid === 'invalid_txid') { - throw new Error('Invalid transaction') - } - return { - graphID, - rawTx: mockUTXO.rawTx, - outputIndex, - proof: 'mock_proof', - txMetadata: metadata ? 'mock_tx_metadata' : undefined, - outputMetadata: metadata ? 'mock_output_metadata' : undefined, - inputs: metadata ? { 'mock_input': { hash: 'mock_hash' } } : undefined - } - }) - - const gasp1 = new GASP(storage1, throwawayRemote, 0, '[GASP #1] ') - const gasp2 = new GASP(storage2, gasp1, 0, '[GASP #2] ') - gasp1.remote = gasp2 - - await gasp1.sync('test-host') - - const syncedUTXOs = await storage2.findKnownUTXOs(0) - expect(syncedUTXOs.length).toBe(1) - expect(syncedUTXOs.map(u => ({ txid: u.txid, outputIndex: u.outputIndex }))).toEqual([{ txid: 'mock_sender1_txid1', outputIndex: 0 }]) - }) - describe('Not that this should ever happen in Bitcoin, but...', () => { - it('Prevents infinite recursion with cyclically referencing nodes', async () => { - const cyclicNode1 = { + ) + + const gasp1 = new GASP(storage1, throwawayRemote, 0, '[GASP #1] ') + const gasp2 = new GASP(storage2, gasp1, 0, '[GASP #2] ') + gasp1.remote = gasp2 + + await gasp1.sync('test-host') + + const syncedUTXOs = await storage2.findKnownUTXOs(0) + expect(syncedUTXOs.length).toBe(1) + expect(syncedUTXOs.map(u => ({ txid: u.txid, outputIndex: u.outputIndex }))).toEqual([ + { txid: 'mock_sender1_txid1', outputIndex: 0 } + ]) + }) + describe('Not that this should ever happen in Bitcoin, but...', () => { + it('Prevents infinite recursion with cyclically referencing nodes', async () => { + const cyclicNode1 = { + graphID: 'cyclic_txid1.0', + rawTx: 'cyclic_rawtx1', + outputIndex: 0, + time: 300, + txid: 'cyclic_txid1', + inputs: { + 'cyclic_txid2.0': { + graphID: 'cyclic_txid2.0', + rawTx: 'deadbeef2024', + outputIndex: 0, + time: 300, + txid: 'cyclic_txid2', + inputs: { + 'cyclic_txid1.0': { graphID: 'cyclic_txid1.0', rawTx: 'cyclic_rawtx1', outputIndex: 0, time: 300, txid: 'cyclic_txid1', - inputs: { - 'cyclic_txid2.0': { - graphID: 'cyclic_txid2.0', - rawTx: 'deadbeef2024', - outputIndex: 0, - time: 300, - txid: 'cyclic_txid2', - inputs: { - 'cyclic_txid1.0': { - graphID: 'cyclic_txid1.0', - rawTx: 'cyclic_rawtx1', - outputIndex: 0, - time: 300, - txid: 'cyclic_txid1', - inputs: {} - } - } - } - } + inputs: {} + } } + } + } + } - const storage1 = new MockStorage([cyclicNode1]) - const storage2 = new MockStorage() - - storage2.findNeededInputs = jest.fn().mockImplementationOnce(async (n: GASPNode): Promise => { - return { - requestedInputs: { - 'cyclic_txid2.0': { - metadata: true - } - } - } - }).mockImplementationOnce(async (n: GASPNode): Promise => { - return { - requestedInputs: { - 'cyclic_txid1.0': { - metadata: true - } - } - } - }).mockImplementationOnce(async (n: GASPNode): Promise => { - return { - requestedInputs: { - 'cyclic_txid2.0': { - metadata: true - } - } - } - }) - storage1.hydrateGASPNode = jest.fn() - .mockReturnValueOnce(cyclicNode1) - .mockReturnValueOnce(cyclicNode1.inputs['cyclic_txid2.0']) - .mockReturnValueOnce(cyclicNode1) - .mockReturnValueOnce(cyclicNode1.inputs['cyclic_txid2.0']) - - const gasp1 = new GASP(storage1, throwawayRemote, 0, '[GASP #1] ') - const gasp2 = new GASP(storage2, gasp1, 0, '[GASP #2] ') - gasp1.remote = gasp2 - await gasp1.sync('test-host') - - // No UTXOs were synced between the parties - expect((await storage2.findKnownUTXOs(0)).length).toBe(0) - // The sync process did not complete - expect((await storage2.findKnownUTXOs(0)).length).not.toEqual((await storage1.findKnownUTXOs(0)).length) - // Two nodes were appended to the temporary graph - expect(storage2.appendToGraph).toHaveBeenCalledTimes(2) - // Two nodes are in temporary storage, the ones that were sent - expect(Object.keys(storage2.tempGraphStore).length).toEqual(2) + const storage1 = new MockStorage([cyclicNode1]) + const storage2 = new MockStorage() + + storage2.findNeededInputs = jest + .fn() + .mockImplementationOnce(async (_n: GASPNode): Promise => { + return { + requestedInputs: { + 'cyclic_txid2.0': { + metadata: true + } + } + } }) - it('Prevents infinite recursion with cyclically referencing nodes the other direction', async () => { - const cyclicNode1 = { + .mockImplementationOnce(async (_n: GASPNode): Promise => { + return { + requestedInputs: { + 'cyclic_txid1.0': { + metadata: true + } + } + } + }) + .mockImplementationOnce(async (_n: GASPNode): Promise => { + return { + requestedInputs: { + 'cyclic_txid2.0': { + metadata: true + } + } + } + }) + storage1.hydrateGASPNode = jest + .fn() + .mockReturnValueOnce(cyclicNode1) + .mockReturnValueOnce(cyclicNode1.inputs['cyclic_txid2.0']) + .mockReturnValueOnce(cyclicNode1) + .mockReturnValueOnce(cyclicNode1.inputs['cyclic_txid2.0']) + + const gasp1 = new GASP(storage1, throwawayRemote, 0, '[GASP #1] ') + const gasp2 = new GASP(storage2, gasp1, 0, '[GASP #2] ') + gasp1.remote = gasp2 + await gasp1.sync('test-host') + + // No UTXOs were synced between the parties + expect((await storage2.findKnownUTXOs(0)).length).toBe(0) + // The sync process did not complete + expect((await storage2.findKnownUTXOs(0)).length).not.toEqual( + (await storage1.findKnownUTXOs(0)).length + ) + // Two nodes were appended to the temporary graph + expect(storage2.appendToGraph).toHaveBeenCalledTimes(2) + // Two nodes are in temporary storage, the ones that were sent + expect(Object.keys(storage2.tempGraphStore).length).toEqual(2) + }) + it('Prevents infinite recursion with cyclically referencing nodes the other direction', async () => { + const cyclicNode1 = { + graphID: 'cyclic_txid1.0', + rawTx: 'cyclic_rawtx1', + outputIndex: 0, + time: 300, + txid: 'cyclic_txid1', + inputs: { + 'cyclic_txid2.0': { + graphID: 'cyclic_txid2.0', + rawTx: 'deadbeef2024', + outputIndex: 0, + time: 300, + txid: 'cyclic_txid2', + inputs: { + 'cyclic_txid1.0': { graphID: 'cyclic_txid1.0', rawTx: 'cyclic_rawtx1', outputIndex: 0, time: 300, txid: 'cyclic_txid1', - inputs: { - 'cyclic_txid2.0': { - graphID: 'cyclic_txid2.0', - rawTx: 'deadbeef2024', - outputIndex: 0, - time: 300, - txid: 'cyclic_txid2', - inputs: { - 'cyclic_txid1.0': { - graphID: 'cyclic_txid1.0', - rawTx: 'cyclic_rawtx1', - outputIndex: 0, - time: 300, - txid: 'cyclic_txid1', - inputs: {} - } - } - } - } + inputs: {} + } } + } + } + } - const storage1 = new MockStorage() - const storage2 = new MockStorage([cyclicNode1]) - - storage1.findNeededInputs = jest.fn().mockImplementationOnce(async (n: GASPNode): Promise => { - return { - requestedInputs: { - 'cyclic_txid2.0': { - metadata: true - } - } - } - }).mockImplementationOnce(async (n: GASPNode): Promise => { - return { - requestedInputs: { - 'cyclic_txid1.0': { - metadata: true - } - } - } - }).mockImplementationOnce(async (n: GASPNode): Promise => { - return { - requestedInputs: { - 'cyclic_txid2.0': { - metadata: true - } - } - } - }) - storage2.hydrateGASPNode = jest.fn() - .mockReturnValueOnce(cyclicNode1) - .mockReturnValueOnce(cyclicNode1.inputs['cyclic_txid2.0']) - .mockReturnValueOnce(cyclicNode1) - .mockReturnValueOnce(cyclicNode1.inputs['cyclic_txid2.0']) - - const gasp1 = new GASP(storage1, throwawayRemote, 0, '[GASP #1] ') - const gasp2 = new GASP(storage2, gasp1, 0, '[GASP #2] ') - gasp1.remote = gasp2 - await gasp1.sync('test-host') - - // This direction, the UTXO does sync because the recipient is able to proceed to graph finalization after refusing to process duplicative data. - expect((await storage1.findKnownUTXOs(0)).length).toBe(1) - expect((await storage1.findKnownUTXOs(0)).length).toBe(1) - expect(storage1.appendToGraph).toHaveBeenCalledTimes(2) + const storage1 = new MockStorage() + const storage2 = new MockStorage([cyclicNode1]) + + storage1.findNeededInputs = jest + .fn() + .mockImplementationOnce(async (_n: GASPNode): Promise => { + return { + requestedInputs: { + 'cyclic_txid2.0': { + metadata: true + } + } + } + }) + .mockImplementationOnce(async (_n: GASPNode): Promise => { + return { + requestedInputs: { + 'cyclic_txid1.0': { + metadata: true + } + } + } + }) + .mockImplementationOnce(async (_n: GASPNode): Promise => { + return { + requestedInputs: { + 'cyclic_txid2.0': { + metadata: true + } + } + } }) - it('Prevents infinite recursion with complex cyclic dependencies', async () => { - const cyclicNodeA = { + storage2.hydrateGASPNode = jest + .fn() + .mockReturnValueOnce(cyclicNode1) + .mockReturnValueOnce(cyclicNode1.inputs['cyclic_txid2.0']) + .mockReturnValueOnce(cyclicNode1) + .mockReturnValueOnce(cyclicNode1.inputs['cyclic_txid2.0']) + + const gasp1 = new GASP(storage1, throwawayRemote, 0, '[GASP #1] ') + const gasp2 = new GASP(storage2, gasp1, 0, '[GASP #2] ') + gasp1.remote = gasp2 + await gasp1.sync('test-host') + + // This direction, the UTXO does sync because the recipient is able to proceed to graph finalization after refusing to process duplicative data. + expect((await storage1.findKnownUTXOs(0)).length).toBe(1) + expect((await storage1.findKnownUTXOs(0)).length).toBe(1) + expect(storage1.appendToGraph).toHaveBeenCalledTimes(2) + }) + it('Prevents infinite recursion with complex cyclic dependencies', async () => { + const cyclicNodeA = { + graphID: 'cyclicA_txid.0', + rawTx: 'cyclicA_rawtx', + outputIndex: 0, + time: 300, + txid: 'cyclicA_txid', + inputs: { + 'cyclicB_txid.0': { + graphID: 'cyclicB_txid.0', + rawTx: 'cyclicB_rawtx', + outputIndex: 0, + time: 300, + txid: 'cyclicB_txid', + inputs: { + 'cyclicA_txid.0': { graphID: 'cyclicA_txid.0', rawTx: 'cyclicA_rawtx', outputIndex: 0, time: 300, txid: 'cyclicA_txid', - inputs: { - 'cyclicB_txid.0': { - graphID: 'cyclicB_txid.0', - rawTx: 'cyclicB_rawtx', - outputIndex: 0, - time: 300, - txid: 'cyclicB_txid', - inputs: { - 'cyclicA_txid.0': { - graphID: 'cyclicA_txid.0', - rawTx: 'cyclicA_rawtx', - outputIndex: 0, - time: 300, - txid: 'cyclicA_txid', - inputs: {} - } - } - } - } + inputs: {} + } } + } + } + } - const cyclicNodeB = { - graphID: 'cyclicB_txid.0', - rawTx: 'cyclicB_rawtx', - outputIndex: 0, - time: 300, - txid: 'cyclicB_txid', - inputs: { - 'cyclicC_txid.0': { - graphID: 'cyclicC_txid.0', - rawTx: 'cyclicC_rawtx', - outputIndex: 0, - time: 300, - txid: 'cyclicC_txid', - inputs: { - 'cyclicA_txid.0': { - graphID: 'cyclicA_txid.0', - rawTx: 'cyclicA_rawtx', - outputIndex: 0, - time: 300, - txid: 'cyclicA_txid', - inputs: {} - } - } - } - } - }; - - const cyclicNodeC = { - graphID: 'cyclicC_txid.0', - rawTx: 'cyclicC_rawtx', - outputIndex: 0, - time: 300, - txid: 'cyclicC_txid', - inputs: { - 'cyclicA_txid.0': { - graphID: 'cyclicA_txid.0', - rawTx: 'cyclicA_rawtx', - outputIndex: 0, - time: 300, - txid: 'cyclicA_txid', - inputs: {} - } - } - }; - - const storage1 = new MockStorage([cyclicNodeA]); - const storage2 = new MockStorage(); - - storage2.findNeededInputs = jest.fn() - .mockImplementationOnce(async (n: GASPNode): Promise => { - return { - requestedInputs: { - 'cyclicB_txid.0': { - metadata: true - } - } - }; - }) - .mockImplementationOnce(async (n: GASPNode): Promise => { - return { - requestedInputs: { - 'cyclicC_txid.0': { - metadata: true - } - } - }; - }) - .mockImplementationOnce(async (n: GASPNode): Promise => { - return { - requestedInputs: { - 'cyclicA_txid.0': { - metadata: true - } - } - }; - }); - - storage1.hydrateGASPNode = jest.fn() - .mockReturnValueOnce(cyclicNodeA) - .mockReturnValueOnce(cyclicNodeB) - .mockReturnValueOnce(cyclicNodeC) - .mockReturnValueOnce(cyclicNodeA); - - const gasp1 = new GASP(storage1, throwawayRemote, 0, '[GASP #1] ') - const gasp2 = new GASP(storage2, gasp1, 0, '[GASP #2] ') - gasp1.remote = gasp2; - - await gasp1.sync('test-host'); - - expect((await storage2.findKnownUTXOs(0)).length).toBe(0); - expect((await storage2.findKnownUTXOs(0)).length).not.toEqual((await storage1.findKnownUTXOs(0)).length); - expect(storage2.appendToGraph).toHaveBeenCalledTimes(3); - expect(Object.keys(storage2.tempGraphStore).length).toEqual(3); - }) - - it('Prevents infinite recursion with complex cyclic dependencies in the other direction', async () => { - const cyclicNodeA = { + const cyclicNodeB = { + graphID: 'cyclicB_txid.0', + rawTx: 'cyclicB_rawtx', + outputIndex: 0, + time: 300, + txid: 'cyclicB_txid', + inputs: { + 'cyclicC_txid.0': { + graphID: 'cyclicC_txid.0', + rawTx: 'cyclicC_rawtx', + outputIndex: 0, + time: 300, + txid: 'cyclicC_txid', + inputs: { + 'cyclicA_txid.0': { graphID: 'cyclicA_txid.0', rawTx: 'cyclicA_rawtx', outputIndex: 0, time: 300, txid: 'cyclicA_txid', - inputs: { - 'cyclicB_txid.0': { - graphID: 'cyclicB_txid.0', - rawTx: 'cyclicB_rawtx', - outputIndex: 0, - time: 300, - txid: 'cyclicB_txid', - inputs: { - 'cyclicA_txid.0': { - graphID: 'cyclicA_txid.0', - rawTx: 'cyclicA_rawtx', - outputIndex: 0, - time: 300, - txid: 'cyclicA_txid', - inputs: {} - } - } - } - } - }; - - const cyclicNodeB = { - graphID: 'cyclicB_txid.0', - rawTx: 'cyclicB_rawtx', - outputIndex: 0, - time: 300, - txid: 'cyclicB_txid', - inputs: { - 'cyclicC_txid.0': { - graphID: 'cyclicC_txid.0', - rawTx: 'cyclicC_rawtx', - outputIndex: 0, - time: 300, - txid: 'cyclicC_txid', - inputs: { - 'cyclicA_txid.0': { - graphID: 'cyclicA_txid.0', - rawTx: 'cyclicA_rawtx', - outputIndex: 0, - time: 300, - txid: 'cyclicA_txid', - inputs: {} - } - } - } - } - }; - - const cyclicNodeC = { - graphID: 'cyclicC_txid.0', - rawTx: 'cyclicC_rawtx', - outputIndex: 0, - time: 300, - txid: 'cyclicC_txid', - inputs: { - 'cyclicA_txid.0': { - graphID: 'cyclicA_txid.0', - rawTx: 'cyclicA_rawtx', - outputIndex: 0, - time: 300, - txid: 'cyclicA_txid', - inputs: {} - } - } - }; - - const storage1 = new MockStorage(); - const storage2 = new MockStorage([cyclicNodeA]); - - storage1.findNeededInputs = jest.fn() - .mockImplementationOnce(async (n: GASPNode): Promise => { - return { - requestedInputs: { - 'cyclicB_txid.0': { - metadata: true - } - } - }; - }) - .mockImplementationOnce(async (n: GASPNode): Promise => { - return { - requestedInputs: { - 'cyclicC_txid.0': { - metadata: true - } - } - }; - }) - .mockImplementationOnce(async (n: GASPNode): Promise => { - return { - requestedInputs: { - 'cyclicA_txid.0': { - metadata: true - } - } - }; - }); - - storage2.hydrateGASPNode = jest.fn() - .mockReturnValueOnce(cyclicNodeA) - .mockReturnValueOnce(cyclicNodeB) - .mockReturnValueOnce(cyclicNodeC) - .mockReturnValueOnce(cyclicNodeA); - - const gasp1 = new GASP(storage1, throwawayRemote, 0, '[GASP #1] ') - const gasp2 = new GASP(storage2, gasp1, 0, '[GASP #2] ') - gasp1.remote = gasp2; - - await gasp1.sync('test-host'); - - expect((await storage1.findKnownUTXOs(0)).length).toBe(1); - expect((await storage1.findKnownUTXOs(0)).length).toBe(1); - expect(storage1.appendToGraph).toHaveBeenCalledTimes(3); + inputs: {} + } + } + } + } + } + + const cyclicNodeC = { + graphID: 'cyclicC_txid.0', + rawTx: 'cyclicC_rawtx', + outputIndex: 0, + time: 300, + txid: 'cyclicC_txid', + inputs: { + 'cyclicA_txid.0': { + graphID: 'cyclicA_txid.0', + rawTx: 'cyclicA_rawtx', + outputIndex: 0, + time: 300, + txid: 'cyclicA_txid', + inputs: {} + } + } + } + + const storage1 = new MockStorage([cyclicNodeA]) + const storage2 = new MockStorage() + + storage2.findNeededInputs = jest + .fn() + .mockImplementationOnce(async (_n: GASPNode): Promise => { + return { + requestedInputs: { + 'cyclicB_txid.0': { + metadata: true + } + } + } }) + .mockImplementationOnce(async (_n: GASPNode): Promise => { + return { + requestedInputs: { + 'cyclicC_txid.0': { + metadata: true + } + } + } + }) + .mockImplementationOnce(async (_n: GASPNode): Promise => { + return { + requestedInputs: { + 'cyclicA_txid.0': { + metadata: true + } + } + } + }) + + storage1.hydrateGASPNode = jest + .fn() + .mockReturnValueOnce(cyclicNodeA) + .mockReturnValueOnce(cyclicNodeB) + .mockReturnValueOnce(cyclicNodeC) + .mockReturnValueOnce(cyclicNodeA) + + const gasp1 = new GASP(storage1, throwawayRemote, 0, '[GASP #1] ') + const gasp2 = new GASP(storage2, gasp1, 0, '[GASP #2] ') + gasp1.remote = gasp2 + + await gasp1.sync('test-host') + + expect((await storage2.findKnownUTXOs(0)).length).toBe(0) + expect((await storage2.findKnownUTXOs(0)).length).not.toEqual( + (await storage1.findKnownUTXOs(0)).length + ) + expect(storage2.appendToGraph).toHaveBeenCalledTimes(3) + expect(Object.keys(storage2.tempGraphStore).length).toEqual(3) }) - describe('Unidirectional Sync Tests', () => { - it('Pull-only from Bob to Alice (Alice is unidirectional client)', async () => { - // Alice has a UTXO that Bob does not have - const aliceUTXO = { - graphID: 'alice_txid.0', - rawTx: 'alice_rawtx', + it('Prevents infinite recursion with complex cyclic dependencies in the other direction', async () => { + const cyclicNodeA = { + graphID: 'cyclicA_txid.0', + rawTx: 'cyclicA_rawtx', + outputIndex: 0, + time: 300, + txid: 'cyclicA_txid', + inputs: { + 'cyclicB_txid.0': { + graphID: 'cyclicB_txid.0', + rawTx: 'cyclicB_rawtx', + outputIndex: 0, + time: 300, + txid: 'cyclicB_txid', + inputs: { + 'cyclicA_txid.0': { + graphID: 'cyclicA_txid.0', + rawTx: 'cyclicA_rawtx', outputIndex: 0, - time: 999, - txid: 'alice_txid', + time: 300, + txid: 'cyclicA_txid', inputs: {} + } } + } + } + } - // Bob has a UTXO that Alice does not have - const bobUTXO = { - graphID: 'bob_txid.1', - rawTx: 'bob_rawtx', - outputIndex: 1, - time: 1000, - txid: 'bob_txid', + const cyclicNodeB = { + graphID: 'cyclicB_txid.0', + rawTx: 'cyclicB_rawtx', + outputIndex: 0, + time: 300, + txid: 'cyclicB_txid', + inputs: { + 'cyclicC_txid.0': { + graphID: 'cyclicC_txid.0', + rawTx: 'cyclicC_rawtx', + outputIndex: 0, + time: 300, + txid: 'cyclicC_txid', + inputs: { + 'cyclicA_txid.0': { + graphID: 'cyclicA_txid.0', + rawTx: 'cyclicA_rawtx', + outputIndex: 0, + time: 300, + txid: 'cyclicA_txid', inputs: {} + } } + } + } + } - // Alice's storage - const storageAlice = new MockStorage([aliceUTXO]) - - // Bob's storage - const storageBob = new MockStorage([bobUTXO]) - - // Alice is the one calling sync() with unidirectional = true, - // meaning "pull-only from Bob's perspective" - const gaspAlice = new GASP(storageAlice, throwawayRemote, 0, '[GASP-Alice] ', false, true) - // Bob is normal, but he doesn't call `sync`. He is the remote from Alice's perspective - const gaspBob = new GASP(storageBob, gaspAlice, 0, '[GASP-Bob] ') - - // Alice uses Bob as the remote - gaspAlice.remote = gaspBob - - // Let Alice do a unidirectional sync from Bob - await gaspAlice.sync('test-host') - - // Expect that Bob's UTXO has arrived in Alice's store - expect((await storageAlice.findKnownUTXOs(0)).map(u => ({ txid: u.txid, outputIndex: u.outputIndex }))).toEqual([ - { txid: 'alice_txid', outputIndex: 0 }, - { txid: 'bob_txid', outputIndex: 1 } - ]) + const cyclicNodeC = { + graphID: 'cyclicC_txid.0', + rawTx: 'cyclicC_rawtx', + outputIndex: 0, + time: 300, + txid: 'cyclicC_txid', + inputs: { + 'cyclicA_txid.0': { + graphID: 'cyclicA_txid.0', + rawTx: 'cyclicA_rawtx', + outputIndex: 0, + time: 300, + txid: 'cyclicA_txid', + inputs: {} + } + } + } - // But, Bob does NOT get Alice's UTXO, because unidirectional means no "reply" from Alice - expect((await storageBob.findKnownUTXOs(0)).map(u => ({ txid: u.txid, outputIndex: u.outputIndex }))).toEqual([ - { txid: 'bob_txid', outputIndex: 1 } - ]) + const storage1 = new MockStorage() + const storage2 = new MockStorage([cyclicNodeA]) + + storage1.findNeededInputs = jest + .fn() + .mockImplementationOnce(async (_n: GASPNode): Promise => { + return { + requestedInputs: { + 'cyclicB_txid.0': { + metadata: true + } + } + } }) - - it('Pull-only from Alice to Bob (Bob is unidirectional client)', async () => { - // Alice has a UTXO that Bob does not have - const aliceUTXO = { - graphID: 'alice_txid.0', - rawTx: 'alice_rawtx', - outputIndex: 0, - time: 999, - txid: 'alice_txid', - inputs: {} + .mockImplementationOnce(async (_n: GASPNode): Promise => { + return { + requestedInputs: { + 'cyclicC_txid.0': { + metadata: true + } } - - // Bob has a UTXO that Alice does not have - const bobUTXO = { - graphID: 'bob_txid.1', - rawTx: 'bob_rawtx', - outputIndex: 1, - time: 1000, - txid: 'bob_txid', - inputs: {} + } + }) + .mockImplementationOnce(async (_n: GASPNode): Promise => { + return { + requestedInputs: { + 'cyclicA_txid.0': { + metadata: true + } } + } + }) - // Storage for each - const storageAlice = new MockStorage([aliceUTXO]) - const storageBob = new MockStorage([bobUTXO]) + storage2.hydrateGASPNode = jest + .fn() + .mockReturnValueOnce(cyclicNodeA) + .mockReturnValueOnce(cyclicNodeB) + .mockReturnValueOnce(cyclicNodeC) + .mockReturnValueOnce(cyclicNodeA) - // Bob is the one calling sync() with unidirectional = true - // Means Bob only pulls from Alice, but doesn't push his own data - const gaspBob = new GASP(storageBob, throwawayRemote, 0, '[GASP-Bob] ', false, true) - const gaspAlice = new GASP(storageAlice, gaspBob, 0, '[GASP-Alice] ') + const gasp1 = new GASP(storage1, throwawayRemote, 0, '[GASP #1] ') + const gasp2 = new GASP(storage2, gasp1, 0, '[GASP #2] ') + gasp1.remote = gasp2 - // Bob uses Alice as his remote - gaspBob.remote = gaspAlice + await gasp1.sync('test-host') - // Bob does a unidirectional sync from Alice - await gaspBob.sync('test-host') + expect((await storage1.findKnownUTXOs(0)).length).toBe(1) + expect((await storage1.findKnownUTXOs(0)).length).toBe(1) + expect(storage1.appendToGraph).toHaveBeenCalledTimes(3) + }) + }) + + describe('Unidirectional Sync Tests', () => { + it('Pull-only from Bob to Alice (Alice is unidirectional client)', async () => { + // Alice has a UTXO that Bob does not have + const aliceUTXO = { + graphID: 'alice_txid.0', + rawTx: 'alice_rawtx', + outputIndex: 0, + time: 999, + txid: 'alice_txid', + inputs: {} + } - // Expect that Alice's UTXO has arrived in Bob's store - expect((await storageBob.findKnownUTXOs(0)).map(u => ({ txid: u.txid, outputIndex: u.outputIndex }))).toEqual([ - { txid: 'bob_txid', outputIndex: 1 }, - { txid: 'alice_txid', outputIndex: 0 } - ]) + // Bob has a UTXO that Alice does not have + const bobUTXO = { + graphID: 'bob_txid.1', + rawTx: 'bob_rawtx', + outputIndex: 1, + time: 1000, + txid: 'bob_txid', + inputs: {} + } - // But, Alice does NOT get Bob's UTXO, because Bob never pushes it in unidirectional mode - expect((await storageAlice.findKnownUTXOs(0)).map(u => ({ txid: u.txid, outputIndex: u.outputIndex }))).toEqual([ - { txid: 'alice_txid', outputIndex: 0 } - ]) - }) + // Alice's storage + const storageAlice = new MockStorage([aliceUTXO]) + + // Bob's storage + const storageBob = new MockStorage([bobUTXO]) + + // Alice is the one calling sync() with unidirectional = true, + // meaning "pull-only from Bob's perspective" + const gaspAlice = new GASP(storageAlice, throwawayRemote, 0, '[GASP-Alice] ', false, true) + // Bob is normal, but he doesn't call `sync`. He is the remote from Alice's perspective + const gaspBob = new GASP(storageBob, gaspAlice, 0, '[GASP-Bob] ') + + // Alice uses Bob as the remote + gaspAlice.remote = gaspBob + + // Let Alice do a unidirectional sync from Bob + await gaspAlice.sync('test-host') + + // Expect that Bob's UTXO has arrived in Alice's store + expect( + (await storageAlice.findKnownUTXOs(0)).map(u => ({ + txid: u.txid, + outputIndex: u.outputIndex + })) + ).toEqual([ + { txid: 'alice_txid', outputIndex: 0 }, + { txid: 'bob_txid', outputIndex: 1 } + ]) + + // But, Bob does NOT get Alice's UTXO, because unidirectional means no "reply" from Alice + expect( + (await storageBob.findKnownUTXOs(0)).map(u => ({ + txid: u.txid, + outputIndex: u.outputIndex + })) + ).toEqual([{ txid: 'bob_txid', outputIndex: 1 }]) + }) + + it('Pull-only from Alice to Bob (Bob is unidirectional client)', async () => { + // Alice has a UTXO that Bob does not have + const aliceUTXO = { + graphID: 'alice_txid.0', + rawTx: 'alice_rawtx', + outputIndex: 0, + time: 999, + txid: 'alice_txid', + inputs: {} + } + + // Bob has a UTXO that Alice does not have + const bobUTXO = { + graphID: 'bob_txid.1', + rawTx: 'bob_rawtx', + outputIndex: 1, + time: 1000, + txid: 'bob_txid', + inputs: {} + } + + // Storage for each + const storageAlice = new MockStorage([aliceUTXO]) + const storageBob = new MockStorage([bobUTXO]) + + // Bob is the one calling sync() with unidirectional = true + // Means Bob only pulls from Alice, but doesn't push his own data + const gaspBob = new GASP(storageBob, throwawayRemote, 0, '[GASP-Bob] ', false, true) + const gaspAlice = new GASP(storageAlice, gaspBob, 0, '[GASP-Alice] ') + + // Bob uses Alice as his remote + gaspBob.remote = gaspAlice + + // Bob does a unidirectional sync from Alice + await gaspBob.sync('test-host') + + // Expect that Alice's UTXO has arrived in Bob's store + expect( + (await storageBob.findKnownUTXOs(0)).map(u => ({ + txid: u.txid, + outputIndex: u.outputIndex + })) + ).toEqual([ + { txid: 'bob_txid', outputIndex: 1 }, + { txid: 'alice_txid', outputIndex: 0 } + ]) + + // But, Alice does NOT get Bob's UTXO, because Bob never pushes it in unidirectional mode + expect( + (await storageAlice.findKnownUTXOs(0)).map(u => ({ + txid: u.txid, + outputIndex: u.outputIndex + })) + ).toEqual([{ txid: 'alice_txid', outputIndex: 0 }]) }) + }) }) diff --git a/packages/overlays/gasp-core/ts2md.json b/packages/overlays/gasp-core/ts2md.json index 893adc9f1..95eed43cf 100644 --- a/packages/overlays/gasp-core/ts2md.json +++ b/packages/overlays/gasp-core/ts2md.json @@ -2,4 +2,4 @@ "inputFilename": "mod.ts", "outputFilename": "./API.md", "firstHeadingLevel": 1 -} \ No newline at end of file +} diff --git a/packages/overlays/gasp-core/tsconfig.cjs.json b/packages/overlays/gasp-core/tsconfig.cjs.json index aef79fe9b..d7f567763 100644 --- a/packages/overlays/gasp-core/tsconfig.cjs.json +++ b/packages/overlays/gasp-core/tsconfig.cjs.json @@ -5,6 +5,9 @@ "module": "commonjs", "moduleResolution": "bundler", "rootDir": "./", - "outDir": "./dist/cjs" + "outDir": "./dist/cjs", + "declaration": true, + "declarationMap": true, + "tsBuildInfoFile": "./node_modules/.cache/gasp-cjs.tsbuildinfo" } -} \ No newline at end of file +} diff --git a/packages/overlays/gasp-core/tsconfig.eslint.json b/packages/overlays/gasp-core/tsconfig.eslint.json index ee4a2ca24..8c419709b 100644 --- a/packages/overlays/gasp-core/tsconfig.eslint.json +++ b/packages/overlays/gasp-core/tsconfig.eslint.json @@ -4,9 +4,5 @@ "noEmit": true, "allowJs": true }, - "include": [ - "./src/**/*.ts", - ".eslintrc.js", - "jest.config.js", - ] + "include": ["./src/**/*.ts", ".eslintrc.js", "jest.config.js"] } diff --git a/packages/overlays/gasp-core/tsconfig.esm.json b/packages/overlays/gasp-core/tsconfig.esm.json index efa568e3e..61a351cec 100644 --- a/packages/overlays/gasp-core/tsconfig.esm.json +++ b/packages/overlays/gasp-core/tsconfig.esm.json @@ -3,6 +3,7 @@ "compilerOptions": { "rootDir": "./", "outDir": "./dist/esm", - "allowSyntheticDefaultImports": true + "allowSyntheticDefaultImports": true, + "tsBuildInfoFile": "./node_modules/.cache/gasp-esm.tsbuildinfo" } } diff --git a/packages/overlays/gasp-core/tsconfig.types.json b/packages/overlays/gasp-core/tsconfig.types.json index 4a492387e..c1bce69bf 100644 --- a/packages/overlays/gasp-core/tsconfig.types.json +++ b/packages/overlays/gasp-core/tsconfig.types.json @@ -5,6 +5,7 @@ "outDir": "./dist/types", "emitDeclarationOnly": true, "declaration": true, - "declarationMap": true + "declarationMap": true, + "tsBuildInfoFile": "./node_modules/.cache/gasp-types.tsbuildinfo" } } diff --git a/packages/sdk/.npmignore b/packages/sdk/.npmignore new file mode 100644 index 000000000..42f8e0ea9 --- /dev/null +++ b/packages/sdk/.npmignore @@ -0,0 +1 @@ +dist/**/*.tsbuildinfo diff --git a/packages/sdk/README.md b/packages/sdk/README.md index d58fd2c3c..be3915533 100644 --- a/packages/sdk/README.md +++ b/packages/sdk/README.md @@ -16,8 +16,9 @@ For application-to-wallet integrations, the SDK exposes the BRC-100 `WalletClien 2. [Getting Started](#getting-started) 3. [Features & Deliverables](#features--deliverables) 4. [Documentation](#documentation) -5. [Contribution Guidelines](#contribution-guidelines) -6. [Support & Contacts](#support--contacts) +5. [Development and Distribution](#development-and-distribution) +6. [Contribution Guidelines](#contribution-guidelines) +7. [Support & Contacts](#support--contacts) ## Objective @@ -42,13 +43,15 @@ import { PrivateKey, P2PKH, Transaction, ARC } from '@bsv/sdk' const privKey = PrivateKey.fromWif('L5EY1SbTvvPNSdCYQe1EJHfXCBBT4PmnF6CDbzCm9iifZptUvDGB') -const sourceTransaction = Transaction.fromHex('0200000001849c6419aec8b65d747cb72282cc02f3fc26dd018b46962f5de48957fac50528020000006a473044022008a60c611f3b48eaf0d07b5425d75f6ce65c3730bd43e6208560648081f9661b0220278fa51877100054d0d08e38e069b0afdb4f0f9d38844c68ee2233ace8e0de2141210360cd30f72e805be1f00d53f9ccd47dfd249cbb65b0d4aee5cfaf005a5258be37ffffffff03d0070000000000001976a914acc4d7c37bc9d0be0a4987483058a2d842f2265d88ac75330100000000001976a914db5b7964eecb19fcab929bf6bd29297ec005d52988ac809f7c09000000001976a914c0b0a42e92f062bdbc6a881b1777eed1213c19eb88ac00000000') +const sourceTransaction = Transaction.fromHex( + '0200000001849c6419aec8b65d747cb72282cc02f3fc26dd018b46962f5de48957fac50528020000006a473044022008a60c611f3b48eaf0d07b5425d75f6ce65c3730bd43e6208560648081f9661b0220278fa51877100054d0d08e38e069b0afdb4f0f9d38844c68ee2233ace8e0de2141210360cd30f72e805be1f00d53f9ccd47dfd249cbb65b0d4aee5cfaf005a5258be37ffffffff03d0070000000000001976a914acc4d7c37bc9d0be0a4987483058a2d842f2265d88ac75330100000000001976a914db5b7964eecb19fcab929bf6bd29297ec005d52988ac809f7c09000000001976a914c0b0a42e92f062bdbc6a881b1777eed1213c19eb88ac00000000' +) const version = 1 const input = { sourceTransaction, sourceOutputIndex: 0, - unlockingScriptTemplate: new P2PKH().unlock(privKey), + unlockingScriptTemplate: new P2PKH().unlock(privKey) } const output = { lockingScript: new P2PKH().lock(privKey.toAddress()), @@ -99,24 +102,52 @@ For a more detailed tutorial and advanced examples, check our [Documentation](#d Comprehensive documentation is available in several formats: - **[📚 Online Documentation](https://bsv-blockchain.github.io/ts-stack/packages/sdk/)**: Our complete documentation: - - **[🚀 Get Started](https://bsv-blockchain.github.io/ts-stack/get-started/)**: Step-by-step lessons to learn by doing - - **[🔧 How-To Guides](https://bsv-blockchain.github.io/ts-stack/guides/)**: Practical solutions to specific problems - - **[📚 Reference](https://bsv-blockchain.github.io/ts-stack/reference/)**: Complete technical specifications and API documentation - - **[🏗️ Architecture](https://bsv-blockchain.github.io/ts-stack/architecture/)**: Architecture and design explanations + - **[🚀 Get Started](https://bsv-blockchain.github.io/ts-stack/get-started/)**: Step-by-step lessons to learn by doing + - **[🔧 How-To Guides](https://bsv-blockchain.github.io/ts-stack/guides/)**: Practical solutions to specific problems + - **[📚 Reference](https://bsv-blockchain.github.io/ts-stack/reference/)**: Complete technical specifications and API documentation + - **[🏗️ Architecture](https://bsv-blockchain.github.io/ts-stack/architecture/)**: Architecture and design explanations - **[⚡ Examples](https://docs.bsvblockchain.org/guides/sdks/ts/examples)**: Practical code examples - **Code Annotations**: The SDK is richly documented with code-level annotations that show up in editors like VSCode +## Development and Distribution + +The workspace requires Node.js 24.11 or newer and pnpm 10. Install from the +repository root, then run the SDK's complete contract: + +```bash +pnpm install +pnpm --filter @bsv/sdk format:check +pnpm --filter @bsv/sdk lint +pnpm --filter @bsv/sdk typecheck +pnpm --filter @bsv/sdk test:coverage +pnpm --filter @bsv/sdk pack:check +pnpm --filter @bsv/sdk test:browser +``` + +`pack:check` installs the exact generated tarball into ESM and CommonJS +consumer projects and verifies public exports and conditional type resolution. +`test:browser` independently bundles that tarball with Vite and esbuild, +rejects Node/server dependencies, validates source maps, and enforces measured +raw, gzip, and Brotli budgets. The package publishes ESM, CommonJS, and a +classic UMD bundle; TypeScript declarations are selected through matching +conditional exports. + +Publishing is performed only by the repository release workflow after these +checks pass. Local development and validation must not rewrite versions or +publish artifacts. + ## Contribution Guidelines We're always looking for contributors to help us improve the SDK. Whether it's bug reports, feature requests, or pull requests - all contributions are welcome. 1. **Fork & Clone**: Fork this repository and clone it to your local machine. -2. **Set Up**: Run `npm install` to install all dependencies. +2. **Set Up**: Run `pnpm install` at the `ts-stack` repository root. 3. **Make Changes**: Create a new branch and make your changes. -4. **Test**: Ensure all tests pass by running `npm test`. +4. **Test**: Run the package checks listed in + [Development and Distribution](#development-and-distribution). 5. **Commit**: Commit your changes and push to your fork. 6. **Pull Request**: Open a pull request from your fork to this repository. -For more details, check the [contribution guidelines](./CONTRIBUTING.md). + For more details, check the [contribution guidelines](./CONTRIBUTING.md). For information on past releases, check out the [changelog](./CHANGELOG.md). For future plans, check the [roadmap](./ROADMAP.md)! diff --git a/packages/sdk/browser-budget.json b/packages/sdk/browser-budget.json new file mode 100644 index 000000000..d1b07ffad --- /dev/null +++ b/packages/sdk/browser-budget.json @@ -0,0 +1,38 @@ +{ + "schemaVersion": 1, + "profile": "browser", + "package": "@bsv/sdk", + "entry": ".", + "requiredExports": [ + "AuthFetch", + "IdentityClient", + "PrivateKey", + "ProtoWallet", + "PublicKey", + "Script", + "Transaction", + "WalletClient" + ], + "prohibitedExports": [], + "maximumBytes": { + "vite": { + "raw": 735000, + "gzip": 185000, + "brotli": 150000 + }, + "esbuild": { + "raw": 555000, + "gzip": 168000, + "brotli": 140000 + } + }, + "umd": { + "path": "dist/umd/bundle.js", + "global": "bsv", + "maximumBytes": { + "raw": 545000, + "gzip": 162000, + "brotli": 136000 + } + } +} diff --git a/packages/sdk/jest.config.js b/packages/sdk/jest.config.js index 951525bf9..ae67f1059 100644 --- a/packages/sdk/jest.config.js +++ b/packages/sdk/jest.config.js @@ -9,23 +9,40 @@ export default { // Ignore compiled output testPathIgnorePatterns: ['dist/'], modulePathIgnorePatterns: ['/dist'], + collectCoverageFrom: [ + 'src/**/*.ts', + '!src/**/__test/**', + '!src/**/__tests/**', + '!src/**/*.test.ts' + ], + coverageThreshold: { + global: { + branches: 80, + functions: 85, + lines: 85, + statements: 85 + } + }, transform: { - '^.+\\.test.ts?$': ['ts-jest', { - useESM: true, - diagnostics: false, - tsconfig: { - // Explicitly enable ES2020 to support BigInt literals - target: 'ES2020', - module: 'ESNext', - moduleResolution: 'bundler', - strict: false, - strictNullChecks: false, - noImplicitAny: false, - strictPropertyInitialization: false, - skipLibCheck: true, - types: ['node', 'jest'] + '^.+\\.test.ts?$': [ + 'ts-jest', + { + useESM: true, + diagnostics: false, + tsconfig: { + // Explicitly enable ES2020 to support BigInt literals + target: 'ES2020', + module: 'ESNext', + moduleResolution: 'bundler', + strict: false, + strictNullChecks: false, + noImplicitAny: false, + strictPropertyInitialization: false, + skipLibCheck: true, + types: ['node', 'jest'] + } } - }] + ] }, // Tell Jest that files ending in .ts should be treated as ESM modules diff --git a/packages/sdk/package.json b/packages/sdk/package.json index 5cf213cb5..8ba328e45 100644 --- a/packages/sdk/package.json +++ b/packages/sdk/package.json @@ -15,237 +15,512 @@ "types": "dist/types/mod.d.ts", "files": [ "dist", - "src", + "!dist/**/*.tsbuildinfo", "docs", - "mod.ts", + "README.md", "LICENSE.txt" ], "exports": { ".": { - "types": "./dist/types/mod.d.ts", - "import": "./dist/esm/mod.js", - "require": "./dist/cjs/mod.js" + "import": { + "types": "./dist/types/mod.d.ts", + "default": "./dist/esm/mod.js" + }, + "require": { + "types": "./dist/cjs/mod.d.ts", + "default": "./dist/cjs/mod.js" + } }, "./*.ts": { - "types": "./dist/types/src/*.d.ts", - "import": "./dist/esm/src/*.js", - "require": "./dist/cjs/src/*.js" + "import": { + "types": "./dist/types/src/*.d.ts", + "default": "./dist/esm/src/*.js" + }, + "require": { + "types": "./dist/cjs/src/*.d.ts", + "default": "./dist/cjs/src/*.js" + } }, "./primitives": { - "import": "./dist/esm/src/primitives/index.js", - "require": "./dist/cjs/src/primitives/index.js", - "types": "./dist/types/src/primitives/index.d.ts" + "import": { + "types": "./dist/types/src/primitives/index.d.ts", + "default": "./dist/esm/src/primitives/index.js" + }, + "require": { + "types": "./dist/cjs/src/primitives/index.d.ts", + "default": "./dist/cjs/src/primitives/index.js" + } }, "./primitives/*": { - "import": "./dist/esm/src/primitives/*.js", - "require": "./dist/cjs/src/primitives/*.js", - "types": "./dist/types/src/primitives/*.d.ts" + "import": { + "types": "./dist/types/src/primitives/*.d.ts", + "default": "./dist/esm/src/primitives/*.js" + }, + "require": { + "types": "./dist/cjs/src/primitives/*.d.ts", + "default": "./dist/cjs/src/primitives/*.js" + } }, "./script": { - "import": "./dist/esm/src/script/index.js", - "require": "./dist/cjs/src/script/index.js", - "types": "./dist/types/src/script/index.d.ts" + "import": { + "types": "./dist/types/src/script/index.d.ts", + "default": "./dist/esm/src/script/index.js" + }, + "require": { + "types": "./dist/cjs/src/script/index.d.ts", + "default": "./dist/cjs/src/script/index.js" + } }, "./script/*": { - "import": "./dist/esm/src/script/*.js", - "require": "./dist/cjs/src/script/*.js", - "types": "./dist/types/src/script/*.d.ts" + "import": { + "types": "./dist/types/src/script/*.d.ts", + "default": "./dist/esm/src/script/*.js" + }, + "require": { + "types": "./dist/cjs/src/script/*.d.ts", + "default": "./dist/cjs/src/script/*.js" + } }, "./script/templates": { - "import": "./dist/esm/src/script/templates/index.js", - "require": "./dist/cjs/src/script/templates/index.js", - "types": "./dist/types/src/script/templates/index.d.ts" + "import": { + "types": "./dist/types/src/script/templates/index.d.ts", + "default": "./dist/esm/src/script/templates/index.js" + }, + "require": { + "types": "./dist/cjs/src/script/templates/index.d.ts", + "default": "./dist/cjs/src/script/templates/index.js" + } }, "./script/templates/*": { - "import": "./dist/esm/src/script/templates/*.js", - "require": "./dist/cjs/src/script/templates/*.js", - "types": "./dist/types/src/script/templates/*.d.ts" + "import": { + "types": "./dist/types/src/script/templates/*.d.ts", + "default": "./dist/esm/src/script/templates/*.js" + }, + "require": { + "types": "./dist/cjs/src/script/templates/*.d.ts", + "default": "./dist/cjs/src/script/templates/*.js" + } }, "./transaction": { - "import": "./dist/esm/src/transaction/index.js", - "require": "./dist/cjs/src/transaction/index.js", - "types": "./dist/types/src/transaction/index.d.ts" + "import": { + "types": "./dist/types/src/transaction/index.d.ts", + "default": "./dist/esm/src/transaction/index.js" + }, + "require": { + "types": "./dist/cjs/src/transaction/index.d.ts", + "default": "./dist/cjs/src/transaction/index.js" + } }, "./transaction/*": { - "import": "./dist/esm/src/transaction/*.js", - "require": "./dist/cjs/src/transaction/*.js", - "types": "./dist/types/src/transaction/*.d.ts" + "import": { + "types": "./dist/types/src/transaction/*.d.ts", + "default": "./dist/esm/src/transaction/*.js" + }, + "require": { + "types": "./dist/cjs/src/transaction/*.d.ts", + "default": "./dist/cjs/src/transaction/*.js" + } }, "./transaction/broadcaster": { - "import": "./dist/esm/src/transaction/broadcaster/index.js", - "require": "./dist/cjs/src/transaction/broadcaster/index.js", - "types": "./dist/types/src/transaction/broadcaster/index.d.ts" + "import": { + "types": "./dist/types/src/transaction/broadcasters/index.d.ts", + "default": "./dist/esm/src/transaction/broadcasters/index.js" + }, + "require": { + "types": "./dist/cjs/src/transaction/broadcasters/index.d.ts", + "default": "./dist/cjs/src/transaction/broadcasters/index.js" + } }, "./transaction/broadcaster/*": { - "import": "./dist/esm/src/transaction/broadcaster/*.js", - "require": "./dist/cjs/src/transaction/broadcaster/*.js", - "types": "./dist/types/src/transaction/broadcaster/*.d.ts" + "import": { + "types": "./dist/types/src/transaction/broadcasters/*.d.ts", + "default": "./dist/esm/src/transaction/broadcasters/*.js" + }, + "require": { + "types": "./dist/cjs/src/transaction/broadcasters/*.d.ts", + "default": "./dist/cjs/src/transaction/broadcasters/*.js" + } + }, + "./transaction/broadcasters": { + "import": { + "types": "./dist/types/src/transaction/broadcasters/index.d.ts", + "default": "./dist/esm/src/transaction/broadcasters/index.js" + }, + "require": { + "types": "./dist/cjs/src/transaction/broadcasters/index.d.ts", + "default": "./dist/cjs/src/transaction/broadcasters/index.js" + } + }, + "./transaction/broadcasters/*": { + "import": { + "types": "./dist/types/src/transaction/broadcasters/*.d.ts", + "default": "./dist/esm/src/transaction/broadcasters/*.js" + }, + "require": { + "types": "./dist/cjs/src/transaction/broadcasters/*.d.ts", + "default": "./dist/cjs/src/transaction/broadcasters/*.js" + } }, "./transaction/chaintrackers": { - "import": "./dist/esm/src/transaction/chaintrackers/index.js", - "require": "./dist/cjs/src/transaction/chaintrackers/index.js", - "types": "./dist/types/src/transaction/chaintrackers/index.d.ts" + "import": { + "types": "./dist/types/src/transaction/chaintrackers/index.d.ts", + "default": "./dist/esm/src/transaction/chaintrackers/index.js" + }, + "require": { + "types": "./dist/cjs/src/transaction/chaintrackers/index.d.ts", + "default": "./dist/cjs/src/transaction/chaintrackers/index.js" + } }, "./transaction/chaintrackers/*": { - "import": "./dist/esm/src/transaction/chaintrackers/*.js", - "require": "./dist/cjs/src/transaction/chaintrackers/*.js", - "types": "./dist/types/src/transaction/chaintrackers/*.d.ts" + "import": { + "types": "./dist/types/src/transaction/chaintrackers/*.d.ts", + "default": "./dist/esm/src/transaction/chaintrackers/*.js" + }, + "require": { + "types": "./dist/cjs/src/transaction/chaintrackers/*.d.ts", + "default": "./dist/cjs/src/transaction/chaintrackers/*.js" + } }, "./transaction/http": { - "import": "./dist/esm/src/transaction/http/index.js", - "require": "./dist/cjs/src/transaction/http/index.js", - "types": "./dist/types/src/transaction/http/index.d.ts" + "import": { + "types": "./dist/types/src/transaction/http/index.d.ts", + "default": "./dist/esm/src/transaction/http/index.js" + }, + "require": { + "types": "./dist/cjs/src/transaction/http/index.d.ts", + "default": "./dist/cjs/src/transaction/http/index.js" + } }, "./transaction/http/*": { - "import": "./dist/esm/src/transaction/http/*.js", - "require": "./dist/cjs/src/transaction/http/*.js", - "types": "./dist/types/src/transaction/http/*.d.ts" + "import": { + "types": "./dist/types/src/transaction/http/*.d.ts", + "default": "./dist/esm/src/transaction/http/*.js" + }, + "require": { + "types": "./dist/cjs/src/transaction/http/*.d.ts", + "default": "./dist/cjs/src/transaction/http/*.js" + } }, "./transaction/fee-model": { - "import": "./dist/esm/src/transaction/fee-model/index.js", - "require": "./dist/cjs/src/transaction/fee-model/index.js", - "types": "./dist/types/src/transaction/fee-model/index.d.ts" + "import": { + "types": "./dist/types/src/transaction/fee-models/index.d.ts", + "default": "./dist/esm/src/transaction/fee-models/index.js" + }, + "require": { + "types": "./dist/cjs/src/transaction/fee-models/index.d.ts", + "default": "./dist/cjs/src/transaction/fee-models/index.js" + } }, "./transaction/fee-model/*": { - "import": "./dist/esm/src/transaction/fee-model/*.js", - "require": "./dist/cjs/src/transaction/fee-model/*.js", - "types": "./dist/types/src/transaction/fee-model/*.d.ts" + "import": { + "types": "./dist/types/src/transaction/fee-models/*.d.ts", + "default": "./dist/esm/src/transaction/fee-models/*.js" + }, + "require": { + "types": "./dist/cjs/src/transaction/fee-models/*.d.ts", + "default": "./dist/cjs/src/transaction/fee-models/*.js" + } + }, + "./transaction/fee-models": { + "import": { + "types": "./dist/types/src/transaction/fee-models/index.d.ts", + "default": "./dist/esm/src/transaction/fee-models/index.js" + }, + "require": { + "types": "./dist/cjs/src/transaction/fee-models/index.d.ts", + "default": "./dist/cjs/src/transaction/fee-models/index.js" + } + }, + "./transaction/fee-models/*": { + "import": { + "types": "./dist/types/src/transaction/fee-models/*.d.ts", + "default": "./dist/esm/src/transaction/fee-models/*.js" + }, + "require": { + "types": "./dist/cjs/src/transaction/fee-models/*.d.ts", + "default": "./dist/cjs/src/transaction/fee-models/*.js" + } }, "./messages": { - "import": "./dist/esm/src/messages/index.js", - "require": "./dist/cjs/src/messages/index.js", - "types": "./dist/types/src/messages/index.d.ts" + "import": { + "types": "./dist/types/src/messages/index.d.ts", + "default": "./dist/esm/src/messages/index.js" + }, + "require": { + "types": "./dist/cjs/src/messages/index.d.ts", + "default": "./dist/cjs/src/messages/index.js" + } }, "./messages/*": { - "import": "./dist/esm/src/messages/*.js", - "require": "./dist/cjs/src/messages/*.js", - "types": "./dist/types/src/messages/*.d.ts" + "import": { + "types": "./dist/types/src/messages/*.d.ts", + "default": "./dist/esm/src/messages/*.js" + }, + "require": { + "types": "./dist/cjs/src/messages/*.d.ts", + "default": "./dist/cjs/src/messages/*.js" + } }, "./compat": { - "import": "./dist/esm/src/compat/index.js", - "require": "./dist/cjs/src/compat/index.js", - "types": "./dist/types/src/compat/index.d.ts" + "import": { + "types": "./dist/types/src/compat/index.d.ts", + "default": "./dist/esm/src/compat/index.js" + }, + "require": { + "types": "./dist/cjs/src/compat/index.d.ts", + "default": "./dist/cjs/src/compat/index.js" + } }, "./compat/*": { - "import": "./dist/esm/src/compat/*.js", - "require": "./dist/cjs/src/compat/*.js", - "types": "./dist/types/src/compat/*.d.ts" + "import": { + "types": "./dist/types/src/compat/*.d.ts", + "default": "./dist/esm/src/compat/*.js" + }, + "require": { + "types": "./dist/cjs/src/compat/*.d.ts", + "default": "./dist/cjs/src/compat/*.js" + } }, "./totp": { - "import": "./dist/esm/src/totp/index.js", - "require": "./dist/cjs/src/totp/index.js", - "types": "./dist/types/src/totp/index.d.ts" + "import": { + "types": "./dist/types/src/totp/index.d.ts", + "default": "./dist/esm/src/totp/index.js" + }, + "require": { + "types": "./dist/cjs/src/totp/index.d.ts", + "default": "./dist/cjs/src/totp/index.js" + } }, "./totp/*": { - "import": "./dist/esm/src/totp/*.js", - "require": "./dist/cjs/src/totp/*.js", - "types": "./dist/types/src/totp/*.d.ts" + "import": { + "types": "./dist/types/src/totp/*.d.ts", + "default": "./dist/esm/src/totp/*.js" + }, + "require": { + "types": "./dist/cjs/src/totp/*.d.ts", + "default": "./dist/cjs/src/totp/*.js" + } }, "./wallet": { - "import": "./dist/esm/src/wallet/index.js", - "require": "./dist/cjs/src/wallet/index.js", - "types": "./dist/types/src/wallet/index.d.ts" + "import": { + "types": "./dist/types/src/wallet/index.d.ts", + "default": "./dist/esm/src/wallet/index.js" + }, + "require": { + "types": "./dist/cjs/src/wallet/index.d.ts", + "default": "./dist/cjs/src/wallet/index.js" + } }, "./wallet/*": { - "import": "./dist/esm/src/wallet/*.js", - "require": "./dist/cjs/src/wallet/*.js", - "types": "./dist/types/src/wallet/*.d.ts" + "import": { + "types": "./dist/types/src/wallet/*.d.ts", + "default": "./dist/esm/src/wallet/*.js" + }, + "require": { + "types": "./dist/cjs/src/wallet/*.d.ts", + "default": "./dist/cjs/src/wallet/*.js" + } }, "./wallet/substrates": { - "import": "./dist/esm/src/wallet/substrates/index.js", - "require": "./dist/cjs/src/wallet/substrates/index.js", - "types": "./dist/types/src/wallet/substrates/index.d.ts" + "import": { + "types": "./dist/types/src/wallet/substrates/index.d.ts", + "default": "./dist/esm/src/wallet/substrates/index.js" + }, + "require": { + "types": "./dist/cjs/src/wallet/substrates/index.d.ts", + "default": "./dist/cjs/src/wallet/substrates/index.js" + } }, "./wallet/substrates/*": { - "import": "./dist/esm/src/wallet/substrates/*.js", - "require": "./dist/cjs/src/wallet/substrates/*.js", - "types": "./dist/types/src/wallet/substrates/*.d.ts" + "import": { + "types": "./dist/types/src/wallet/substrates/*.d.ts", + "default": "./dist/esm/src/wallet/substrates/*.js" + }, + "require": { + "types": "./dist/cjs/src/wallet/substrates/*.d.ts", + "default": "./dist/cjs/src/wallet/substrates/*.js" + } }, "./auth": { - "import": "./dist/esm/src/auth/index.js", - "require": "./dist/cjs/src/auth/index.js", - "types": "./dist/types/src/auth/index.d.ts" + "import": { + "types": "./dist/types/src/auth/index.d.ts", + "default": "./dist/esm/src/auth/index.js" + }, + "require": { + "types": "./dist/cjs/src/auth/index.d.ts", + "default": "./dist/cjs/src/auth/index.js" + } }, "./auth/*": { - "import": "./dist/esm/src/auth/*.js", - "require": "./dist/cjs/src/auth/*.js", - "types": "./dist/types/src/auth/*.d.ts" + "import": { + "types": "./dist/types/src/auth/*.d.ts", + "default": "./dist/esm/src/auth/*.js" + }, + "require": { + "types": "./dist/cjs/src/auth/*.d.ts", + "default": "./dist/cjs/src/auth/*.js" + } }, "./auth/certificate": { - "import": "./dist/esm/src/auth/certificate/index.js", - "require": "./dist/cjs/src/auth/certificate/index.js", - "types": "./dist/types/src/auth/certificate/index.d.ts" + "import": { + "types": "./dist/types/src/auth/certificates/index.d.ts", + "default": "./dist/esm/src/auth/certificates/index.js" + }, + "require": { + "types": "./dist/cjs/src/auth/certificates/index.d.ts", + "default": "./dist/cjs/src/auth/certificates/index.js" + } }, "./auth/certificate/*": { - "import": "./dist/esm/src/auth/certificate/*.js", - "require": "./dist/cjs/src/auth/certificate/*.js", - "types": "./dist/types/src/auth/certificate/*.d.ts" + "import": { + "types": "./dist/types/src/auth/certificates/*.d.ts", + "default": "./dist/esm/src/auth/certificates/*.js" + }, + "require": { + "types": "./dist/cjs/src/auth/certificates/*.d.ts", + "default": "./dist/cjs/src/auth/certificates/*.js" + } + }, + "./auth/certificates": { + "import": { + "types": "./dist/types/src/auth/certificates/index.d.ts", + "default": "./dist/esm/src/auth/certificates/index.js" + }, + "require": { + "types": "./dist/cjs/src/auth/certificates/index.d.ts", + "default": "./dist/cjs/src/auth/certificates/index.js" + } + }, + "./auth/certificates/*": { + "import": { + "types": "./dist/types/src/auth/certificates/*.d.ts", + "default": "./dist/esm/src/auth/certificates/*.js" + }, + "require": { + "types": "./dist/cjs/src/auth/certificates/*.d.ts", + "default": "./dist/cjs/src/auth/certificates/*.js" + } }, "./overlay-tools": { - "import": "./dist/esm/src/overlay-tools/index.js", - "require": "./dist/cjs/src/overlay-tools/index.js", - "types": "./dist/types/src/overlay-tools/index.d.ts" + "import": { + "types": "./dist/types/src/overlay-tools/index.d.ts", + "default": "./dist/esm/src/overlay-tools/index.js" + }, + "require": { + "types": "./dist/cjs/src/overlay-tools/index.d.ts", + "default": "./dist/cjs/src/overlay-tools/index.js" + } }, "./overlay-tools/*": { - "import": "./dist/esm/src/overlay-tools/*.js", - "require": "./dist/cjs/src/overlay-tools/*.js", - "types": "./dist/types/src/overlay-tools/*.d.ts" + "import": { + "types": "./dist/types/src/overlay-tools/*.d.ts", + "default": "./dist/esm/src/overlay-tools/*.js" + }, + "require": { + "types": "./dist/cjs/src/overlay-tools/*.d.ts", + "default": "./dist/cjs/src/overlay-tools/*.js" + } }, "./telemetry": { - "import": "./dist/esm/src/telemetry/index.js", - "require": "./dist/cjs/src/telemetry/index.js", - "types": "./dist/types/src/telemetry/index.d.ts" + "import": { + "types": "./dist/types/src/telemetry/index.d.ts", + "default": "./dist/esm/src/telemetry/index.js" + }, + "require": { + "types": "./dist/cjs/src/telemetry/index.d.ts", + "default": "./dist/cjs/src/telemetry/index.js" + } }, "./telemetry/*": { - "import": "./dist/esm/src/telemetry/*.js", - "require": "./dist/cjs/src/telemetry/*.js", - "types": "./dist/types/src/telemetry/*.d.ts" + "import": { + "types": "./dist/types/src/telemetry/*.d.ts", + "default": "./dist/esm/src/telemetry/*.js" + }, + "require": { + "types": "./dist/cjs/src/telemetry/*.d.ts", + "default": "./dist/cjs/src/telemetry/*.js" + } }, "./storage": { - "import": "./dist/esm/src/storage/index.js", - "require": "./dist/cjs/src/storage/index.js", - "types": "./dist/types/src/storage/index.d.ts" + "import": { + "types": "./dist/types/src/storage/index.d.ts", + "default": "./dist/esm/src/storage/index.js" + }, + "require": { + "types": "./dist/cjs/src/storage/index.d.ts", + "default": "./dist/cjs/src/storage/index.js" + } }, "./storage/*": { - "import": "./dist/esm/src/storage/*.js", - "require": "./dist/cjs/src/storage/*.js", - "types": "./dist/types/src/storage/*.d.ts" + "import": { + "types": "./dist/types/src/storage/*.d.ts", + "default": "./dist/esm/src/storage/*.js" + }, + "require": { + "types": "./dist/cjs/src/storage/*.d.ts", + "default": "./dist/cjs/src/storage/*.js" + } }, "./kvstore": { - "import": "./dist/esm/src/kvstore/index.js", - "require": "./dist/cjs/src/kvstore/index.js", - "types": "./dist/types/src/kvstore/index.d.ts" + "import": { + "types": "./dist/types/src/kvstore/index.d.ts", + "default": "./dist/esm/src/kvstore/index.js" + }, + "require": { + "types": "./dist/cjs/src/kvstore/index.d.ts", + "default": "./dist/cjs/src/kvstore/index.js" + } }, "./kvstore/*": { - "import": "./dist/esm/src/kvstore/*.js", - "require": "./dist/cjs/src/kvstore/*.js", - "types": "./dist/types/src/kvstore/*.d.ts" + "import": { + "types": "./dist/types/src/kvstore/*.d.ts", + "default": "./dist/esm/src/kvstore/*.js" + }, + "require": { + "types": "./dist/cjs/src/kvstore/*.d.ts", + "default": "./dist/cjs/src/kvstore/*.js" + } }, "./remittance": { - "import": "./dist/esm/src/remittance/index.js", - "require": "./dist/cjs/src/remittance/index.js", - "types": "./dist/types/src/remittance/index.d.ts" + "import": { + "types": "./dist/types/src/remittance/index.d.ts", + "default": "./dist/esm/src/remittance/index.js" + }, + "require": { + "types": "./dist/cjs/src/remittance/index.d.ts", + "default": "./dist/cjs/src/remittance/index.js" + } }, "./remittance/*": { - "import": "./dist/esm/src/remittance/*.js", - "require": "./dist/cjs/src/remittance/*.js", - "types": "./dist/types/src/remittance/*.d.ts" + "import": { + "types": "./dist/types/src/remittance/*.d.ts", + "default": "./dist/esm/src/remittance/*.js" + }, + "require": { + "types": "./dist/cjs/src/remittance/*.d.ts", + "default": "./dist/cjs/src/remittance/*.js" + } }, "./umd": { + "types": "./dist/types/mod.d.ts", "import": "./dist/umd/bundle.js" } }, "scripts": { - "test": "npm run build && jest", - "test:watch": "npm run build && jest --watch", - "test:coverage": "npm run build && jest --coverage --watchman=false", - "lint:ci": "oxlint src", - "lint": "oxlint src", - "build": "npm run build:ts && npm run build:umd", + "test": "pnpm build && jest --watchman=false", + "test:watch": "pnpm build && jest --watch", + "test:coverage": "pnpm build && jest --coverage --watchman=false", + "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", + "lint": "oxlint mod.ts src jest.config.js rspack.config.js --ignore-pattern 'src/**/__test/**' --ignore-pattern 'src/**/__tests/**' --ignore-pattern 'src/**/*.test.ts' --deny-warnings", + "pack:check": "pnpm build && node ../../scripts/check-package-artifact.mjs . --exports PrivateKey,PublicKey,Transaction,Script,WalletClient,ProtoWallet,AuthFetch,IdentityClient,LookupResolver,RemittanceManager --esm-only-entrypoints ./umd", + "test:browser": "pnpm build && node ../../scripts/check-browser-package.mjs .", + "typecheck": "tsc --build --pretty false", + "build": "pnpm build:ts && pnpm build:umd", "build:ts": "tsc -b && tsconfig-to-dual-package tsconfig.cjs.json", "build:umd": "rspack --config rspack.config.js", "dev": "tsc -b -w", - "prepublish": "npm run build", + "prepublishOnly": "pnpm build", "doc": "ts2md", "docs:serve": "mkdocs serve", "docs:build": "mkdocs build" @@ -282,5 +557,156 @@ "tsconfig-to-dual-package": "^1.2.0", "typescript": "^6.0.3", "oxlint": "^1.75.0" + }, + "typesVersions": { + "*": { + "*.ts": [ + "dist/types/src/*.d.ts" + ], + "primitives": [ + "dist/types/src/primitives/index.d.ts" + ], + "primitives/*": [ + "dist/types/src/primitives/*.d.ts" + ], + "script": [ + "dist/types/src/script/index.d.ts" + ], + "script/*": [ + "dist/types/src/script/*.d.ts" + ], + "script/templates": [ + "dist/types/src/script/templates/index.d.ts" + ], + "script/templates/*": [ + "dist/types/src/script/templates/*.d.ts" + ], + "transaction": [ + "dist/types/src/transaction/index.d.ts" + ], + "transaction/*": [ + "dist/types/src/transaction/*.d.ts" + ], + "transaction/broadcaster": [ + "dist/types/src/transaction/broadcasters/index.d.ts" + ], + "transaction/broadcaster/*": [ + "dist/types/src/transaction/broadcasters/*.d.ts" + ], + "transaction/broadcasters": [ + "dist/types/src/transaction/broadcasters/index.d.ts" + ], + "transaction/broadcasters/*": [ + "dist/types/src/transaction/broadcasters/*.d.ts" + ], + "transaction/chaintrackers": [ + "dist/types/src/transaction/chaintrackers/index.d.ts" + ], + "transaction/chaintrackers/*": [ + "dist/types/src/transaction/chaintrackers/*.d.ts" + ], + "transaction/http": [ + "dist/types/src/transaction/http/index.d.ts" + ], + "transaction/http/*": [ + "dist/types/src/transaction/http/*.d.ts" + ], + "transaction/fee-model": [ + "dist/types/src/transaction/fee-models/index.d.ts" + ], + "transaction/fee-model/*": [ + "dist/types/src/transaction/fee-models/*.d.ts" + ], + "transaction/fee-models": [ + "dist/types/src/transaction/fee-models/index.d.ts" + ], + "transaction/fee-models/*": [ + "dist/types/src/transaction/fee-models/*.d.ts" + ], + "messages": [ + "dist/types/src/messages/index.d.ts" + ], + "messages/*": [ + "dist/types/src/messages/*.d.ts" + ], + "compat": [ + "dist/types/src/compat/index.d.ts" + ], + "compat/*": [ + "dist/types/src/compat/*.d.ts" + ], + "totp": [ + "dist/types/src/totp/index.d.ts" + ], + "totp/*": [ + "dist/types/src/totp/*.d.ts" + ], + "wallet": [ + "dist/types/src/wallet/index.d.ts" + ], + "wallet/*": [ + "dist/types/src/wallet/*.d.ts" + ], + "wallet/substrates": [ + "dist/types/src/wallet/substrates/index.d.ts" + ], + "wallet/substrates/*": [ + "dist/types/src/wallet/substrates/*.d.ts" + ], + "auth": [ + "dist/types/src/auth/index.d.ts" + ], + "auth/*": [ + "dist/types/src/auth/*.d.ts" + ], + "auth/certificate": [ + "dist/types/src/auth/certificates/index.d.ts" + ], + "auth/certificate/*": [ + "dist/types/src/auth/certificates/*.d.ts" + ], + "auth/certificates": [ + "dist/types/src/auth/certificates/index.d.ts" + ], + "auth/certificates/*": [ + "dist/types/src/auth/certificates/*.d.ts" + ], + "overlay-tools": [ + "dist/types/src/overlay-tools/index.d.ts" + ], + "overlay-tools/*": [ + "dist/types/src/overlay-tools/*.d.ts" + ], + "telemetry": [ + "dist/types/src/telemetry/index.d.ts" + ], + "telemetry/*": [ + "dist/types/src/telemetry/*.d.ts" + ], + "storage": [ + "dist/types/src/storage/index.d.ts" + ], + "storage/*": [ + "dist/types/src/storage/*.d.ts" + ], + "kvstore": [ + "dist/types/src/kvstore/index.d.ts" + ], + "kvstore/*": [ + "dist/types/src/kvstore/*.d.ts" + ], + "remittance": [ + "dist/types/src/remittance/index.d.ts" + ], + "remittance/*": [ + "dist/types/src/remittance/*.d.ts" + ], + "umd": [ + "dist/types/mod.d.ts" + ], + "*": [ + "dist/types/mod.d.ts" + ] + } } } diff --git a/packages/sdk/rspack.config.js b/packages/sdk/rspack.config.js index 434529113..7aa41719a 100644 --- a/packages/sdk/rspack.config.js +++ b/packages/sdk/rspack.config.js @@ -6,6 +6,7 @@ const __dirname = path.dirname(__filename) export default { mode: 'production', + devtool: 'source-map', entry: './dist/esm/mod.js', output: { filename: 'bundle.js', diff --git a/packages/sdk/src/auth/clients/AuthFetch.ts b/packages/sdk/src/auth/clients/AuthFetch.ts index 6f97cb920..7596f3db5 100644 --- a/packages/sdk/src/auth/clients/AuthFetch.ts +++ b/packages/sdk/src/auth/clients/AuthFetch.ts @@ -3,7 +3,10 @@ import * as Utils from '../../primitives/utils.js' import Random from '../../primitives/Random.js' import P2PKH from '../../script/templates/P2PKH.js' import PublicKey from '../../primitives/PublicKey.js' -import { OriginatorDomainNameStringUnder250Bytes, WalletInterface } from '../../wallet/Wallet.interfaces.js' +import { + OriginatorDomainNameStringUnder250Bytes, + WalletInterface +} from '../../wallet/Wallet.interfaces.js' import { createNonce } from '../utils/createNonce.js' import { Peer } from '../Peer.js' import { SimplifiedFetchTransport } from '../transports/SimplifiedFetchTransport.js' @@ -61,25 +64,30 @@ const PAYMENT_VERSION = '1.0' * AuthFetch provides a lightweight fetch client for interacting with servers * over a simplified HTTP transport mechanism. It integrates session management, peer communication, * and certificate handling to enable secure and mutually-authenticated requests. - * + * * Additionally, it automatically handles 402 Payment Required responses by creating * and sending BSV payment transactions when necessary. */ export class AuthFetch { private readonly sessionManager: SessionManager private readonly wallet: WalletInterface - private callbacks: Record = {} + private callbacks: Record = {} private readonly certificatesReceived: VerifiableCertificate[] = [] private readonly requestedCertificates?: RequestedCertificateSet private readonly originator?: OriginatorDomainNameStringUnder250Bytes peers: Record = {} /** - * Constructs a new AuthFetch instance. - * @param wallet - The wallet instance for signing and authentication. - * @param requestedCertificates - Optional set of certificates to request from peers. - */ - constructor(wallet: WalletInterface, requestedCertificates?: RequestedCertificateSet, sessionManager?: SessionManager | AsyncSessionManager, originator?: OriginatorDomainNameStringUnder250Bytes) { + * Constructs a new AuthFetch instance. + * @param wallet - The wallet instance for signing and authentication. + * @param requestedCertificates - Optional set of certificates to request from peers. + */ + constructor( + wallet: WalletInterface, + requestedCertificates?: RequestedCertificateSet, + sessionManager?: SessionManager | AsyncSessionManager, + originator?: OriginatorDomainNameStringUnder250Bytes + ) { this.wallet = wallet this.requestedCertificates = requestedCertificates // See `Peer.sessionManager`: field stays typed as the synchronous @@ -91,15 +99,15 @@ export class AuthFetch { /** * Mutually authenticates and sends a HTTP request to a server. - * + * * 1) Attempt the request. * 2) If 402 Payment Required, automatically create and send payment. * 3) Return the final response. - * + * * @param url - The URL to send the request to. * @param config - Configuration options for the request, including method, headers, and body. * @returns A promise that resolves with the server's response, structured as a Response-like object. - * + * * @throws Will throw an error if unsupported headers are used or other validation fails. */ async fetch(url: string, config: SimplifiedFetchRequestOptions = {}): Promise { @@ -109,198 +117,213 @@ export class AuthFetch { } config.retryCounter-- } - const response = await new Promise((async (resolve, reject) => { - try { - // Apply defaults - const { method = 'GET', headers = {}, body } = config - - // Extract a base url - const parsedUrl = new URL(url) - const baseURL = parsedUrl.origin - - // Create a new transport for this base url if needed - let peerToUse: AuthPeer - if (this.peers[baseURL] === undefined) { - // Create a peer for the request - const newTransport = new SimplifiedFetchTransport(baseURL) - const newPeer = new Peer(this.wallet, newTransport, this.requestedCertificates, this.sessionManager, undefined, this.originator) - await newPeer.ready - peerToUse = { - peer: newPeer, - pendingCertificateRequests: [] - } - this.peers[baseURL] = peerToUse - this.peers[baseURL].peer.listenForCertificatesReceived((senderPublicKey: string, certs: VerifiableCertificate[]) => { - this.certificatesReceived.push(...certs) - }) - this.peers[baseURL].peer.listenForCertificatesRequested((async (verifier: string, requestedCertificates: RequestedCertificateSet) => { - try { - this.peers[baseURL].pendingCertificateRequests.push(true) - const certificatesToInclude = await getVerifiableCertificates( - this.wallet, - requestedCertificates, - verifier, - this.originator - ) - if (certificatesToInclude.length > 0) { - await this.peers[baseURL].peer.sendCertificateResponse(verifier, certificatesToInclude) - } - } finally { - // Give the backend 500 ms to process the certificates we just sent, before releasing the queue entry - await new Promise(resolve => setTimeout(resolve, 500)) - this.peers[baseURL].pendingCertificateRequests.shift() + const response = await new Promise((resolve, reject) => { + void (async () => { + try { + // Apply defaults + const { method = 'GET', headers = {}, body } = config + + // Extract a base url + const parsedUrl = new URL(url) + const baseURL = parsedUrl.origin + + // Create a new transport for this base url if needed + let peerToUse: AuthPeer + if (this.peers[baseURL] === undefined) { + // Create a peer for the request + const newTransport = new SimplifiedFetchTransport(baseURL) + const newPeer = new Peer( + this.wallet, + newTransport, + this.requestedCertificates, + this.sessionManager, + undefined, + this.originator + ) + await newPeer.ready + peerToUse = { + peer: newPeer, + pendingCertificateRequests: [] } - }) as Function) - } else { - // Check if there's a session associated with this baseURL - if (this.peers[baseURL].supportsMutualAuth === false) { - // Use standard fetch if mutual authentication is not supported - try { - const response = await this.handleFetchAndValidate(url, config, this.peers[baseURL]) - resolve(response) - } catch (error) { - reject(error) + this.peers[baseURL] = peerToUse + this.peers[baseURL].peer.listenForCertificatesReceived( + (senderPublicKey: string, certs: VerifiableCertificate[]) => { + this.certificatesReceived.push(...certs) + } + ) + this.peers[baseURL].peer.listenForCertificatesRequested((async ( + verifier: string, + requestedCertificates: RequestedCertificateSet + ) => { + try { + this.peers[baseURL].pendingCertificateRequests.push(true) + const certificatesToInclude = await getVerifiableCertificates( + this.wallet, + requestedCertificates, + verifier, + this.originator + ) + if (certificatesToInclude.length > 0) { + await this.peers[baseURL].peer.sendCertificateResponse( + verifier, + certificatesToInclude + ) + } + } finally { + // Give the backend 500 ms to process the certificates we just sent, before releasing the queue entry + await new Promise(resolve => setTimeout(resolve, 500)) + this.peers[baseURL].pendingCertificateRequests.shift() + } + }) as Function) + } else { + // Check if there's a session associated with this baseURL + if (this.peers[baseURL].supportsMutualAuth === false) { + // Use standard fetch if mutual authentication is not supported + try { + const response = await this.handleFetchAndValidate(url, config, this.peers[baseURL]) + resolve(response) + } catch (error) { + reject(error) + } + return } - return + peerToUse = this.peers[baseURL] } - peerToUse = this.peers[baseURL] - } - // Serialize the simplified fetch request. - const requestNonce = Random(32) - const requestNonceAsBase64 = Utils.toBase64(requestNonce) + // Serialize the simplified fetch request. + const requestNonce = Random(32) + const requestNonceAsBase64 = Utils.toBase64(requestNonce) + + const writer = await this.serializeRequest(method, headers, body, parsedUrl, requestNonce) + + // Setup general message listener to resolve requests once a response is received + this.callbacks[requestNonceAsBase64] = { resolve, reject } + const listenerId = peerToUse.peer.listenForGeneralMessages( + (senderPublicKey: string, payload: number[]) => { + // Create a reader + const responseReader = new Utils.Reader(payload) + // Deserialize first 32 bytes of payload + const responseNonceAsBase64 = Utils.toBase64(responseReader.read(32)) + if (responseNonceAsBase64 !== requestNonceAsBase64) { + return + } + peerToUse.peer.stopListeningForGeneralMessages(listenerId) + + // Save the identity key for the peer for future requests, since we have it here. + this.peers[baseURL].identityKey = senderPublicKey + this.peers[baseURL].supportsMutualAuth = true + + // Status code + const statusCode = responseReader.readVarIntNum() + + // Headers + const responseHeaders = {} + const nHeaders = responseReader.readVarIntNum() + if (nHeaders > 0) { + for (let i = 0; i < nHeaders; i++) { + const nHeaderKeyBytes = responseReader.readVarIntNum() + const headerKeyBytes = responseReader.read(nHeaderKeyBytes) + const headerKey = Utils.toUTF8(headerKeyBytes) + const nHeaderValueBytes = responseReader.readVarIntNum() + const headerValueBytes = responseReader.read(nHeaderValueBytes) + const headerValue = Utils.toUTF8(headerValueBytes) + responseHeaders[headerKey] = headerValue + } + } - const writer = await this.serializeRequest( - method, - headers, - body, - parsedUrl, - requestNonce - ) + // Add back the server identity key header + responseHeaders['x-bsv-auth-identity-key'] = senderPublicKey - // Setup general message listener to resolve requests once a response is received - this.callbacks[requestNonceAsBase64] = { resolve, reject } - const listenerId = peerToUse.peer.listenForGeneralMessages((senderPublicKey: string, payload: number[]) => { - // Create a reader - const responseReader = new Utils.Reader(payload) - // Deserialize first 32 bytes of payload - const responseNonceAsBase64 = Utils.toBase64(responseReader.read(32)) - if (responseNonceAsBase64 !== requestNonceAsBase64) { - return - } - peerToUse.peer.stopListeningForGeneralMessages(listenerId) - - // Save the identity key for the peer for future requests, since we have it here. - this.peers[baseURL].identityKey = senderPublicKey - this.peers[baseURL].supportsMutualAuth = true - - // Status code - const statusCode = responseReader.readVarIntNum() - - // Headers - const responseHeaders = {} - const nHeaders = responseReader.readVarIntNum() - if (nHeaders > 0) { - for (let i = 0; i < nHeaders; i++) { - const nHeaderKeyBytes = responseReader.readVarIntNum() - const headerKeyBytes = responseReader.read(nHeaderKeyBytes) - const headerKey = Utils.toUTF8(headerKeyBytes) - const nHeaderValueBytes = responseReader.readVarIntNum() - const headerValueBytes = responseReader.read(nHeaderValueBytes) - const headerValue = Utils.toUTF8(headerValueBytes) - responseHeaders[headerKey] = headerValue - } - } + // Body + let responseBody + const responseBodyBytes = responseReader.readVarIntNum() + if (responseBodyBytes > 0) { + responseBody = responseReader.read(responseBodyBytes) + } - // Add back the server identity key header - responseHeaders['x-bsv-auth-identity-key'] = senderPublicKey + // Create the Response object + const responseValue = new Response( + responseBody ? new Uint8Array(responseBody) : null, + { + status: statusCode, + statusText: `${statusCode}`, + headers: new Headers(responseHeaders) + } + ) - // Body - let responseBody - const responseBodyBytes = responseReader.readVarIntNum() - if (responseBodyBytes > 0) { - responseBody = responseReader.read(responseBodyBytes) - } + // Resolve or reject the correct request with the response data + this.callbacks[requestNonceAsBase64].resolve(responseValue) - // Create the Response object - const responseValue = new Response( - responseBody ? new Uint8Array(responseBody) : null, - { - status: statusCode, - statusText: `${statusCode}`, - headers: new Headers(responseHeaders) + // Clean up + delete this.callbacks[requestNonceAsBase64] } ) - // Resolve or reject the correct request with the response data - this.callbacks[requestNonceAsBase64].resolve(responseValue) - - // Clean up - delete this.callbacks[requestNonceAsBase64] - }) - - // Before sending general messages to the peer, ensure that no certificate requests are pending. - // This way, the user would need to choose to either allow or reject the certificate request first. - // If the server has a resource that requires certificates to be sent before access would be granted, - // this makes sure the user has a chance to send the certificates before the resource is requested. - if (peerToUse.pendingCertificateRequests.length > 0) { - const CERTIFICATE_WAIT_TIMEOUT_MS = 30000 - const CHECK_INTERVAL_MS = 100 + // Before sending general messages to the peer, ensure that no certificate requests are pending. + // This way, the user would need to choose to either allow or reject the certificate request first. + // If the server has a resource that requires certificates to be sent before access would be granted, + // this makes sure the user has a chance to send the certificates before the resource is requested. + if (peerToUse.pendingCertificateRequests.length > 0) { + const CERTIFICATE_WAIT_TIMEOUT_MS = 30000 + const CHECK_INTERVAL_MS = 100 + + await new Promise((resolve, reject) => { + const startTime = Date.now() + + const checkPending = (): void => { + if (peerToUse.pendingCertificateRequests.length === 0) { + resolve() + return + } + + if (Date.now() - startTime > CERTIFICATE_WAIT_TIMEOUT_MS) { + reject(new Error('Timeout waiting for certificate request to complete')) + return + } + + setTimeout(checkPending, CHECK_INTERVAL_MS) + } - await new Promise((resolve, reject) => { - const startTime = Date.now() + checkPending() + }) + } - const checkPending = (): void => { - if (peerToUse.pendingCertificateRequests.length === 0) { - resolve() + // Send the request, now that all listeners are set up + await peerToUse.peer + .toPeer(writer.toArray(), peerToUse.identityKey) + .catch(async error => { + const isStaleSession = + error.message.includes('Session not found for nonce') || + (error.message.includes('without valid BSV authentication') && + peerToUse.identityKey != null && + (error as any).details?.status === 401) + if (isStaleSession) { + // Stale session: server no longer recognises the session nonce + // (e.g. after a server restart). Clear the cached peer so a fresh + // handshake is performed on retry. + delete this.peers[baseURL] + config.retryCounter ??= 3 + const response = await this.fetch(url, config) + resolve(response) return } - - if (Date.now() - startTime > CERTIFICATE_WAIT_TIMEOUT_MS) { - reject(new Error('Timeout waiting for certificate request to complete')) - return + if (error.message.includes('HTTP server failed to authenticate')) { + try { + const response = await this.handleFetchAndValidate(url, config, peerToUse) + resolve(response) + return + } catch (fetchError) { + reject(fetchError) + } + } else { + reject(error) } - - setTimeout(checkPending, CHECK_INTERVAL_MS) - } - - checkPending() - }) + }) + } catch (error) { + reject(error) } - - // Send the request, now that all listeners are set up - await peerToUse.peer.toPeer(writer.toArray(), peerToUse.identityKey).catch(async error => { - const isStaleSession = - error.message.includes('Session not found for nonce') || - (error.message.includes('without valid BSV authentication') && - peerToUse.identityKey != null && - (error as any).details?.status === 401) - if (isStaleSession) { - // Stale session: server no longer recognises the session nonce - // (e.g. after a server restart). Clear the cached peer so a fresh - // handshake is performed on retry. - delete this.peers[baseURL] - config.retryCounter ??= 3 - const response = await this.fetch(url, config) - resolve(response) - return - } - if (error.message.includes('HTTP server failed to authenticate')) { - try { - const response = await this.handleFetchAndValidate(url, config, peerToUse) - resolve(response) - return - } catch (fetchError) { - reject(fetchError) - } - } else { - reject(error) - } - }) - } catch (e) { - reject(e) - } - }) as Function) + })() + }) // Check if server requires payment to access the requested route if (response.status === 402) { // Create and attach a payment, then retry @@ -312,10 +335,13 @@ export class AuthFetch { /** * Request Certificates from a Peer - * @param baseUrl - * @param certificatesToRequest + * @param baseUrl + * @param certificatesToRequest */ - async sendCertificateRequest(baseUrl: string, certificatesToRequest: RequestedCertificateSet): Promise { + async sendCertificateRequest( + baseUrl: string, + certificatesToRequest: RequestedCertificateSet + ): Promise { const parsedUrl = new URL(baseUrl) const baseURL = parsedUrl.origin @@ -338,7 +364,7 @@ export class AuthFetch { // Return a promise that resolves when certificates are received const CERTIFICATE_REQUEST_TIMEOUT_MS = 30000 - return await new Promise((async (resolve, reject) => { + return await new Promise((resolve, reject) => { let settled = false const cleanup = (): void => { @@ -348,29 +374,34 @@ export class AuthFetch { } // Set up the listener before making the request - const callbackId = peerToUse.peer.listenForCertificatesReceived((_senderPublicKey: string, certs: VerifiableCertificate[]) => { - if (settled) return - cleanup() - this.certificatesReceived.push(...certs) - resolve(certs) - }) + const callbackId = peerToUse.peer.listenForCertificatesReceived( + (_senderPublicKey: string, certs: VerifiableCertificate[]) => { + if (settled) return + cleanup() + this.certificatesReceived.push(...certs) + resolve(certs) + } + ) const timer = setTimeout(() => { if (settled) return cleanup() - reject(new Error(`sendCertificateRequest timed out after ${CERTIFICATE_REQUEST_TIMEOUT_MS}ms waiting for certificate response from ${baseURL}`)) + reject( + new Error( + `sendCertificateRequest timed out after ${CERTIFICATE_REQUEST_TIMEOUT_MS}ms waiting for certificate response from ${baseURL}` + ) + ) }, CERTIFICATE_REQUEST_TIMEOUT_MS) - try { - // Initiate the certificate request - await peerToUse.peer.requestCertificates(certificatesToRequest, peerToUse.identityKey) - } catch (err) { - if (!settled) { - cleanup() - reject(err) - } - } - }) as Function) + void peerToUse.peer + .requestCertificates(certificatesToRequest, peerToUse.identityKey) + .catch(error => { + if (!settled) { + cleanup() + reject(error) + } + }) + }) } /** @@ -447,7 +478,9 @@ export class AuthFetch { v = v.split(';')[0].trim() includedHeaders.push([k, v]) } else { - throw new Error('Unsupported header in the simplified fetch implementation. Only content-type, authorization, and x-bsv-* headers are supported.') + throw new Error( + 'Unsupported header in the simplified fetch implementation. Only content-type, authorization, and x-bsv-* headers are supported.' + ) } } @@ -493,10 +526,14 @@ export class AuthFetch { return writer } - /** + /** * Handles a non-authenticated fetch requests and validates that the server is not claiming to be authenticated. */ - private async handleFetchAndValidate(url: string, config: RequestInit, peerToUse: AuthPeer): Promise { + private async handleFetchAndValidate( + url: string, + config: RequestInit, + peerToUse: AuthPeer + ): Promise { const response = await fetch(url, config) response.headers.forEach(header => { if (header.toLocaleLowerCase().startsWith('x-bsv')) { @@ -523,7 +560,9 @@ export class AuthFetch { ): Promise { const paymentVersion = originalResponse.headers.get('x-bsv-payment-version') if (!paymentVersion || paymentVersion !== PAYMENT_VERSION) { - throw new Error(`Unsupported x-bsv-payment-version response header. Client version: ${PAYMENT_VERSION}, Server version: ${paymentVersion}`) + throw new Error( + `Unsupported x-bsv-payment-version response header. Client version: ${PAYMENT_VERSION}, Server version: ${paymentVersion}` + ) } const satoshisRequiredHeader = originalResponse.headers.get('x-bsv-payment-satoshis-required') @@ -562,7 +601,11 @@ export class AuthFetch { derivationPrefix ) if (requirementsChanged) { - this.logPaymentAttempt('warn', 'Server adjusted payment requirements; regenerating transaction', this.composePaymentLogDetails(url, paymentContext)) + this.logPaymentAttempt( + 'warn', + 'Server adjusted payment requirements; regenerating transaction', + this.composePaymentLogDetails(url, paymentContext) + ) paymentContext = await this.createPaymentContext( url, config, @@ -574,7 +617,11 @@ export class AuthFetch { } if (paymentContext.attempts >= paymentContext.maxAttempts) { - throw this.buildPaymentFailureError(url, paymentContext, new Error('Maximum payment attempts exceeded before retrying')) + throw this.buildPaymentFailureError( + url, + paymentContext, + new Error('Maximum payment attempts exceeded before retrying') + ) } const headersWithPayment: Record = { @@ -600,11 +647,19 @@ export class AuthFetch { const maxAttempts = paymentContext.maxAttempts paymentContext.attempts = attemptNumber const attemptDetails = this.composePaymentLogDetails(url, paymentContext) - this.logPaymentAttempt('warn', `Attempting paid request (${attemptNumber}/${maxAttempts})`, attemptDetails) + this.logPaymentAttempt( + 'warn', + `Attempting paid request (${attemptNumber}/${maxAttempts})`, + attemptDetails + ) try { const response = await this.fetch(url, nextConfig) - this.logPaymentAttempt('info', `Paid request attempt ${attemptNumber} succeeded`, attemptDetails) + this.logPaymentAttempt( + 'info', + `Paid request attempt ${attemptNumber} succeeded`, + attemptDetails + ) return response } catch (error) { const errorEntry = this.createPaymentErrorEntry(paymentContext.attempts, error) @@ -649,27 +704,44 @@ export class AuthFetch { ): Promise { const derivationSuffix = await createNonce(this.wallet, undefined, this.originator) - const { publicKey: derivedPublicKey } = await this.wallet.getPublicKey({ - protocolID: [2, '3241645161d8'], - keyID: `${derivationPrefix} ${derivationSuffix}`, - counterparty: serverIdentityKey - }, this.originator) - const lockingScript = new P2PKH().lock(PublicKey.fromString(derivedPublicKey).toAddress()).toHex() - - const { tx } = await this.wallet.createAction({ - description: `Payment for request to ${new URL(url).origin}`, - outputs: [{ - satoshis: satoshisRequired, - lockingScript, - customInstructions: JSON.stringify({ derivationPrefix, derivationSuffix, payee: serverIdentityKey }), - outputDescription: 'HTTP request payment' - }], - options: { - randomizeOutputs: false - } - }, this.originator) + const { publicKey: derivedPublicKey } = await this.wallet.getPublicKey( + { + protocolID: [2, '3241645161d8'], + keyID: `${derivationPrefix} ${derivationSuffix}`, + counterparty: serverIdentityKey + }, + this.originator + ) + const lockingScript = new P2PKH() + .lock(PublicKey.fromString(derivedPublicKey).toAddress()) + .toHex() + + const { tx } = await this.wallet.createAction( + { + description: `Payment for request to ${new URL(url).origin}`, + outputs: [ + { + satoshis: satoshisRequired, + lockingScript, + customInstructions: JSON.stringify({ + derivationPrefix, + derivationSuffix, + payee: serverIdentityKey + }), + outputDescription: 'HTTP request payment' + } + ], + options: { + randomizeOutputs: false + } + }, + this.originator + ) - const { publicKey: clientIdentityKey } = await this.wallet.getPublicKey({ identityKey: true }, this.originator) + const { publicKey: clientIdentityKey } = await this.wallet.getPublicKey( + { identityKey: true }, + this.originator + ) return { satoshisRequired, @@ -686,7 +758,8 @@ export class AuthFetch { } private getMaxPaymentAttempts(config: SimplifiedFetchRequestOptions): number { - const attempts = typeof config.paymentRetryAttempts === 'number' ? config.paymentRetryAttempts : undefined + const attempts = + typeof config.paymentRetryAttempts === 'number' ? config.paymentRetryAttempts : undefined if (typeof attempts === 'number' && attempts > 0) { return Math.floor(attempts) } @@ -710,7 +783,7 @@ export class AuthFetch { } } - private describeRequestBodyForLogging(body: any): { type: string, byteLength: number } { + private describeRequestBodyForLogging(body: any): { type: string; byteLength: number } { if (body == null) { return { type: 'none', byteLength: 0 } } @@ -720,7 +793,7 @@ export class AuthFetch { } if (Array.isArray(body)) { - if (body.every((item) => typeof item === 'number')) { + if (body.every(item => typeof item === 'number')) { return { type: 'number[]', byteLength: body.length } } return { type: 'array', byteLength: body.length } @@ -759,7 +832,7 @@ export class AuthFetch { if (typeof serialized === 'string') { return { type: 'object', byteLength: Utils.toArray(serialized, 'utf8').length } } - } catch (_jsonSerializationError) { + } catch { // Ignore JSON serialization issues for logging purposes only } @@ -859,10 +932,10 @@ export class AuthFetch { errors: context.errors } - ; (error as any).details = failureDetails + ;(error as any).details = failureDetails if (lastError instanceof Error) { - ; (error as any).cause = lastError + ;(error as any).cause = lastError } return error @@ -875,7 +948,7 @@ export class AuthFetch { } // 1. number[] - if (Array.isArray(body) && body.every((item) => typeof item === 'number')) { + if (Array.isArray(body) && body.every(item => typeof item === 'number')) { return body // Return the array as is } @@ -886,9 +959,10 @@ export class AuthFetch { // 3. ArrayBuffer / TypedArrays if (body instanceof ArrayBuffer || ArrayBuffer.isView(body)) { - const typedArray = body instanceof ArrayBuffer - ? new Uint8Array(body) - : new Uint8Array(body.buffer, body.byteOffset, body.byteLength) + const typedArray = + body instanceof ArrayBuffer + ? new Uint8Array(body) + : new Uint8Array(body.buffer, body.byteOffset, body.byteLength) return Array.from(typedArray) } diff --git a/packages/sdk/src/compat/ECIES.ts b/packages/sdk/src/compat/ECIES.ts index e14fb83ca..82f7d1202 100644 --- a/packages/sdk/src/compat/ECIES.ts +++ b/packages/sdk/src/compat/ECIES.ts @@ -6,7 +6,7 @@ import Point from '../primitives/Point.js' import * as Hash from '../primitives/Hash.js' import { toArray, toHex, encode } from '../primitives/utils.js' -function AES (key): void { +function AES(key): void { if (this._tables[0][0][0] === 0) this._precompute() let tmp @@ -133,7 +133,11 @@ AES.prototype = { th[d[i] ^ i] = i } - for (x = xInv = 0; sbox[x] === 0; x ^= (x2 === 0 ? 1 : x2), xInv = th[xInv] === 0 ? 1 : th[xInv]) { + for ( + x = xInv = 0; + sbox[x] === 0; + x ^= x2 === 0 ? 1 : x2, xInv = th[xInv] === 0 ? 1 : th[xInv] + ) { // Compute sbox s = xInv ^ (xInv << 1) ^ (xInv << 2) ^ (xInv << 3) ^ (xInv << 4) s = (s >> 8) ^ (s & 255) ^ 99 @@ -192,30 +196,10 @@ AES.prototype = { // Inner rounds. Cribbed from OpenSSL. for (i = 0; i < nInnerRounds; i++) { - a2 = - t0[a >>> 24] ^ - t1[(b >> 16) & 255] ^ - t2[(c >> 8) & 255] ^ - t3[d & 255] ^ - key[kIndex] - b2 = - t0[b >>> 24] ^ - t1[(c >> 16) & 255] ^ - t2[(d >> 8) & 255] ^ - t3[a & 255] ^ - key[kIndex + 1] - c2 = - t0[c >>> 24] ^ - t1[(d >> 16) & 255] ^ - t2[(a >> 8) & 255] ^ - t3[b & 255] ^ - key[kIndex + 2] - d = - t0[d >>> 24] ^ - t1[(a >> 16) & 255] ^ - t2[(b >> 8) & 255] ^ - t3[c & 255] ^ - key[kIndex + 3] + a2 = t0[a >>> 24] ^ t1[(b >> 16) & 255] ^ t2[(c >> 8) & 255] ^ t3[d & 255] ^ key[kIndex] + b2 = t0[b >>> 24] ^ t1[(c >> 16) & 255] ^ t2[(d >> 8) & 255] ^ t3[a & 255] ^ key[kIndex + 1] + c2 = t0[c >>> 24] ^ t1[(d >> 16) & 255] ^ t2[(a >> 8) & 255] ^ t3[b & 255] ^ key[kIndex + 2] + d = t0[d >>> 24] ^ t1[(a >> 16) & 255] ^ t2[(b >> 8) & 255] ^ t3[c & 255] ^ key[kIndex + 3] kIndex += 4 a = a2 b = b2 @@ -243,7 +227,7 @@ AES.prototype = { // eslint-disable-next-line @typescript-eslint/no-extraneous-class class AESWrapper { - public static encrypt (messageBuf: number[], keyBuf: number[]): number[] { + public static encrypt(messageBuf: number[], keyBuf: number[]): number[] { const key = AESWrapper.buf2Words(keyBuf) const message = AESWrapper.buf2Words(messageBuf) const a = new AES(key) @@ -252,7 +236,7 @@ class AESWrapper { return encBuf } - public static decrypt (encBuf: number[], keyBuf: number[]): number[] { + public static decrypt(encBuf: number[], keyBuf: number[]): number[] { const enc = AESWrapper.buf2Words(encBuf) const key = AESWrapper.buf2Words(keyBuf) const a = new AES(key) @@ -261,7 +245,7 @@ class AESWrapper { return messageBuf } - public static buf2Words (buf: number[]): number[] { + public static buf2Words(buf: number[]): number[] { if (buf.length % 4 !== 0) { throw new Error('buf length must be a multiple of 4') } @@ -277,8 +261,8 @@ class AESWrapper { return words } - public static words2Buf (words: number[]): number[] { - const buf = new Array(words.length * 4) + public static words2Buf(words: number[]): number[] { + const buf = Array.from({ length: words.length * 4 }, () => 0) for (let i = 0; i < words.length; i++) { const word = words[i] @@ -294,7 +278,7 @@ class AESWrapper { // eslint-disable-next-line @typescript-eslint/no-extraneous-class class CBC { - public static buf2BlocksBuf (buf: number[], blockSize: number): number[][] { + public static buf2BlocksBuf(buf: number[], blockSize: number): number[][] { const bytesize = blockSize / 8 const blockBufs: number[][] = [] @@ -311,7 +295,7 @@ class CBC { return blockBufs } - public static blockBufs2Buf (blockBufs: number[][]): number[] { + public static blockBufs2Buf(blockBufs: number[][]): number[] { let last = blockBufs.at(-1) last = CBC.pkcs7Unpad(last) blockBufs[blockBufs.length - 1] = last @@ -321,7 +305,7 @@ class CBC { return buf } - public static encrypt ( + public static encrypt( messageBuf: number[], ivBuf: number[], blockCipher: any, @@ -329,17 +313,12 @@ class CBC { ): number[] { const blockSize = ivBuf.length * 8 const blockBufs = CBC.buf2BlocksBuf(messageBuf, blockSize) - const encBufs = CBC.encryptBlocks( - blockBufs, - ivBuf, - blockCipher, - cipherKeyBuf - ) + const encBufs = CBC.encryptBlocks(blockBufs, ivBuf, blockCipher, cipherKeyBuf) const encBuf = encBufs.flat() return encBuf } - public static decrypt ( + public static decrypt( encBuf: number[], ivBuf: number[], blockCipher: any, @@ -350,17 +329,12 @@ class CBC { for (let i = 0; i < encBuf.length / bytesize; i++) { encBufs.push(encBuf.slice(i * bytesize, i * bytesize + bytesize)) } - const blockBufs = CBC.decryptBlocks( - encBufs, - ivBuf, - blockCipher, - cipherKeyBuf - ) + const blockBufs = CBC.decryptBlocks(encBufs, ivBuf, blockCipher, cipherKeyBuf) const buf = CBC.blockBufs2Buf(blockBufs) return buf } - public static encryptBlock ( + public static encryptBlock( blockBuf: number[], ivBuf: number[], blockCipher: any, @@ -371,7 +345,7 @@ class CBC { return encBuf } - public static decryptBlock ( + public static decryptBlock( encBuf: number[], ivBuf: number[], blockCipher: any, @@ -382,7 +356,7 @@ class CBC { return blockBuf } - public static encryptBlocks ( + public static encryptBlocks( blockBufs: number[][], ivBuf: number[], blockCipher: any, @@ -391,12 +365,7 @@ class CBC { const encBufs: number[][] = [] for (const blockBuf of blockBufs) { - const encBuf = CBC.encryptBlock( - blockBuf, - ivBuf, - blockCipher, - cipherKeyBuf - ) + const encBuf = CBC.encryptBlock(blockBuf, ivBuf, blockCipher, cipherKeyBuf) encBufs.push(encBuf) @@ -406,7 +375,7 @@ class CBC { return encBufs } - public static decryptBlocks ( + public static decryptBlocks( encBufs: number[][], ivBuf: number[], blockCipher: any, @@ -415,12 +384,7 @@ class CBC { const blockBufs: number[][] = [] for (const encBuf of encBufs) { - const blockBuf = CBC.decryptBlock( - encBuf, - ivBuf, - blockCipher, - cipherKeyBuf - ) + const blockBuf = CBC.decryptBlock(encBuf, ivBuf, blockCipher, cipherKeyBuf) blockBufs.push(blockBuf) @@ -430,22 +394,19 @@ class CBC { return blockBufs } - public static pkcs7Pad (buf: number[], blockSize: number): number[] { + public static pkcs7Pad(buf: number[], blockSize: number): number[] { const bytesize = blockSize / 8 const padbytesize = bytesize - buf.length - const pad = new Array(padbytesize) + const pad = Array.from({ length: padbytesize }, () => 0) pad.fill(padbytesize) const paddedbuf = [...buf, ...pad] return paddedbuf } - public static pkcs7Unpad (paddedbuf: number[]): number[] { + public static pkcs7Unpad(paddedbuf: number[]): number[] { const padlength = paddedbuf.at(-1) - const padbuf = paddedbuf.slice( - paddedbuf.length - padlength, - paddedbuf.length - ) - const padbuf2 = new Array(padlength) + const padbuf = paddedbuf.slice(paddedbuf.length - padlength, paddedbuf.length) + const padbuf2 = Array.from({ length: padlength }, () => 0) padbuf2.fill(padlength) if (toHex(padbuf) !== toHex(padbuf2)) { throw new Error('invalid padding') @@ -453,12 +414,12 @@ class CBC { return paddedbuf.slice(0, paddedbuf.length - padlength) } - public static xorBufs (buf1: number[], buf2: number[]): number[] { + public static xorBufs(buf1: number[], buf2: number[]): number[] { if (buf1.length !== buf2.length) { throw new Error('bufs must have the same length') } - const buf = new Array(buf1.length) + const buf = Array.from({ length: buf1.length }, () => 0) for (let i = 0; i < buf1.length; i++) { buf[i] = buf1[i] ^ buf2[i] @@ -470,7 +431,7 @@ class CBC { // eslint-disable-next-line @typescript-eslint/no-extraneous-class class AESCBC { - public static encrypt ( + public static encrypt( messageBuf: number[], cipherKeyBuf: number[], ivBuf: number[], @@ -485,11 +446,7 @@ class AESCBC { } } - public static decrypt ( - encBuf: number[], - cipherKeyBuf: number[], - ivBuf?: number[] - ): number[] { + public static decrypt(encBuf: number[], cipherKeyBuf: number[], ivBuf?: number[]): number[] { if (ivBuf == null) { ivBuf = encBuf.slice(0, 128 / 8) const ctBuf = encBuf.slice(128 / 8) @@ -518,10 +475,10 @@ export default class ECIES { * @param {PublicKey} pubKey - The receiver's public key. * @returns {Object} An object containing the iv, kE, and kM as number arrays. */ - public static ivkEkM ( + public static ivkEkM( privKey: PrivateKey, pubKey: PublicKey - ): { iv: number[], kE: number[], kM: number[] } { + ): { iv: number[]; kE: number[]; kM: number[] } { const r = privKey const KB = pubKey const P = KB.mul(r) @@ -544,7 +501,7 @@ export default class ECIES { * @param {boolean} [noKey=false] - If true, does not include the sender's public key in the encrypted message. * @returns {number[]} The encrypted message as a number array. */ - public static electrumEncrypt ( + public static electrumEncrypt( messageBuf: number[], toPublicKey: PublicKey, fromPrivateKey?: PrivateKey, @@ -576,7 +533,7 @@ export default class ECIES { * @param {PublicKey} [fromPublicKey=null] - The public key of the sender. If not provided, it is extracted from the message. * @returns {number[]} The decrypted message as a number array. */ - public static electrumDecrypt ( + public static electrumDecrypt( encBuf: number[], toPrivateKey: PrivateKey, fromPublicKey?: PublicKey @@ -616,10 +573,7 @@ export default class ECIES { const ciphertext = encBuf.slice(offset, encBuf.length - tagLength) const hmac = encBuf.slice(encBuf.length - tagLength, encBuf.length) - const hmac2 = Hash.sha256hmac( - kM, - encBuf.slice(0, encBuf.length - tagLength) - ) + const hmac2 = Hash.sha256hmac(kM, encBuf.slice(0, encBuf.length - tagLength)) if (toHex(hmac) !== toHex(hmac2)) { throw new Error('Invalid checksum') @@ -637,7 +591,7 @@ export default class ECIES { * @param {number[]} [ivBuf] - The initialization vector for encryption. If not provided, a random IV is used. * @returns {number[]} The encrypted message as a number array. */ - public static bitcoreEncrypt ( + public static bitcoreEncrypt( messageBuf: number[], toPublicKey: PublicKey, fromPrivateKey?: PrivateKey, @@ -668,10 +622,7 @@ export default class ECIES { * @param {PrivateKey} toPrivateKey - The private key of the recipient. * @returns {number[]} The decrypted message as a number array. */ - public static bitcoreDecrypt ( - encBuf: number[], - toPrivateKey: PrivateKey - ): number[] { + public static bitcoreDecrypt(encBuf: number[], toPrivateKey: PrivateKey): number[] { const kB = toPrivateKey const fromPublicKey = PublicKey.fromString(toHex(encBuf.slice(0, 33))) const R = fromPublicKey diff --git a/packages/sdk/src/compat/Utxo.ts b/packages/sdk/src/compat/Utxo.ts index f402fd499..51846df49 100644 --- a/packages/sdk/src/compat/Utxo.ts +++ b/packages/sdk/src/compat/Utxo.ts @@ -36,7 +36,7 @@ interface jsonUtxo { * @param unlockingScriptTemplate: { sign: (tx: Transaction, inputIndex: number) => Promise, estimateLength: (tx: Transaction, inputIndex: number) => Promise } * @returns */ -export default function fromUtxo ( +export default function fromUtxo( utxo: jsonUtxo, unlockingScriptTemplate: { sign: (tx: Transaction, inputIndex: number) => Promise @@ -44,7 +44,10 @@ export default function fromUtxo ( } ): TransactionInput { const sourceTransaction = new Transaction(0, [], [], 0) - sourceTransaction.outputs = new Array(utxo.vout + 1).fill(null) + sourceTransaction.outputs = Array.from( + { length: utxo.vout + 1 }, + () => null + ) as unknown as Transaction['outputs'] sourceTransaction.outputs[utxo.vout] = { satoshis: utxo.satoshis, lockingScript: LockingScript.fromHex(utxo.script) diff --git a/packages/sdk/src/identity/ContactsManager.ts b/packages/sdk/src/identity/ContactsManager.ts index e527016d0..f7baee0d5 100644 --- a/packages/sdk/src/identity/ContactsManager.ts +++ b/packages/sdk/src/identity/ContactsManager.ts @@ -11,19 +11,19 @@ const CONTACT_PROTOCOL_ID: WalletProtocol = [2, 'contact'] class MemoryCache { private readonly cache = new Map() - getItem (key: string): string | null { + getItem(key: string): string | null { return this.cache.get(key) ?? null } - setItem (key: string, value: string): void { + setItem(key: string, value: string): void { this.cache.set(key, value) } - removeItem (key: string): void { + removeItem(key: string): void { this.cache.delete(key) } - clear (): void { + clear(): void { this.cache.clear() } } @@ -41,7 +41,7 @@ export class ContactsManager { private inFlightLoad: Promise | null = null private knownEmpty = false - constructor (wallet?: WalletInterface, originator?: string) { + constructor(wallet?: WalletInterface, originator?: string) { this.wallet = wallet ?? new WalletClient() this.originator = originator } @@ -58,7 +58,11 @@ export class ContactsManager { * @param forceRefresh Whether to force a check for new contact data * @param limit Maximum number of contacts to return */ - async getContacts (identityKey?: PubKeyHex, forceRefresh = false, limit = 1000): Promise { + async getContacts( + identityKey?: PubKeyHex, + forceRefresh = false, + limit = 1000 + ): Promise { if (forceRefresh) this.invalidate() if (this.knownEmpty) return [] @@ -78,18 +82,24 @@ export class ContactsManager { } /** Reset cached state. Call after writes. */ - private invalidate (): void { + private invalidate(): void { this.cache.removeItem(this.CONTACTS_CACHE_KEY) this.knownEmpty = false this.inFlightLoad = null } /** Underlying wallet load — invoked at most once concurrently via `inFlightLoad`. */ - private async loadContactsFromWallet (limit: number): Promise { + private async loadContactsFromWallet(limit: number): Promise { // Always load the full basket so subsequent filters (by identityKey) hit cache. // Tag filtering is reserved for explicit per-key write paths. const outputs = await this.wallet.listOutputs( - { basket: 'contacts', include: 'locking scripts', includeCustomInstructions: true, tags: [], limit }, + { + basket: 'contacts', + include: 'locking scripts', + includeCustomInstructions: true, + tags: [], + limit + }, this.originator ) @@ -105,12 +115,14 @@ export class ContactsManager { } /** Returns cached contacts (optionally filtered) or null if cache is missing/invalid. */ - private loadCachedContacts (identityKey?: PubKeyHex): Contact[] | null { + private loadCachedContacts(identityKey?: PubKeyHex): Contact[] | null { const cached = this.cache.getItem(this.CONTACTS_CACHE_KEY) if (cached == null || cached === '') return null try { const cachedContacts: Contact[] = JSON.parse(cached) - return identityKey != null ? cachedContacts.filter(c => c.identityKey === identityKey) : cachedContacts + return identityKey != null + ? cachedContacts.filter(c => c.identityKey === identityKey) + : cachedContacts } catch (e) { console.warn('Invalid cached contacts JSON; will reload from chain', e) return null @@ -118,22 +130,25 @@ export class ContactsManager { } /** Builds the HMAC-based identity-key tag array; empty array if no identity key is given. */ - private async buildIdentityKeyTags (identityKey?: PubKeyHex): Promise { + private async buildIdentityKeyTags(identityKey?: PubKeyHex): Promise { if (identityKey == null) return [] - const { hmac: hashedIdentityKey } = await this.wallet.createHmac({ - protocolID: CONTACT_PROTOCOL_ID, - keyID: identityKey, - counterparty: 'self', - data: Utils.toArray(identityKey, 'utf8') - }, this.originator) + const { hmac: hashedIdentityKey } = await this.wallet.createHmac( + { + protocolID: CONTACT_PROTOCOL_ID, + keyID: identityKey, + counterparty: 'self', + data: Utils.toArray(identityKey, 'utf8') + }, + this.originator + ) return [`identityKey ${Utils.toHex(hashedIdentityKey)}`] } /** Decodes and decrypts all contact outputs in parallel, returning valid Contact objects. */ - private async decryptContactOutputs ( + private async decryptContactOutputs( rawOutputs: Awaited>['outputs'] ): Promise { - const decryptTasks: Array<{ keyID: string, ciphertext: number[] }> = [] + const decryptTasks: Array<{ keyID: string; ciphertext: number[] }> = [] for (const output of rawOutputs) { try { if (output.lockingScript == null || output.customInstructions == null) continue @@ -146,8 +161,17 @@ export class ContactsManager { } const decryptResults = await Promise.allSettled( - decryptTasks.map(async task => - await this.wallet.decrypt({ ciphertext: task.ciphertext, protocolID: CONTACT_PROTOCOL_ID, keyID: task.keyID, counterparty: 'self' }, this.originator) + decryptTasks.map( + async task => + await this.wallet.decrypt( + { + ciphertext: task.ciphertext, + protocolID: CONTACT_PROTOCOL_ID, + keyID: task.keyID, + counterparty: 'self' + }, + this.originator + ) ) ) @@ -171,28 +195,39 @@ export class ContactsManager { * @param contact The displayable identity information for the contact * @param metadata Optional metadata to store with the contact (ex. notes, aliases, etc) */ - async saveContact (contact: DisplayableIdentity, metadata?: Record): Promise { + async saveContact(contact: DisplayableIdentity, metadata?: Record): Promise { const cached = this.cache.getItem(this.CONTACTS_CACHE_KEY) - const contacts: Contact[] = (cached != null && cached !== '') ? JSON.parse(cached) : await this.getContacts() + const contacts: Contact[] = + cached != null && cached !== '' ? JSON.parse(cached) : await this.getContacts() const contactToStore: Contact = { ...contact, metadata } const existingIndex = contacts.findIndex(c => c.identityKey === contact.identityKey) if (existingIndex >= 0) contacts[existingIndex] = contactToStore else contacts.push(contactToStore) const hashedIdentityKey = await this.hashIdentityKey(contact.identityKey) - const outputs = await this.wallet.listOutputs({ - basket: 'contacts', - include: 'entire transactions', - includeCustomInstructions: true, - tags: [`identityKey ${Utils.toHex(hashedIdentityKey)}`], - limit: 100 - }, this.originator) + const outputs = await this.wallet.listOutputs( + { + basket: 'contacts', + include: 'entire transactions', + includeCustomInstructions: true, + tags: [`identityKey ${Utils.toHex(hashedIdentityKey)}`], + limit: 100 + }, + this.originator + ) const { existingOutput, keyID } = await this.findExistingOutput(outputs, contact.identityKey) const lockingScript = await this.encryptAndLock(contactToStore, keyID) if (existingOutput != null) { - await this.updateContactOutput(outputs, existingOutput, lockingScript, keyID, hashedIdentityKey, contact) + await this.updateContactOutput( + outputs, + existingOutput, + lockingScript, + keyID, + hashedIdentityKey, + contact + ) } else { await this.createContactOutput(lockingScript, keyID, hashedIdentityKey, contact) } @@ -202,21 +237,24 @@ export class ContactsManager { } /** Computes the HMAC-based hash of an identity key for tag indexing. */ - private async hashIdentityKey (identityKey: string): Promise { - const { hmac } = await this.wallet.createHmac({ - protocolID: CONTACT_PROTOCOL_ID, - keyID: identityKey, - counterparty: 'self', - data: Utils.toArray(identityKey, 'utf8') - }, this.originator) + private async hashIdentityKey(identityKey: string): Promise { + const { hmac } = await this.wallet.createHmac( + { + protocolID: CONTACT_PROTOCOL_ID, + keyID: identityKey, + counterparty: 'self', + data: Utils.toArray(identityKey, 'utf8') + }, + this.originator + ) return hmac } /** Scans existing outputs to find the one matching the given identity key; returns output + keyID. */ - private async findExistingOutput ( + private async findExistingOutput( outputs: Awaited>, identityKey: string - ): Promise<{ existingOutput: any, keyID: string }> { + ): Promise<{ existingOutput: any; keyID: string }> { let existingOutput: any = null let keyID = Utils.toBase64(Random(32)) if (outputs.outputs == null) return { existingOutput, keyID } @@ -228,28 +266,47 @@ export class ContactsManager { if (output.customInstructions == null) continue keyID = JSON.parse(output.customInstructions).keyID const { plaintext } = await this.wallet.decrypt( - { ciphertext: decoded.fields[0], protocolID: CONTACT_PROTOCOL_ID, keyID, counterparty: 'self' }, this.originator + { + ciphertext: decoded.fields[0], + protocolID: CONTACT_PROTOCOL_ID, + keyID, + counterparty: 'self' + }, + this.originator ) const storedContact: Contact = JSON.parse(Utils.toUTF8(plaintext)) - if (storedContact.identityKey === identityKey) { existingOutput = output; break } - } catch (_malformedOrUndecryptableOutput) { /* skip */ } + if (storedContact.identityKey === identityKey) { + existingOutput = output + break + } + } catch { + /* skip */ + } } return { existingOutput, keyID } } /** Encrypts a contact and produces its PushDrop locking script. */ - private async encryptAndLock (contactData: Contact, keyID: string): Promise { - const { ciphertext } = await this.wallet.encrypt({ - plaintext: Utils.toArray(JSON.stringify(contactData), 'utf8'), - protocolID: CONTACT_PROTOCOL_ID, + private async encryptAndLock(contactData: Contact, keyID: string): Promise { + const { ciphertext } = await this.wallet.encrypt( + { + plaintext: Utils.toArray(JSON.stringify(contactData), 'utf8'), + protocolID: CONTACT_PROTOCOL_ID, + keyID, + counterparty: 'self' + }, + this.originator + ) + return await new PushDrop(this.wallet, this.originator).lock( + [ciphertext], + CONTACT_PROTOCOL_ID, keyID, - counterparty: 'self' - }, this.originator) - return await new PushDrop(this.wallet, this.originator).lock([ciphertext], CONTACT_PROTOCOL_ID, keyID, 'self') + 'self' + ) } /** Spends an existing contact output and creates a replacement with updated data. */ - private async updateContactOutput ( + private async updateContactOutput( outputs: Awaited>, existingOutput: any, lockingScript: LockingScript, @@ -260,49 +317,69 @@ export class ContactsManager { const [txid, outputIndex] = String(existingOutput.outpoint).split('.') const prevOutpoint = `${txid}.${outputIndex}` as const const pushdrop = new PushDrop(this.wallet, this.originator) - const { signableTransaction } = await this.wallet.createAction({ - description: 'Update Contact', - inputBEEF: outputs.BEEF as number[], - inputs: [{ outpoint: prevOutpoint, unlockingScriptLength: 74, inputDescription: 'Spend previous contact output' }], - outputs: [{ - basket: 'contacts', - satoshis: 1, - lockingScript: lockingScript.toHex(), - outputDescription: `Updated Contact: ${contact.name ?? contact.identityKey.slice(0, 10)}`, - tags: [`identityKey ${Utils.toHex(hashedIdentityKey)}`], - customInstructions: JSON.stringify({ keyID }) - }], - options: { acceptDelayedBroadcast: false, randomizeOutputs: false } - }, this.originator) + const { signableTransaction } = await this.wallet.createAction( + { + description: 'Update Contact', + inputBEEF: outputs.BEEF as number[], + inputs: [ + { + outpoint: prevOutpoint, + unlockingScriptLength: 74, + inputDescription: 'Spend previous contact output' + } + ], + outputs: [ + { + basket: 'contacts', + satoshis: 1, + lockingScript: lockingScript.toHex(), + outputDescription: `Updated Contact: ${contact.name ?? contact.identityKey.slice(0, 10)}`, + tags: [`identityKey ${Utils.toHex(hashedIdentityKey)}`], + customInstructions: JSON.stringify({ keyID }) + } + ], + options: { acceptDelayedBroadcast: false, randomizeOutputs: false } + }, + this.originator + ) if (signableTransaction == null) throw new Error('Unable to update contact') - const unlockingScript = await pushdrop.unlock(CONTACT_PROTOCOL_ID, keyID, 'self') + const unlockingScript = await pushdrop + .unlock(CONTACT_PROTOCOL_ID, keyID, 'self') .sign(Transaction.fromBEEF(signableTransaction.tx), 0) - const { tx } = await this.wallet.signAction({ - reference: signableTransaction.reference, - spends: { 0: { unlockingScript: unlockingScript.toHex() } } - }, this.originator) + const { tx } = await this.wallet.signAction( + { + reference: signableTransaction.reference, + spends: { 0: { unlockingScript: unlockingScript.toHex() } } + }, + this.originator + ) if (tx == null) throw new Error('Failed to update contact output') } /** Creates a new on-chain contact output. */ - private async createContactOutput ( + private async createContactOutput( lockingScript: LockingScript, keyID: string, hashedIdentityKey: number[], contact: DisplayableIdentity ): Promise { - const { tx } = await this.wallet.createAction({ - description: 'Add Contact', - outputs: [{ - basket: 'contacts', - satoshis: 1, - lockingScript: lockingScript.toHex(), - outputDescription: `Contact: ${contact.name ?? contact.identityKey.slice(0, 10)}`, - tags: [`identityKey ${Utils.toHex(hashedIdentityKey)}`], - customInstructions: JSON.stringify({ keyID }) - }], - options: { acceptDelayedBroadcast: false, randomizeOutputs: false } - }, this.originator) + const { tx } = await this.wallet.createAction( + { + description: 'Add Contact', + outputs: [ + { + basket: 'contacts', + satoshis: 1, + lockingScript: lockingScript.toHex(), + outputDescription: `Contact: ${contact.name ?? contact.identityKey.slice(0, 10)}`, + tags: [`identityKey ${Utils.toHex(hashedIdentityKey)}`], + customInstructions: JSON.stringify({ keyID }) + } + ], + options: { acceptDelayedBroadcast: false, randomizeOutputs: false } + }, + this.originator + ) if (tx == null) throw new Error('Failed to create contact output') } @@ -310,7 +387,7 @@ export class ContactsManager { * Remove a contact from the contacts basket * @param identityKey The identity key of the contact to remove */ - async removeContact (identityKey: string): Promise { + async removeContact(identityKey: string): Promise { // Update in-memory cache const cached = this.cache.getItem(this.CONTACTS_CACHE_KEY) if (cached != null && cached !== '') { @@ -327,7 +404,13 @@ export class ContactsManager { const tags = await this.buildIdentityKeyTags(identityKey) const outputs = await this.wallet.listOutputs( - { basket: 'contacts', include: 'entire transactions', includeCustomInstructions: true, tags, limit: 100 }, + { + basket: 'contacts', + include: 'entire transactions', + includeCustomInstructions: true, + tags, + limit: 100 + }, this.originator ) if (outputs.outputs == null) return @@ -336,12 +419,14 @@ export class ContactsManager { try { const spent = await this.trySpendContactOutput(output, outputs, identityKey) if (spent) return - } catch (_malformedOrUndecryptableOutput) { /* skip */ } + } catch { + /* skip */ + } } } /** Attempts to decrypt and spend a single output if it matches the given identity key. Returns true if spent. */ - private async trySpendContactOutput ( + private async trySpendContactOutput( output: Awaited>['outputs'][number], outputs: Awaited>, identityKey: string @@ -352,27 +437,46 @@ export class ContactsManager { if (output.customInstructions == null) return false const keyID = JSON.parse(output.customInstructions).keyID const { plaintext } = await this.wallet.decrypt( - { ciphertext: decoded.fields[0], protocolID: CONTACT_PROTOCOL_ID, keyID, counterparty: 'self' }, this.originator + { + ciphertext: decoded.fields[0], + protocolID: CONTACT_PROTOCOL_ID, + keyID, + counterparty: 'self' + }, + this.originator ) const storedContact: Contact = JSON.parse(Utils.toUTF8(plaintext)) if (storedContact.identityKey !== identityKey) return false const prevOutpoint = `${txid}.${outputIndex}` as const const pushdrop = new PushDrop(this.wallet, this.originator) - const { signableTransaction } = await this.wallet.createAction({ - description: 'Delete Contact', - inputBEEF: outputs.BEEF as number[], - inputs: [{ outpoint: prevOutpoint, unlockingScriptLength: 74, inputDescription: 'Spend contact output to delete' }], - outputs: [], - options: { acceptDelayedBroadcast: false, randomizeOutputs: false } - }, this.originator) + const { signableTransaction } = await this.wallet.createAction( + { + description: 'Delete Contact', + inputBEEF: outputs.BEEF as number[], + inputs: [ + { + outpoint: prevOutpoint, + unlockingScriptLength: 74, + inputDescription: 'Spend contact output to delete' + } + ], + outputs: [], + options: { acceptDelayedBroadcast: false, randomizeOutputs: false } + }, + this.originator + ) if (signableTransaction == null) throw new Error('Unable to delete contact') - const unlockingScript = await pushdrop.unlock(CONTACT_PROTOCOL_ID, keyID, 'self') + const unlockingScript = await pushdrop + .unlock(CONTACT_PROTOCOL_ID, keyID, 'self') .sign(Transaction.fromBEEF(signableTransaction.tx), 0) - const { tx: deleteTx } = await this.wallet.signAction({ - reference: signableTransaction.reference, - spends: { 0: { unlockingScript: unlockingScript.toHex() } } - }, this.originator) + const { tx: deleteTx } = await this.wallet.signAction( + { + reference: signableTransaction.reference, + spends: { 0: { unlockingScript: unlockingScript.toHex() } } + }, + this.originator + ) if (deleteTx == null) throw new Error('Failed to delete contact output') return true } diff --git a/packages/sdk/src/identity/IdentityClient.ts b/packages/sdk/src/identity/IdentityClient.ts index c8c5c9ea4..51fb13e2f 100644 --- a/packages/sdk/src/identity/IdentityClient.ts +++ b/packages/sdk/src/identity/IdentityClient.ts @@ -1,4 +1,9 @@ -import { DEFAULT_IDENTITY_CLIENT_OPTIONS, defaultIdentity, DisplayableIdentity, KNOWN_IDENTITY_TYPES } from './types/index.js' +import { + DEFAULT_IDENTITY_CLIENT_OPTIONS, + defaultIdentity, + DisplayableIdentity, + KNOWN_IDENTITY_TYPES +} from './types/index.js' import { Base64String, CertificateFieldNameUnder50Bytes, @@ -15,7 +20,12 @@ import { BroadcastFailure, BroadcastResponse, Transaction } from '../transaction import Certificate from '../auth/certificates/Certificate.js' import { PushDrop } from '../script/index.js' import { PrivateKey, Utils } from '../primitives/index.js' -import { LookupResolver, SHIPBroadcaster, TopicBroadcaster, withDoubleSpendRetry } from '../overlay-tools/index.js' +import { + LookupResolver, + SHIPBroadcaster, + TopicBroadcaster, + withDoubleSpendRetry +} from '../overlay-tools/index.js' import { ContactsManager, Contact } from './ContactsManager.js' /** @@ -29,12 +39,12 @@ const PARSE_BATCH_SIZE = 32 * Yield control to the event loop so queued microtasks / timers can run. Uses * `scheduler.yield()` when available (Chromium) or a 0ms macrotask fallback. */ -async function yieldToEventLoop (): Promise { +async function yieldToEventLoop(): Promise { const sched = (globalThis as any).scheduler if (sched != null && typeof sched.yield === 'function') { return sched.yield() } - return await new Promise((resolve) => setTimeout(resolve, 0)) + return await new Promise(resolve => setTimeout(resolve, 0)) } /** Options for {@link IdentityClient.resolveByIdentityKey}. */ @@ -77,9 +87,9 @@ export interface ResolveByAttributesOptions { } /** Normalize either legacy boolean / new options object into a canonical { useContacts, parallel }. */ -function normalizeOpts ( +function normalizeOpts( raw: boolean | ResolveByIdentityKeyOptions | ResolveByAttributesOptions | undefined -): { useContacts: boolean, parallel: boolean } { +): { useContacts: boolean; parallel: boolean } { if (raw === undefined) return { useContacts: false, parallel: false } if (typeof raw === 'boolean') return { useContacts: raw, parallel: false } const useContacts = raw.overrideWithContacts ?? raw.useContacts ?? false @@ -92,12 +102,11 @@ function normalizeOpts ( export class IdentityClient { private readonly wallet: WalletInterface private readonly contactsManager: ContactsManager - constructor ( + constructor( wallet?: WalletInterface, private readonly options = DEFAULT_IDENTITY_CLIENT_OPTIONS, private readonly originator?: OriginatorDomainNameStringUnder250Bytes ) { - this.originator = originator this.wallet = wallet ?? new WalletClient() this.contactsManager = new ContactsManager(this.wallet, this.originator) } @@ -112,19 +121,15 @@ export class IdentityClient { * @returns {Promise} A promise that resolves with the broadcast result from the overlay network. * @throws {Error} Throws an error if the certificate is invalid, the fields cannot be revealed, or if the broadcast fails. */ - async publiclyRevealAttributes ( + async publiclyRevealAttributes( certificate: WalletCertificate, fieldsToReveal: CertificateFieldNameUnder50Bytes[] ): Promise { if (Object.keys(certificate.fields).length === 0) { - throw new Error( - 'Public reveal failed: Certificate has no fields to reveal!' - ) + throw new Error('Public reveal failed: Certificate has no fields to reveal!') } if (fieldsToReveal.length === 0) { - throw new Error( - 'Public reveal failed: You must reveal at least one field!' - ) + throw new Error('Public reveal failed: You must reveal at least one field!') } try { const masterCert = new Certificate( @@ -137,7 +142,7 @@ export class IdentityClient { certificate.signature ) await masterCert.verify() - } catch (_certVerificationError) { + } catch { // Low-level cert error details are suppressed — surface a user-facing message only throw new Error('Public reveal failed: Certificate verification failed!') } @@ -155,11 +160,7 @@ export class IdentityClient { // Build the lockingScript with pushdrop.create() and the transaction with createAction() const lockingScript = await new PushDrop(this.wallet, this.originator).lock( - [ - Utils.toArray( - JSON.stringify({ ...certificate, keyring: keyringForVerifier }) - ) - ], + [Utils.toArray(JSON.stringify({ ...certificate, keyring: keyringForVerifier }))], this.options.protocolID, this.options.keyID, 'anyone', @@ -211,7 +212,7 @@ export class IdentityClient { * @param args - Arguments for requesting the discovery based on the identity key. * @param opts - Boolean (legacy) or options object. Boolean `true` ≡ `{ useContacts: true }`. */ - async resolveByIdentityKey ( + async resolveByIdentityKey( args: DiscoverByIdentityKeyArgs, opts: boolean | ResolveByIdentityKeyOptions = false ): Promise { @@ -253,7 +254,7 @@ export class IdentityClient { * @param args - Attributes and optional parameters used to discover certificates. * @param opts - Boolean (legacy) or options object. Boolean `true` ≡ `{ useContacts: true }`. */ - async resolveByAttributes ( + async resolveByAttributes( args: DiscoverByAttributesArgs, opts: boolean | ResolveByAttributesOptions = false ): Promise { @@ -275,7 +276,7 @@ export class IdentityClient { const certs = certificatesResult?.certificates ?? [] if (contacts.length === 0) return await IdentityClient.parseIdentities(certs) const contactByKey = new Map( - contacts.map((contact) => [contact.identityKey, contact] as const) + contacts.map(contact => [contact.identityKey, contact] as const) ) return await IdentityClient.parseIdentitiesWithOverrides(certs, contactByKey) } @@ -288,7 +289,7 @@ export class IdentityClient { const certs = certificatesResult?.certificates ?? [] if (contacts.length === 0) return await IdentityClient.parseIdentities(certs) const contactByKey = new Map( - contacts.map((contact) => [contact.identityKey, contact] as const) + contacts.map(contact => [contact.identityKey, contact] as const) ) return await IdentityClient.parseIdentitiesWithOverrides(certs, contactByKey) } @@ -299,17 +300,17 @@ export class IdentityClient { * can be skipped. Compares string-valued attributes against same-named fields on the contact's * decrypted record. Returns the subset of contacts that match every supplied attribute. */ - private matchContactsByAttributes ( + private matchContactsByAttributes( contacts: Contact[], args: DiscoverByAttributesArgs ): Contact[] { - const attrs = (args).attributes + const attrs = args.attributes if (attrs == null || typeof attrs !== 'object' || Array.isArray(attrs)) return [] const entries = Object.entries(attrs as Record).filter( ([, v]) => typeof v === 'string' && v.length > 0 ) as Array<[string, string]> if (entries.length === 0) return [] - return contacts.filter((contact) => { + return contacts.filter(contact => { const bag: Record = { name: contact.name, identityKey: contact.identityKey @@ -325,9 +326,7 @@ export class IdentityClient { * Remove public certificate revelation from overlay services by spending the identity token * @param serialNumber - Unique serial number of the certificate to revoke revelation */ - async revokeCertificateRevelation ( - serialNumber: Base64String - ): Promise { + async revokeCertificateRevelation(serialNumber: Base64String): Promise { // 1. Find existing UTXO const lookupResolver = new LookupResolver({ networkPreset: (await this.wallet.getNetwork({})).network @@ -339,7 +338,9 @@ export class IdentityClient { } }) - if (result.type !== 'output-list') { throw new Error('Failed to get lookup result') } + if (result.type !== 'output-list') { + throw new Error('Failed to get lookup result') + } const topicBroadcaster = new SHIPBroadcaster(['tm_identity'], { networkPreset: (await this.wallet.getNetwork({})).network, @@ -390,10 +391,7 @@ export class IdentityClient { 'anyone' ) - const unlockingScript = await unlocker.sign( - partialTx, - this.options.outputIndex - ) + const unlockingScript = await unlocker.sign(partialTx, this.options.outputIndex) const { tx: signedTx } = await this.wallet.signAction( { @@ -426,16 +424,12 @@ export class IdentityClient { * @param limit Optional limit on number of contacts to fetch * @returns A promise that resolves with an array of contacts */ - public async getContacts ( + public async getContacts( identityKey?: PubKeyHex, forceRefresh = false, limit = 1000 ): Promise { - return await this.contactsManager.getContacts( - identityKey, - forceRefresh, - limit - ) + return await this.contactsManager.getContacts(identityKey, forceRefresh, limit) } /** @@ -443,7 +437,7 @@ export class IdentityClient { * @param contact The displayable identity information for the contact * @param metadata Optional metadata to store with the contact (ex. notes, aliases, etc) */ - public async saveContact ( + public async saveContact( contact: DisplayableIdentity, metadata?: Record ): Promise { @@ -454,7 +448,7 @@ export class IdentityClient { * Remove a contact from the contacts basket * @param identityKey The identity key of the contact to remove */ - public async removeContact (identityKey: PubKeyHex): Promise { + public async removeContact(identityKey: PubKeyHex): Promise { return await this.contactsManager.removeContact(identityKey) } @@ -463,12 +457,12 @@ export class IdentityClient { * event loop every {@link PARSE_BATCH_SIZE} entries so large result sets don't hog * the main thread. Equivalent to `certs.map(parseIdentity)` for small inputs. */ - static async parseIdentities (certs: IdentityCertificate[]): Promise { + static async parseIdentities(certs: IdentityCertificate[]): Promise { const n = certs.length if (n <= PARSE_BATCH_SIZE) { - return certs.map((c) => IdentityClient.parseIdentity(c)) + return certs.map(c => IdentityClient.parseIdentity(c)) } - const out: DisplayableIdentity[] = new Array(n) + const out: DisplayableIdentity[] = Array.from({ length: n }) for (let i = 0; i < n; i++) { out[i] = IdentityClient.parseIdentity(certs[i]) if ((i + 1) % PARSE_BATCH_SIZE === 0) await yieldToEventLoop() @@ -480,15 +474,15 @@ export class IdentityClient { * Same as {@link parseIdentities} but consults a contact override map keyed by subject * identity key. Used by `resolveByAttributes` when contacts are loaded. */ - static async parseIdentitiesWithOverrides ( + static async parseIdentitiesWithOverrides( certs: IdentityCertificate[], contactByKey: Map ): Promise { const n = certs.length if (n <= PARSE_BATCH_SIZE) { - return certs.map((cert) => contactByKey.get(cert.subject) ?? IdentityClient.parseIdentity(cert)) + return certs.map(cert => contactByKey.get(cert.subject) ?? IdentityClient.parseIdentity(cert)) } - const out: DisplayableIdentity[] = new Array(n) + const out: DisplayableIdentity[] = Array.from({ length: n }) for (let i = 0; i < n; i++) { const cert = certs[i] out[i] = contactByKey.get(cert.subject) ?? IdentityClient.parseIdentity(cert) @@ -502,9 +496,7 @@ export class IdentityClient { * @param identityToParse - The Identity Certificate to parse * @returns - IdentityToDisplay */ - static parseIdentity ( - identityToParse: IdentityCertificate - ): DisplayableIdentity { + static parseIdentity(identityToParse: IdentityCertificate): DisplayableIdentity { const { type, decryptedFields, certifierInfo } = identityToParse let name, avatarURL, badgeLabel, badgeIconURL, badgeClickURL @@ -558,8 +550,7 @@ export class IdentityClient { case KNOWN_IDENTITY_TYPES.anyone: name = 'Anyone' avatarURL = 'XUT4bpQ6cpBaXi1oMzZsXfpkWGbtp2JTUYAoN7PzhStFJ6wLfoeR' - badgeLabel = - 'Represents the ability for anyone to access this information.' + badgeLabel = 'Represents the ability for anyone to access this information.' badgeIconURL = 'XUUV39HVPkpmMzYNTx7rpKzJvXfeiVyQWg2vfSpjBAuhunTCA9uG' badgeClickURL = 'https://bsv-blockchain.github.io/ts-sdk/reference/identity/' // (no dedicated page yet) break @@ -589,9 +580,7 @@ export class IdentityClient { name, avatarURL, abbreviatedKey: - identityToParse.subject.length > 0 - ? `${identityToParse.subject.substring(0, 10)}...` - : '', + identityToParse.subject.length > 0 ? `${identityToParse.subject.substring(0, 10)}...` : '', identityKey: identityToParse.subject, badgeIconURL, badgeLabel, @@ -602,7 +591,7 @@ export class IdentityClient { /** * Helper to check if a value is a non-empty string */ - private static hasValue (value: any): value is string { + private static hasValue(value: any): value is string { return value !== undefined && value !== null && value !== '' } @@ -610,17 +599,17 @@ export class IdentityClient { * Try to parse identity information from unknown certificate types * by checking common field names */ - private static tryToParseGenericIdentity ( + private static tryToParseGenericIdentity( type: string, decryptedFields: Record, certifierInfo: any ): { - name: string - avatarURL: string - badgeLabel: string - badgeIconURL: string - badgeClickURL: string - } { + name: string + avatarURL: string + badgeLabel: string + badgeIconURL: string + badgeClickURL: string + } { // Try to construct a name from common field patterns const firstName = decryptedFields.firstName const lastName = decryptedFields.lastName diff --git a/packages/sdk/src/kvstore/GlobalKVStore.ts b/packages/sdk/src/kvstore/GlobalKVStore.ts index 57649a568..9d8cd3dfc 100644 --- a/packages/sdk/src/kvstore/GlobalKVStore.ts +++ b/packages/sdk/src/kvstore/GlobalKVStore.ts @@ -2,14 +2,30 @@ import Transaction from '../transaction/Transaction.js' import * as Utils from '../primitives/utils.js' import { TopicBroadcaster, LookupResolver, withDoubleSpendRetry } from '../overlay-tools/index.js' import { BroadcastResponse, BroadcastFailure } from '../transaction/Broadcaster.js' -import { WalletInterface, WalletProtocol, CreateActionInput, OutpointString, PubKeyHex, CreateActionOutput, HexString } from '../wallet/Wallet.interfaces.js' +import { + WalletInterface, + WalletProtocol, + CreateActionInput, + OutpointString, + PubKeyHex, + CreateActionOutput, + HexString +} from '../wallet/Wallet.interfaces.js' import { PushDrop } from '../script/index.js' import WalletClient from '../wallet/WalletClient.js' import { Beef } from '../transaction/Beef.js' import { Historian } from '../overlay-tools/Historian.js' import { KVContext, kvStoreInterpreter } from './kvStoreInterpreter.js' import { ProtoWallet } from '../wallet/ProtoWallet.js' -import { kvProtocol, KVStoreConfig, KVStoreQuery, KVStoreEntry, KVStoreGetOptions, KVStoreSetOptions, KVStoreRemoveOptions } from './types.js' +import { + kvProtocol, + KVStoreConfig, + KVStoreQuery, + KVStoreEntry, + KVStoreGetOptions, + KVStoreSetOptions, + KVStoreRemoveOptions +} from './types.js' /** * Default configuration values for GlobalKVStore operations. @@ -69,7 +85,8 @@ export class GlobalKVStore { * A map to store locks for each key to ensure atomic updates. * @private */ - private readonly keyLocks: Map) => void>> = new Map() + private readonly keyLocks: Map) => void>> = + new Map() /** * Cached user identity key @@ -84,7 +101,7 @@ export class GlobalKVStore { * @param {WalletInterface} [config.wallet] - Wallet to use for operations. Defaults to WalletClient. * @throws {Error} If the configuration contains invalid parameters. */ - constructor (config: KVStoreConfig = {}) { + constructor(config: KVStoreConfig = {}) { // Merge with defaults to create a fully resolved config this.config = { ...DEFAULT_CONFIG, ...config } this.wallet = config.wallet ?? new WalletClient() @@ -101,11 +118,13 @@ export class GlobalKVStore { // networkPreset-only construction. // `hostOverrides` / `slapTrackers` are passed straight through; LookupResolver // already falls back to its defaults when they're undefined. - this.lookupResolver = this.config.lookupResolver ?? new LookupResolver({ - networkPreset: this.config.networkPreset, - hostOverrides: this.config.hostOverrides, - slapTrackers: this.config.slapTrackers - }) + this.lookupResolver = + this.config.lookupResolver ?? + new LookupResolver({ + networkPreset: this.config.networkPreset, + hostOverrides: this.config.hostOverrides, + slapTrackers: this.config.slapTrackers + }) this.topicBroadcaster = new TopicBroadcaster(this.config.topics as string[], { networkPreset: this.config.networkPreset, resolver: this.lookupResolver @@ -120,7 +139,10 @@ export class GlobalKVStore { * @param {KVStoreGetOptions} [options={}] - Configuration options for the get operation * @returns {Promise} Single entry for key+controller queries, array for all other queries */ - async get (query: KVStoreQuery, options: KVStoreGetOptions = {}): Promise { + async get( + query: KVStoreQuery, + options: KVStoreGetOptions = {} + ): Promise { this.validateQuerySelectors(query) if (query.key != null && query.controller != null) { // Specific key+controller query - return single entry @@ -136,7 +158,7 @@ export class GlobalKVStore { * @param {KVStoreQuery} query - Query parameters sent to overlay. * @throws {Error} If the query does not include a valid selector. */ - private validateQuerySelectors (query: KVStoreQuery): void { + private validateQuerySelectors(query: KVStoreQuery): void { const hasSelector = (typeof query.key === 'string' && query.key.length > 0) || (typeof query.controller === 'string' && query.controller.length > 0) || @@ -156,7 +178,7 @@ export class GlobalKVStore { * @param {KVStoreSetOptions} [options={}] - Configuration options for the set operation * @returns {Promise} The outpoint of the created token */ - async set (key: string, value: string, options: KVStoreSetOptions = {}): Promise { + async set(key: string, value: string, options: KVStoreSetOptions = {}): Promise { if (typeof key !== 'string' || key.length === 0) { throw new Error('Key must be a non-empty string.') } @@ -167,8 +189,14 @@ export class GlobalKVStore { const controller = await this.getIdentityKey() const lockQueue = await this.queueOperationOnKey(key) const protocolID = options.protocolID ?? this.config.protocolID - const tokenSetDescription = (options.tokenSetDescription != null && options.tokenSetDescription !== '') ? options.tokenSetDescription : `Create KVStore value for ${key}` - const tokenUpdateDescription = (options.tokenUpdateDescription != null && options.tokenUpdateDescription !== '') ? options.tokenUpdateDescription : `Update KVStore value for ${key}` + const tokenSetDescription = + options.tokenSetDescription != null && options.tokenSetDescription !== '' + ? options.tokenSetDescription + : `Create KVStore value for ${key}` + const tokenUpdateDescription = + options.tokenUpdateDescription != null && options.tokenUpdateDescription !== '' + ? options.tokenUpdateDescription + : `Update KVStore value for ${key}` const tokenAmount = options.tokenAmount ?? this.config.tokenAmount const tags = options.tags ?? [] @@ -189,7 +217,7 @@ export class GlobalKVStore { const lockingScript = await pushdrop.lock( lockingScriptFields, - protocolID ?? this.config.protocolID as WalletProtocol, + protocolID ?? (this.config.protocolID as WalletProtocol), Utils.toUTF8(Utils.toArray(key, 'utf8')), 'anyone', true @@ -203,19 +231,24 @@ export class GlobalKVStore { if (existingToken == null) { // Create new token - const { tx } = await this.wallet.createAction({ - description: tokenSetDescription, - outputs: [{ - satoshis: tokenAmount ?? this.config.tokenAmount as number, - lockingScript: lockingScript.toHex(), - outputDescription: 'KVStore token' - }], - options: { - acceptDelayedBroadcast: this.config.acceptDelayedBroadcast, - noSend: this.config.overlayBroadcast, - randomizeOutputs: false - } - }, this.config.originator) + const { tx } = await this.wallet.createAction( + { + description: tokenSetDescription, + outputs: [ + { + satoshis: tokenAmount ?? (this.config.tokenAmount as number), + lockingScript: lockingScript.toHex(), + outputDescription: 'KVStore token' + } + ], + options: { + acceptDelayedBroadcast: this.config.acceptDelayedBroadcast, + noSend: this.config.overlayBroadcast, + randomizeOutputs: false + } + }, + this.config.originator + ) if (tx == null) { throw new Error('Failed to create transaction') @@ -226,49 +259,55 @@ export class GlobalKVStore { return `${transaction.id('hex')}.0` } else { // Update existing token - const inputs: CreateActionInput[] = [{ - outpoint: `${existingToken.txid}.${existingToken.outputIndex}`, - unlockingScriptLength: 74, - inputDescription: 'Previous KVStore token' - }] + const inputs: CreateActionInput[] = [ + { + outpoint: `${existingToken.txid}.${existingToken.outputIndex}`, + unlockingScriptLength: 74, + inputDescription: 'Previous KVStore token' + } + ] const inputBEEF = existingToken.beef - const { signableTransaction } = await this.wallet.createAction({ - description: tokenUpdateDescription, - inputBEEF: inputBEEF.toBinary(), - inputs, - outputs: [{ - satoshis: tokenAmount ?? this.config.tokenAmount as number, - lockingScript: lockingScript.toHex(), - outputDescription: 'KVStore token' - }], - options: { - acceptDelayedBroadcast: this.config.acceptDelayedBroadcast, - noSend: this.config.overlayBroadcast, - randomizeOutputs: false - } - }, this.config.originator) + const { signableTransaction } = await this.wallet.createAction( + { + description: tokenUpdateDescription, + inputBEEF: inputBEEF.toBinary(), + inputs, + outputs: [ + { + satoshis: tokenAmount ?? (this.config.tokenAmount as number), + lockingScript: lockingScript.toHex(), + outputDescription: 'KVStore token' + } + ], + options: { + acceptDelayedBroadcast: this.config.acceptDelayedBroadcast, + noSend: this.config.overlayBroadcast, + randomizeOutputs: false + } + }, + this.config.originator + ) if (signableTransaction == null) { throw new Error('Unable to create update transaction') } const tx = Transaction.fromAtomicBEEF(signableTransaction.tx) - const unlocker = pushdrop.unlock( - this.config.protocolID as WalletProtocol, - key, - 'anyone' - ) + const unlocker = pushdrop.unlock(this.config.protocolID as WalletProtocol, key, 'anyone') const unlockingScript = await unlocker.sign(tx, 0) - const { tx: finalTx } = await this.wallet.signAction({ - reference: signableTransaction.reference, - spends: { 0: { unlockingScript: unlockingScript.toHex() } }, - options: { - acceptDelayedBroadcast: this.config.acceptDelayedBroadcast, - noSend: this.config.overlayBroadcast - } - }, this.config.originator) + const { tx: finalTx } = await this.wallet.signAction( + { + reference: signableTransaction.reference, + spends: { 0: { unlockingScript: unlockingScript.toHex() } }, + options: { + acceptDelayedBroadcast: this.config.acceptDelayedBroadcast, + noSend: this.config.overlayBroadcast + } + }, + this.config.originator + ) if (finalTx == null) { throw new Error('Unable to finalize update transaction') @@ -300,7 +339,11 @@ export class GlobalKVStore { * @throws {Error} If the overlay service is unreachable or the transaction fails. * @throws {Error} If there are existing tokens that cannot be unlocked. */ - async remove (key: string, outputs?: CreateActionOutput[], options: KVStoreRemoveOptions = {}): Promise { + async remove( + key: string, + outputs?: CreateActionOutput[], + options: KVStoreRemoveOptions = {} + ): Promise { if (typeof key !== 'string' || key.length === 0) { throw new Error('Key must be a non-empty string.') } @@ -309,7 +352,10 @@ export class GlobalKVStore { const lockQueue = await this.queueOperationOnKey(key) const protocolID = options.protocolID ?? this.config.protocolID - const tokenRemovalDescription = (options.tokenRemovalDescription != null && options.tokenRemovalDescription !== '') ? options.tokenRemovalDescription : `Remove KVStore value for ${key}` + const tokenRemovalDescription = + options.tokenRemovalDescription != null && options.tokenRemovalDescription !== '' + ? options.tokenRemovalDescription + : `Remove KVStore value for ${key}` try { const pushdrop = new PushDrop(this.wallet, this.config.originator) @@ -324,23 +370,28 @@ export class GlobalKVStore { } const existingToken = existingEntries[0].token - const inputs: CreateActionInput[] = [{ - outpoint: `${existingToken.txid}.${existingToken.outputIndex}`, - unlockingScriptLength: 74, - inputDescription: 'KVStore token to remove' - }] - - const { signableTransaction } = await this.wallet.createAction({ - description: tokenRemovalDescription, - inputBEEF: existingToken.beef.toBinary(), - inputs, - outputs, - options: { - acceptDelayedBroadcast: this.config.acceptDelayedBroadcast, - randomizeOutputs: false, - noSend: this.config.overlayBroadcast + const inputs: CreateActionInput[] = [ + { + outpoint: `${existingToken.txid}.${existingToken.outputIndex}`, + unlockingScriptLength: 74, + inputDescription: 'KVStore token to remove' } - }, this.config.originator) + ] + + const { signableTransaction } = await this.wallet.createAction( + { + description: tokenRemovalDescription, + inputBEEF: existingToken.beef.toBinary(), + inputs, + outputs, + options: { + acceptDelayedBroadcast: this.config.acceptDelayedBroadcast, + randomizeOutputs: false, + noSend: this.config.overlayBroadcast + } + }, + this.config.originator + ) if (signableTransaction == null) { throw new Error('Unable to create removal transaction') @@ -348,20 +399,23 @@ export class GlobalKVStore { const tx = Transaction.fromAtomicBEEF(signableTransaction.tx) const unlocker = pushdrop.unlock( - protocolID ?? this.config.protocolID as WalletProtocol, + protocolID ?? (this.config.protocolID as WalletProtocol), key, 'anyone' ) const unlockingScript = await unlocker.sign(tx, 0) - const { tx: finalTx } = await this.wallet.signAction({ - reference: signableTransaction.reference, - spends: { 0: { unlockingScript: unlockingScript.toHex() } }, - options: { - acceptDelayedBroadcast: this.config.acceptDelayedBroadcast, - noSend: this.config.overlayBroadcast - } - }, this.config.originator) + const { tx: finalTx } = await this.wallet.signAction( + { + reference: signableTransaction.reference, + spends: { 0: { unlockingScript: unlockingScript.toHex() } }, + options: { + acceptDelayedBroadcast: this.config.acceptDelayedBroadcast, + noSend: this.config.overlayBroadcast + } + }, + this.config.originator + ) if (finalTx == null) { throw new Error('Unable to finalize removal transaction') @@ -388,7 +442,9 @@ export class GlobalKVStore { * @returns {Promise) => void>>} The lock queue for cleanup. * @private */ - private async queueOperationOnKey (key: string): Promise) => void>> { + private async queueOperationOnKey( + key: string + ): Promise) => void>> { // Check if a lock exists for this key and wait for it to resolve let lockQueue = this.keyLocks.get(key) if (lockQueue == null) { @@ -396,10 +452,12 @@ export class GlobalKVStore { this.keyLocks.set(key, lockQueue) } - let resolveNewLock: () => void = () => { } - const newLock = new Promise((resolve) => { + let resolveNewLock: () => void = () => {} + const newLock = new Promise(resolve => { resolveNewLock = resolve - if (lockQueue != null) { lockQueue.push(resolve) } + if (lockQueue != null) { + lockQueue.push(resolve) + } }) // If we are the only request, resolve the lock immediately, queue remains at 1 item until request ends. @@ -418,7 +476,10 @@ export class GlobalKVStore { * @param {Array<(value: void | PromiseLike) => void>} lockQueue - The lock queue from queueOperationOnKey. * @private */ - private finishOperationOnKey (key: string, lockQueue: Array<(value: void | PromiseLike) => void>): void { + private finishOperationOnKey( + key: string, + lockQueue: Array<(value: void | PromiseLike) => void> + ): void { lockQueue.shift() // Remove the current lock from the queue if (lockQueue.length > 0) { // If there are more locks waiting, resolve the next one @@ -435,8 +496,10 @@ export class GlobalKVStore { * @returns {Promise} The identity key of the current user * @private */ - private async getIdentityKey (): Promise { - this.cachedIdentityKey ??= (await this.wallet.getPublicKey({ identityKey: true }, this.config.originator)).publicKey + private async getIdentityKey(): Promise { + this.cachedIdentityKey ??= ( + await this.wallet.getPublicKey({ identityKey: true }, this.config.originator) + ).publicKey return this.cachedIdentityKey } @@ -448,9 +511,12 @@ export class GlobalKVStore { * @returns {Promise} Array of matching KV entries * @private */ - private async queryOverlay (query: KVStoreQuery, options: KVStoreGetOptions = {}): Promise { + private async queryOverlay( + query: KVStoreQuery, + options: KVStoreGetOptions = {} + ): Promise { const answer = await this.lookupResolver.query({ - service: options.serviceName ?? this.config.serviceName as string, + service: options.serviceName ?? (this.config.serviceName as string), query }) @@ -486,7 +552,7 @@ export class GlobalKVStore { protocolID: JSON.parse(Utils.toUTF8(decoded.fields[kvProtocol.protocolID])), keyID: Utils.toUTF8(decoded.fields[kvProtocol.key]) }) - } catch (_signatureVerificationError) { + } catch { // Skip all outputs that fail signature verification continue } @@ -496,7 +562,7 @@ export class GlobalKVStore { if (hasTagsField && decoded.fields[kvProtocol.tags] != null) { try { tags = JSON.parse(Utils.toUTF8(decoded.fields[kvProtocol.tags])) - } catch (_tagsParseError) { + } catch { // If tags parsing fails, continue without tags tags = undefined } @@ -527,7 +593,7 @@ export class GlobalKVStore { } entries.push(entry) - } catch (_malformedOutputError) { + } catch { // Skip malformed or undecodable outputs rather than failing the entire query continue } @@ -545,7 +611,9 @@ export class GlobalKVStore { * @throws {Error} If the broadcast fails or the network is unreachable. * @private */ - private async submitToOverlay (transaction: Transaction): Promise { + private async submitToOverlay( + transaction: Transaction + ): Promise { return await this.topicBroadcaster.broadcast(transaction) } } diff --git a/packages/sdk/src/overlay-tools/HostReputationTracker.ts b/packages/sdk/src/overlay-tools/HostReputationTracker.ts index 54c8fb5e1..14daf99bc 100644 --- a/packages/sdk/src/overlay-tools/HostReputationTracker.ts +++ b/packages/sdk/src/overlay-tools/HostReputationTracker.ts @@ -38,27 +38,27 @@ export class HostReputationTracker { private readonly store: KeyValueStore | undefined private saveTimer: ReturnType | null = null - constructor (store?: KeyValueStore) { + constructor(store?: KeyValueStore) { this.stats = new Map() this.store = store ?? this.getLocalStorageAdapter() this.loadFromStorage() } - reset (): void { + reset(): void { this.stats.clear() this.scheduleSave() } - recordSuccess (host: string, latencyMs: number): void { + recordSuccess(host: string, latencyMs: number): void { const entry = this.getOrCreate(host) const now = Date.now() - const safeLatency = Number.isFinite(latencyMs) && latencyMs >= 0 ? latencyMs : DEFAULT_LATENCY_MS + const safeLatency = + Number.isFinite(latencyMs) && latencyMs >= 0 ? latencyMs : DEFAULT_LATENCY_MS if (entry.avgLatencyMs === null) { entry.avgLatencyMs = safeLatency } else { entry.avgLatencyMs = - (1 - LATENCY_SMOOTHING_FACTOR) * entry.avgLatencyMs + - LATENCY_SMOOTHING_FACTOR * safeLatency + (1 - LATENCY_SMOOTHING_FACTOR) * entry.avgLatencyMs + LATENCY_SMOOTHING_FACTOR * safeLatency } entry.lastLatencyMs = safeLatency entry.totalSuccesses += 1 @@ -69,7 +69,7 @@ export class HostReputationTracker { this.scheduleSave() } - recordFailure (host: string, reason?: unknown): void { + recordFailure(host: string, reason?: unknown): void { const entry = this.getOrCreate(host) const now = Date.now() entry.totalFailures += 1 @@ -112,7 +112,7 @@ export class HostReputationTracker { this.scheduleSave() } - rankHosts (hosts: string[], now: number = Date.now()): RankedHost[] { + rankHosts(hosts: string[], now: number = Date.now()): RankedHost[] { const seen = new Map() hosts.forEach((host, idx) => { if (typeof host !== 'string' || host.length === 0) return @@ -120,7 +120,7 @@ export class HostReputationTracker { }) const orderedHosts = Array.from(seen.keys()) - const ranked = orderedHosts.map((host) => { + const ranked = orderedHosts.map(host => { const entry = this.getOrCreate(host) return { ...entry, @@ -138,16 +138,16 @@ export class HostReputationTracker { return (a as any).originalOrder - (b as any).originalOrder }) - return ranked.map(({ originalOrder, ...rest }) => rest) + return ranked.map(({ originalOrder: _originalOrder, ...rest }) => rest) } - snapshot (host: string): HostReputationEntry | undefined { + snapshot(host: string): HostReputationEntry | undefined { const entry = this.stats.get(host) return entry == null ? undefined : { ...entry } } /** Flushes a pending debounced persistence write immediately. */ - flush (): void { + flush(): void { if (this.saveTimer !== null) { clearTimeout(this.saveTimer) this.saveTimer = null @@ -155,7 +155,7 @@ export class HostReputationTracker { this.saveToStorage() } - private getStorage (): any { + private getStorage(): any { try { const g: any = typeof globalThis === 'object' ? globalThis : undefined if (g?.localStorage == null) return undefined @@ -165,20 +165,26 @@ export class HostReputationTracker { } } - private getLocalStorageAdapter (): KeyValueStore | undefined { + private getLocalStorageAdapter(): KeyValueStore | undefined { const s = this.getStorage() if (s == null) return undefined return { get: (key: string) => { - try { return s.getItem(key) } catch { return null } + try { + return s.getItem(key) + } catch { + return null + } }, set: (key: string, value: string) => { - try { s.setItem(key, value) } catch { } + try { + s.setItem(key, value) + } catch {} } } } - private loadFromStorage (): void { + private loadFromStorage(): void { const s = this.store if (s == null) return try { @@ -196,7 +202,7 @@ export class HostReputationTracker { if (typeof data !== 'object' || data === null) return this.stats.clear() for (const k of Object.keys(data)) { - const v: any = (data)[k] + const v: any = data[k] if (v != null && typeof v === 'object') { const entry: HostReputationEntry = { host: String(v.host ?? k), @@ -216,7 +222,7 @@ export class HostReputationTracker { } catch {} } - private scheduleSave (): void { + private scheduleSave(): void { if (this.store == null || this.saveTimer !== null) return this.saveTimer = setTimeout(() => { this.saveTimer = null @@ -226,7 +232,7 @@ export class HostReputationTracker { timer.unref?.() } - private saveToStorage (): void { + private saveToStorage(): void { const s = this.store if (s == null) return try { @@ -239,7 +245,7 @@ export class HostReputationTracker { } catch {} } - private computeScore (entry: HostReputationEntry, now: number): number { + private computeScore(entry: HostReputationEntry, now: number): number { const latency = entry.avgLatencyMs ?? DEFAULT_LATENCY_MS const failurePenalty = entry.consecutiveFailures * FAILURE_PENALTY_MS const successBonus = Math.min(entry.totalSuccesses * SUCCESS_BONUS_MS, latency / 2) @@ -248,7 +254,7 @@ export class HostReputationTracker { return latency + failurePenalty + backoffPenalty - successBonus } - private getOrCreate (host: string): HostReputationEntry { + private getOrCreate(host: string): HostReputationEntry { let entry = this.stats.get(host) if (entry == null) { this.prune(Date.now()) @@ -268,7 +274,7 @@ export class HostReputationTracker { return entry } - private prune (now: number): void { + private prune(now: number): void { for (const [host, entry] of this.stats) { if (entry.lastUpdatedAt > 0 && now - entry.lastUpdatedAt > REPUTATION_ENTRY_TTL_MS) { this.stats.delete(host) @@ -277,7 +283,7 @@ export class HostReputationTracker { while (this.stats.size > MAX_REPUTATION_ENTRIES) this.evictOldestEntry() } - private evictOldestEntry (): void { + private evictOldestEntry(): void { let oldestHost: string | undefined let oldestUpdatedAt = Number.POSITIVE_INFINITY for (const [host, entry] of this.stats) { diff --git a/packages/sdk/src/overlay-tools/LookupResolver.ts b/packages/sdk/src/overlay-tools/LookupResolver.ts index 4edf2d2e2..6fc7d1026 100644 --- a/packages/sdk/src/overlay-tools/LookupResolver.ts +++ b/packages/sdk/src/overlay-tools/LookupResolver.ts @@ -27,17 +27,16 @@ export interface LookupQuestion { } /** An aggregatable output-list answer returned by the resolver. */ -export type LookupAnswer = - | { - type: 'output-list' - outputs: Array<{ - beef: number[] - outputIndex: number - context?: number[] - /** Optional txid hint. When present, consumers can skip re-parsing beef to derive the txid. */ - txid?: string - }> - } +export type LookupAnswer = { + type: 'output-list' + outputs: Array<{ + beef: number[] + outputIndex: number + context?: number[] + /** Optional txid hint. When present, consumers can skip re-parsing beef to derive the txid. */ + txid?: string + }> +} /** A valid non-aggregatable response returned by a lookup service. */ export interface LookupFreeformAnswer { @@ -115,7 +114,7 @@ export interface UnreachableHostInfo { */ export interface LookupAnswerProgress { type: 'output-list' - outputs: Array<{ beef: number[], outputIndex: number, context?: number[], txid?: string }> + outputs: Array<{ beef: number[]; outputIndex: number; context?: number[]; txid?: string }> /** Parallel array of resolved tx ids for each output (same index as `outputs`). */ txIds: string[] /** True only for the final emission, after every in-flight host has settled. */ @@ -180,10 +179,9 @@ export class LookupHTTPError extends Error { readonly status: number readonly kind: LookupHTTPErrorKind - constructor (status: number, kind: LookupHTTPErrorKind, statusText?: string) { - const detail = typeof statusText === 'string' && statusText.trim().length > 0 - ? ` ${statusText.trim()}` - : '' + constructor(status: number, kind: LookupHTTPErrorKind, statusText?: string) { + const detail = + typeof statusText === 'string' && statusText.trim().length > 0 ? ` ${statusText.trim()}` : '' super(`Failed to facilitate lookup (HTTP ${status}${detail})`) this.name = 'LookupHTTPError' this.status = status @@ -192,31 +190,41 @@ export class LookupHTTPError extends Error { } /** True when an HTTP response rejects this query without proving host outage. */ -function isSemanticLookupRejection (err: unknown): boolean { +function isSemanticLookupRejection(err: unknown): boolean { return err instanceof LookupHTTPError && err.kind === 'semantic' } -function isByteArray (value: unknown): value is number[] { - return Array.isArray(value) && value.every((byte) => Number.isInteger(byte) && byte >= 0 && byte <= 255) +function isByteArray(value: unknown): value is number[] { + return ( + Array.isArray(value) && value.every(byte => Number.isInteger(byte) && byte >= 0 && byte <= 255) + ) } -function isLookupOutput (value: unknown): value is LookupAnswer['outputs'][number] { +function isLookupOutput(value: unknown): value is LookupAnswer['outputs'][number] { if (typeof value !== 'object' || value === null) return false const output = value as Record if (!isByteArray(output.beef) || output.beef.length === 0) return false if (!Number.isInteger(output.outputIndex) || (output.outputIndex as number) < 0) return false if (output.context !== undefined && !isByteArray(output.context)) return false - if (output.txid !== undefined && (typeof output.txid !== 'string' || !/^[0-9a-fA-F]{64}$/.test(output.txid))) return false + if ( + output.txid !== undefined && + (typeof output.txid !== 'string' || !/^[0-9a-fA-F]{64}$/.test(output.txid)) + ) + return false return true } -function isOutputListAnswer (value: unknown): value is LookupAnswer { +function isOutputListAnswer(value: unknown): value is LookupAnswer { if (typeof value !== 'object' || value === null) return false const answer = value as Record - return answer.type === 'output-list' && Array.isArray(answer.outputs) && answer.outputs.every(isLookupOutput) + return ( + answer.type === 'output-list' && + Array.isArray(answer.outputs) && + answer.outputs.every(isLookupOutput) + ) } -function isFreeformAnswer (value: unknown): value is LookupFreeformAnswer { +function isFreeformAnswer(value: unknown): value is LookupFreeformAnswer { if (typeof value !== 'object' || value === null) return false const answer = value as Record return answer.type === 'freeform' && Object.prototype.hasOwnProperty.call(answer, 'result') @@ -232,13 +240,17 @@ interface Deadline { didTimeOut: () => boolean } -function createDeadline (timeoutMs: number, controller?: AbortController): Deadline { +function createDeadline(timeoutMs: number, controller?: AbortController): Deadline { let expired = false let timer: ReturnType | null = null const promise = new Promise((_resolve, reject) => { timer = setTimeout(() => { expired = true - try { controller?.abort() } catch { /* noop */ } + try { + controller?.abort() + } catch { + /* noop */ + } reject(new Error('Request timed out')) }, timeoutMs) }) @@ -251,7 +263,7 @@ function createDeadline (timeoutMs: number, controller?: AbortController): Deadl } } -function normalizeLookupError (err: unknown, timedOut: boolean): Error { +function normalizeLookupError(err: unknown, timedOut: boolean): Error { if (timedOut) return new Error('Request timed out') if ((err as { name?: string })?.name === 'AbortError') return new Error('Request timed out') if (err instanceof Error) return err @@ -262,7 +274,7 @@ function normalizeLookupError (err: unknown, timedOut: boolean): Error { * Coerce a non-Error thrown value to a human-readable string without falling * back to the default `'[object Object]'` for plain objects. */ -function stringifyErrorValue (value: unknown): string { +function stringifyErrorValue(value: unknown): string { if (value === null) return 'null' if (value === undefined) return 'undefined' if (typeof value === 'string') return value @@ -283,7 +295,7 @@ function stringifyErrorValue (value: unknown): string { * `application/octet-stream`, ignoring case and any media-type parameters * (e.g. `; charset=utf-8`). */ -function isOctetStream (contentType: string | null): boolean { +function isOctetStream(contentType: string | null): boolean { if (typeof contentType !== 'string') return false const baseType = contentType.split(';', 1)[0].trim().toLowerCase() return baseType === 'application/octet-stream' @@ -319,7 +331,9 @@ export interface LookupResolverConfig { /** Optional cache tuning. */ cache?: CacheOptions /** Optional storage for host reputation data. */ - reputationStorage?: 'localStorage' | { get: (key: string) => string | null | undefined, set: (key: string, value: string) => void } + reputationStorage?: + | 'localStorage' + | { get: (key: string) => string | null | undefined; set: (key: string, value: string) => void } /** Optional privacy-bounded telemetry sink. Query payloads are never emitted. */ telemetry?: TelemetryConfig } @@ -344,26 +358,24 @@ export class HTTPSOverlayLookupFacilitator implements OverlayLookupFacilitator { fetchClient: typeof fetch allowHTTP: boolean - constructor (httpClient = defaultFetch, allowHTTP: boolean = false) { + constructor(httpClient = defaultFetch, allowHTTP: boolean = false) { if (typeof httpClient !== 'function') { throw new TypeError( 'HTTPSOverlayLookupFacilitator requires a fetch implementation. ' + - 'In environments without fetch, provide a polyfill or custom implementation.' + 'In environments without fetch, provide a polyfill or custom implementation.' ) } this.fetchClient = httpClient this.allowHTTP = allowHTTP } - async lookup ( + async lookup( url: string, question: LookupQuestion, timeout: number = 2000 ): Promise { if (!url.startsWith('https:') && !this.allowHTTP) { - throw new Error( - 'HTTPS facilitator can only use URLs that start with "https:"' - ) + throw new Error('HTTPS facilitator can only use URLs that start with "https:"') } const controller = typeof AbortController === 'undefined' ? undefined : new AbortController() @@ -376,7 +388,9 @@ export class HTTPSOverlayLookupFacilitator implements OverlayLookupFacilitator { // the consumer-facing promise always settles within `timeout` ms. const fetchPromise = this.performLookupRequest(url, question, controller?.signal) // Swallow background rejection if the deadline wins first. - fetchPromise.catch(() => { /* noop */ }) + fetchPromise.catch(() => { + /* noop */ + }) try { return await Promise.race([fetchPromise, deadline.promise]) @@ -387,7 +401,7 @@ export class HTTPSOverlayLookupFacilitator implements OverlayLookupFacilitator { } } - private async performLookupRequest ( + private async performLookupRequest( url: string, question: LookupQuestion, signal: AbortSignal | undefined @@ -407,7 +421,11 @@ export class HTTPSOverlayLookupFacilitator implements OverlayLookupFacilitator { // reject this request but do not prove that the host is unavailable, so // they remain distinguishable and neutral for availability reputation. const kind: LookupHTTPErrorKind = - response.status < 400 || response.status === 408 || response.status === 425 || response.status === 429 || response.status >= 500 + response.status < 400 || + response.status === 408 || + response.status === 425 || + response.status === 429 || + response.status >= 500 ? 'availability' : 'semantic' throw new LookupHTTPError(response.status, kind, response.statusText) @@ -419,11 +437,11 @@ export class HTTPSOverlayLookupFacilitator implements OverlayLookupFacilitator { } /** Parse the aggregated octet-stream lookup response into an output-list LookupAnswer. */ - private async parseOctetStreamLookup (response: Response): Promise { + private async parseOctetStreamLookup(response: Response): Promise { const payload = await response.arrayBuffer() const r = new Utils.Reader([...new Uint8Array(payload)]) const nOutpoints = r.readVarIntNum() - const outpoints: Array<{ txid: string, outputIndex: number, context?: number[] }> = [] + const outpoints: Array<{ txid: string; outputIndex: number; context?: number[] }> = [] for (let i = 0; i < nOutpoints; i++) { const txid = Utils.toHex(r.read(32)) const outputIndex = r.readVarIntNum() @@ -438,12 +456,17 @@ export class HTTPSOverlayLookupFacilitator implements OverlayLookupFacilitator { } /** Memoize per-txid atomic BEEF extraction, yielding to the event loop between outputs. */ - private async extractAtomicOutputs ( - outpoints: Array<{ txid: string, outputIndex: number, context?: number[] }>, + private async extractAtomicOutputs( + outpoints: Array<{ txid: string; outputIndex: number; context?: number[] }>, beefObj: Beef - ): Promise> { + ): Promise> { const beefByTxid = new Map() - const outputs: Array<{ outputIndex: number, context?: number[], beef: number[], txid: string }> = new Array(outpoints.length) + const outputs: Array<{ + outputIndex: number + context?: number[] + beef: number[] + txid: string + }> = Array.from({ length: outpoints.length }) for (let idx = 0; idx < outpoints.length; idx++) { const x = outpoints[idx] let beefBytes = beefByTxid.get(x.txid) @@ -451,10 +474,15 @@ export class HTTPSOverlayLookupFacilitator implements OverlayLookupFacilitator { beefBytes = beefObj.toBinaryAtomic(x.txid) beefByTxid.set(x.txid, beefBytes) } - outputs[idx] = { outputIndex: x.outputIndex, context: x.context, beef: beefBytes, txid: x.txid } + outputs[idx] = { + outputIndex: x.outputIndex, + context: x.context, + beef: beefBytes, + txid: x.txid + } // Yield to event loop so UI animations and other JS don't starve. if (idx > 0 && idx < outpoints.length - 1) { - await new Promise((resolve) => setTimeout(resolve, 0)) + await new Promise(resolve => setTimeout(resolve, 0)) } } return outputs @@ -474,12 +502,12 @@ export default class LookupResolver { private readonly telemetry: Telemetry // ---- Caches / memoization ---- - private readonly hostsCache: Map + private readonly hostsCache: Map private readonly hostsInFlight: Map> private readonly hostsTtlMs: number private readonly hostsMaxEntries: number - private readonly txMemo: Map + private readonly txMemo: Map private readonly txMemoTtlMs: number /** @@ -490,10 +518,14 @@ export default class LookupResolver { private readonly advertisedBy: Map private readonly lastUnreachableNotificationAt: Map - constructor (config: LookupResolverConfig = {}) { + constructor(config: LookupResolverConfig = {}) { this.networkPreset = config.networkPreset ?? 'mainnet' - this.facilitator = config.facilitator ?? new HTTPSOverlayLookupFacilitator(undefined, this.networkPreset === 'local') - this.slapTrackers = config.slapTrackers ?? (this.networkPreset === 'mainnet' ? DEFAULT_SLAP_TRACKERS : DEFAULT_TESTNET_SLAP_TRACKERS) + this.facilitator = + config.facilitator ?? + new HTTPSOverlayLookupFacilitator(undefined, this.networkPreset === 'local') + this.slapTrackers = + config.slapTrackers ?? + (this.networkPreset === 'mainnet' ? DEFAULT_SLAP_TRACKERS : DEFAULT_TESTNET_SLAP_TRACKERS) const hostOverrides = config.hostOverrides ?? {} this.assertValidOverrideServices(hostOverrides) this.hostOverrides = hostOverrides @@ -503,7 +535,12 @@ export default class LookupResolver { const rs = config.reputationStorage if (rs === 'localStorage') { this.hostReputation = new HostReputationTracker() - } else if (typeof rs === 'object' && rs !== null && typeof rs.get === 'function' && typeof rs.set === 'function') { + } else if ( + typeof rs === 'object' && + rs !== null && + typeof rs.get === 'function' && + typeof rs.set === 'function' + ) { this.hostReputation = new HostReputationTracker(rs) } else { this.hostReputation = getOverlayHostReputationTracker() @@ -528,7 +565,7 @@ export default class LookupResolver { * Optional `options.softTimeoutMs` resolves the query early with whatever has arrived once any host has * answered (or with an empty result if no host has answered by `softTimeoutMs`). */ - async query ( + async query( question: LookupQuestion, timeout?: number, options?: LookupQueryOptions @@ -541,7 +578,7 @@ export default class LookupResolver { * evidence required by security-sensitive consumers to distinguish an * authoritative empty result from an availability failure. */ - async queryDetailed ( + async queryDetailed( question: LookupQuestion, timeout?: number, options?: LookupQueryOptions @@ -599,14 +636,15 @@ export default class LookupResolver { * * No host work runs past its per-host `timeout` — there is no leak risk on early break. */ - async * query$ ( + async *query$( question: LookupQuestion, timeout?: number, options?: LookupQueryOptions ): AsyncIterable { let competentHosts: string[] = [] if (question.service === 'ls_slap') { - competentHosts = this.networkPreset === 'local' ? ['http://localhost:8080'] : this.slapTrackers + competentHosts = + this.networkPreset === 'local' ? ['http://localhost:8080'] : this.slapTrackers } else if (this.hostOverrides[question.service] != null) { competentHosts = this.hostOverrides[question.service] } else if (this.networkPreset === 'local') { @@ -631,10 +669,7 @@ export default class LookupResolver { // SLAP-eligible services (no overrides, not local, not ls_slap itself). let rankedHosts: string[] try { - rankedHosts = this.prepareHostsForQuery( - competentHosts, - `lookup service ${question.service}` - ) + rankedHosts = this.prepareHostsForQuery(competentHosts, `lookup service ${question.service}`) } catch (err) { const isSlapEligible = question.service !== 'ls_slap' && @@ -658,29 +693,35 @@ export default class LookupResolver { } // Re-rank — if SLAP returned the same hosts and they're all still in // backoff, propagate the original error. - rankedHosts = this.prepareHostsForQuery( - fresh, - `lookup service ${question.service}` - ) + rankedHosts = this.prepareHostsForQuery(fresh, `lookup service ${question.service}`) } if (rankedHosts.length < 1) { - throw new Error(`All competent hosts for ${question.service} are temporarily unavailable due to backoff.`) + throw new Error( + `All competent hosts for ${question.service} are temporarily unavailable due to backoff.` + ) } const graceMs = options?.graceMs ?? 80 const softTimeoutMs = options?.softTimeoutMs const onUnreachableHost = options?.onUnreachableHost const requestedNotificationCooldownMs = options?.unreachableHostNotificationCooldownMs - const notificationCooldownMs = typeof requestedNotificationCooldownMs === 'number' && Number.isFinite(requestedNotificationCooldownMs) && requestedNotificationCooldownMs >= 0 - ? requestedNotificationCooldownMs - : DEFAULT_UNREACHABLE_NOTIFICATION_COOLDOWN_MS + const notificationCooldownMs = + typeof requestedNotificationCooldownMs === 'number' && + Number.isFinite(requestedNotificationCooldownMs) && + requestedNotificationCooldownMs >= 0 + ? requestedNotificationCooldownMs + : DEFAULT_UNREACHABLE_NOTIFICATION_COOLDOWN_MS const waitForAllHosts = options?.waitForAllHosts ?? options?.holdForUnknownHosts ?? false const hostCount = rankedHosts.length - const correlationId = options?.correlationId ?? + const correlationId = + options?.correlationId ?? (this.telemetry.enabled ? this.telemetry.createCorrelationId() : undefined) const lookupStartedAt = Date.now() - const outputsMap = new Map() + const outputsMap = new Map< + string, + { beef: number[]; context?: number[]; outputIndex: number } + >() const txIds: string[] = [] let completedHosts = 0 let successfulHosts = 0 @@ -704,7 +745,7 @@ export default class LookupResolver { }) type Event = - | { kind: 'answer', answer: LookupAnswer } + | { kind: 'answer'; answer: LookupAnswer } | { kind: 'done' } | { kind: 'grace' } | { kind: 'soft' } @@ -722,7 +763,7 @@ export default class LookupResolver { for (const host of rankedHosts) { const hostStartedAt = Date.now() void this.lookupHostWithTracking(host, question, timeout) - .then((answer) => { + .then(answer => { if (isOutputListAnswer(answer)) { successfulHosts++ if (answer.outputs.length === 0) emptyHosts++ @@ -745,7 +786,7 @@ export default class LookupResolver { ) } }) - .catch((err) => { + .catch(err => { const semanticRejection = isSemanticLookupRejection(err) if (semanticRejection) rejectedHosts++ else failedHosts++ @@ -760,7 +801,8 @@ export default class LookupResolver { if (!isSemanticLookupRejection(err) && typeof onUnreachableHost === 'function') { const notificationKey = `${question.service}\u0000${host}` const now = Date.now() - const lastNotificationAt = this.lastUnreachableNotificationAt.get(notificationKey) ?? Number.NEGATIVE_INFINITY + const lastNotificationAt = + this.lastUnreachableNotificationAt.get(notificationKey) ?? Number.NEGATIVE_INFINITY if (now - lastNotificationAt < notificationCooldownMs) return if (this.lastUnreachableNotificationAt.size >= MAX_NOTIFICATION_DEDUP_ENTRIES) { this.evictOldest(this.lastUnreachableNotificationAt) @@ -773,8 +815,12 @@ export default class LookupResolver { error: err instanceof Error ? err.message : String(err), advertisedBy: this.advertisedBy.get(host) }) - void Promise.resolve(callbackResult).catch(() => { /* consumer callback is isolated */ }) - } catch { /* never let a consumer callback break the query */ } + void Promise.resolve(callbackResult).catch(() => { + /* consumer callback is isolated */ + }) + } catch { + /* never let a consumer callback break the query */ + } } }) .finally(() => { @@ -827,7 +873,9 @@ export default class LookupResolver { while (true) { if (completedHosts >= hostCount) break if (queue.length === 0) { - await new Promise((resolve) => { waiter = resolve }) + await new Promise(resolve => { + waiter = resolve + }) } const e = queue.shift() as Event if (e.kind === 'answer') { @@ -904,7 +952,7 @@ export default class LookupResolver { /** * Cached wrapper for competent host discovery with stale-while-revalidate. */ - private async getCompetentHostsCached (service: string): Promise { + private async getCompetentHostsCached(service: string): Promise { const now = Date.now() const cached = this.hostsCache.get(service) @@ -916,9 +964,12 @@ export default class LookupResolver { // if stale but present, kick off a refresh if not already in-flight and return stale if (typeof cached === 'object' && cached.expiresAt <= now) { if (!this.hostsInFlight.has(service)) { - this.hostsInFlight.set(service, this.refreshHosts(service).finally(() => { - this.hostsInFlight.delete(service) - })) + this.hostsInFlight.set( + service, + this.refreshHosts(service).finally(() => { + this.hostsInFlight.delete(service) + }) + ) } return cached.hosts.slice() } @@ -947,7 +998,10 @@ export default class LookupResolver { /** * Actually resolves competent hosts from SLAP trackers and updates cache. */ - private async refreshHosts (service: string, requireAvailable: boolean = false): Promise { + private async refreshHosts( + service: string, + requireAvailable: boolean = false + ): Promise { const hosts = await this.findCompetentHosts(service, requireAvailable) const expiresAt = Date.now() + this.hostsTtlMs @@ -963,7 +1017,7 @@ export default class LookupResolver { /** * Extracts competent host domains from a SLAP tracker response. */ - private extractHostsFromAnswer (answer: LookupAnswer, service: string): string[] { + private extractHostsFromAnswer(answer: LookupAnswer, service: string): string[] { const hosts: string[] = [] if (answer.type !== 'output-list') return hosts for (const output of answer.outputs) { @@ -990,29 +1044,31 @@ export default class LookupResolver { * @param service Service for which competent hosts are to be returned * @returns Array of hosts competent for resolving queries */ - private async findCompetentHosts (service: string, requireAvailable: boolean = false): Promise { + private async findCompetentHosts( + service: string, + requireAvailable: boolean = false + ): Promise { const query: LookupQuestion = { service: 'ls_slap', query: { service } } - const trackerHosts = this.prepareHostsForQuery( - this.slapTrackers, - 'SLAP trackers' - ) + const trackerHosts = this.prepareHostsForQuery(this.slapTrackers, 'SLAP trackers') if (trackerHosts.length === 0) return [] // Fire all trackers, resolve as soon as any returns valid hosts. // Remaining trackers continue in the background for reputation tracking. - return await new Promise((resolve) => { + return await new Promise(resolve => { const allHosts = new Set() let resolved = false let pending = trackerHosts.length for (const tracker of trackerHosts) { this.lookupHostWithTracking(tracker, query, MAX_TRACKER_WAIT_TIME) - .then((answer) => { - const hosts = isOutputListAnswer(answer) ? this.extractHostsFromAnswer(answer, service) : [] + .then(answer => { + const hosts = isOutputListAnswer(answer) + ? this.extractHostsFromAnswer(answer, service) + : [] for (const h of hosts) { if (!allHosts.has(h)) { allHosts.add(h) @@ -1022,7 +1078,7 @@ export default class LookupResolver { } } const now = Date.now() - const foundAvailable = [...allHosts].some((host) => { + const foundAvailable = [...allHosts].some(host => { const backoffUntil = this.hostReputation.snapshot(host)?.backoffUntil ?? 0 return backoffUntil <= now }) @@ -1031,7 +1087,9 @@ export default class LookupResolver { resolve([...allHosts]) } }) - .catch(() => { /* tracker failure tracked in reputation */ }) + .catch(() => { + /* tracker failure tracked in reputation */ + }) .finally(() => { pending-- if (pending === 0 && !resolved) { @@ -1048,8 +1106,8 @@ export default class LookupResolver { * fast path when present; otherwise memoizes Transaction.fromBEEF(beef).id('hex') keyed by * the BEEF byte sequence. Returns null when the BEEF is unparseable. */ - private resolveTxIdForOutput ( - output: { txid?: string, beef: number[], outputIndex: number, context?: number[] }, + private resolveTxIdForOutput( + output: { txid?: string; beef: number[]; outputIndex: number; context?: number[] }, now: number ): string | null { if (typeof output.txid === 'string' && output.txid.length > 0) { @@ -1076,7 +1134,7 @@ export default class LookupResolver { if (firstKey !== undefined) m.delete(firstKey) } - private assertValidOverrideServices (overrides: Record): void { + private assertValidOverrideServices(overrides: Record): void { for (const service of Object.keys(overrides)) { if (!service.startsWith('ls_')) { throw new Error(`Host override service names must start with "ls_": ${service}`) @@ -1084,37 +1142,40 @@ export default class LookupResolver { } } - private prepareHostsForQuery (hosts: string[], context: string): string[] { + private prepareHostsForQuery(hosts: string[], context: string): string[] { if (hosts.length === 0) return [] const now = Date.now() const ranked = this.hostReputation.rankHosts(hosts, now) - const available = ranked.filter((h) => h.backoffUntil <= now).map((h) => h.host) + const available = ranked.filter(h => h.backoffUntil <= now).map(h => h.host) if (available.length > 0) return available - const soonest = Math.min(...ranked.map((h) => h.backoffUntil)) + const soonest = Math.min(...ranked.map(h => h.backoffUntil)) const waitMs = Math.max(soonest - now, 0) throw new Error( `All ${context} hosts are backing off for approximately ${waitMs}ms due to repeated failures.` ) } - private async lookupHostWithTracking ( + private async lookupHostWithTracking( host: string, question: LookupQuestion, timeout?: number ): Promise { const startedAt = Date.now() - const effectiveTimeout = typeof timeout === 'number' && Number.isFinite(timeout) && timeout >= 0 - ? timeout - : DEFAULT_LOOKUP_TIMEOUT + const effectiveTimeout = + typeof timeout === 'number' && Number.isFinite(timeout) && timeout >= 0 + ? timeout + : DEFAULT_LOOKUP_TIMEOUT const deadline = createDeadline(effectiveTimeout) // The Promise constructor converts a synchronous throw from a non-conforming // custom facilitator into a rejection while preserving immediate invocation. // Awaiting here would bypass the wall-clock deadline below. - const lookupPromise = new Promise((resolve) => { + const lookupPromise = new Promise(resolve => { resolve(this.facilitator.lookup(host, question, timeout)) }) - lookupPromise.catch(() => { /* deadline may win while custom facilitator settles later */ }) + lookupPromise.catch(() => { + /* deadline may win while custom facilitator settles later */ + }) let answer: LookupFacilitatorAnswer try { @@ -1142,7 +1203,7 @@ export default class LookupResolver { throw malformed } - private captureHostTelemetry ( + private captureHostTelemetry( service: string, host: string, outcome: 'success' | 'empty' | 'failed' | 'rejected' | 'freeform', @@ -1171,12 +1232,13 @@ export default class LookupResolver { }) } - private captureLookupCompletedTelemetry ( + private captureLookupCompletedTelemetry( service: string, progress: LookupAnswerProgress, durationMs: number ): void { - const degraded = progress.failedHosts > 0 || progress.rejectedHosts > 0 || progress.freeformHosts > 0 + const degraded = + progress.failedHosts > 0 || progress.rejectedHosts > 0 || progress.freeformHosts > 0 this.telemetry.capture({ name: 'sdk.overlay.lookup.completed', component: 'sdk.lookup-resolver', diff --git a/packages/sdk/src/overlay-tools/SHIPBroadcaster.ts b/packages/sdk/src/overlay-tools/SHIPBroadcaster.ts index c943d85de..9ec63b2e5 100644 --- a/packages/sdk/src/overlay-tools/SHIPBroadcaster.ts +++ b/packages/sdk/src/overlay-tools/SHIPBroadcaster.ts @@ -88,16 +88,14 @@ export class HTTPSOverlayBroadcastFacilitator implements OverlayBroadcastFacilit httpClient: typeof fetch allowHTTP: boolean - constructor (httpClient = fetch, allowHTTP: boolean = false) { + constructor(httpClient = fetch, allowHTTP: boolean = false) { this.httpClient = httpClient this.allowHTTP = allowHTTP } - async send (url: string, taggedBEEF: TaggedBEEF): Promise { + async send(url: string, taggedBEEF: TaggedBEEF): Promise { if (!url.startsWith('https:') && !this.allowHTTP) { - throw new Error( - 'HTTPS facilitator can only use URLs that start with "https:"' - ) + throw new Error('HTTPS facilitator can only use URLs that start with "https:"') } const headers = { 'Content-Type': 'application/octet-stream', @@ -138,11 +136,15 @@ export default class TopicBroadcaster implements Broadcaster { private readonly resolver: LookupResolver private readonly requireAcknowledgmentFromAllHostsForTopics: TopicAcknowledgmentRequirement private readonly requireAcknowledgmentFromAnyHostForTopics: TopicAcknowledgmentRequirement - private readonly requireAcknowledgmentFromSpecificHostsForTopics: Record + private readonly requireAcknowledgmentFromSpecificHostsForTopics: Record< + string, + TopicAcknowledgmentRequirement + > private readonly networkPreset: 'mainnet' | 'testnet' | 'local' // Cache for findInterestedHosts to avoid repeated SHIP tracker lookups - private interestedHostsCache: { hosts: Record>, expiresAt: number } | null = null + private interestedHostsCache: { hosts: Record>; expiresAt: number } | null = + null private interestedHostsInFlight: Promise>> | null = null private readonly interestedHostsTtlMs: number @@ -152,16 +154,18 @@ export default class TopicBroadcaster implements Broadcaster { * @param {string[]} topics - The list of SHIP topic names where transactions are to be sent. * @param {SHIPBroadcasterConfig} config - Configuration options for the SHIP broadcaster. */ - constructor (topics: string[], config: SHIPBroadcasterConfig = {}) { + constructor(topics: string[], config: SHIPBroadcasterConfig = {}) { if (topics.length === 0) { throw new Error('At least one topic is required for broadcast.') } - if (topics.some((x) => !x.startsWith('tm_'))) { + if (topics.some(x => !x.startsWith('tm_'))) { throw new Error('Every topic must start with "tm_".') } this.topics = topics this.networkPreset = config.networkPreset ?? 'mainnet' - this.facilitator = config.facilitator ?? new HTTPSOverlayBroadcastFacilitator(undefined, this.networkPreset === 'local') + this.facilitator = + config.facilitator ?? + new HTTPSOverlayBroadcastFacilitator(undefined, this.networkPreset === 'local') this.resolver = config.resolver ?? new LookupResolver({ networkPreset: this.networkPreset }) this.requireAcknowledgmentFromAllHostsForTopics = config.requireAcknowledgmentFromAllHostsForTopics ?? [] @@ -178,14 +182,12 @@ export default class TopicBroadcaster implements Broadcaster { * @param {Transaction} tx - The transaction to be sent. * @returns {Promise} A promise that resolves to either a success or failure response. */ - async broadcast ( - tx: Transaction - ): Promise { + async broadcast(tx: Transaction): Promise { let beef: number[] const offChainValues = tx.metadata.get('OffChainValues') as number[] try { beef = tx.toBEEF() - } catch (error) { + } catch { throw new Error( 'Transactions sent via SHIP to Overlay Services must be serializable to BEEF format.' ) @@ -198,26 +200,24 @@ export default class TopicBroadcaster implements Broadcaster { description: `No ${this.networkPreset} hosts are interested in receiving this transaction.` } } - const hostPromises = Object.entries(interestedHosts).map( - async ([host, topics]) => { - try { - const steak = await this.facilitator.send(host, { - beef, - offChainValues, - topics: [...topics] - }) - if (steak == null || Object.keys(steak).length === 0) { - throw new Error('Steak has no topics.') - } - return { host, success: true, steak } - } catch (error) { - return { host, success: false, error } + const hostPromises = Object.entries(interestedHosts).map(async ([host, topics]) => { + try { + const steak = await this.facilitator.send(host, { + beef, + offChainValues, + topics: [...topics] + }) + if (steak == null || Object.keys(steak).length === 0) { + throw new Error('Steak has no topics.') } + return { host, success: true, steak } + } catch (error) { + return { host, success: false, error } } - ) + }) const results = await Promise.all(hostPromises) - const successfulHosts = results.filter((result) => result.success) + const successfulHosts = results.filter(result => result.success) if (successfulHosts.length === 0) { return { @@ -241,11 +241,7 @@ export default class TopicBroadcaster implements Broadcaster { const coinsToRetain = instructions.coinsToRetain const coinsRemoved = instructions.coinsRemoved - if ( - outputsToAdmit?.length > 0 || - coinsToRetain?.length > 0 || - coinsRemoved?.length > 0 - ) { + if (outputsToAdmit?.length > 0 || coinsToRetain?.length > 0 || coinsRemoved?.length > 0) { acknowledgedTopics.add(topic) } } @@ -272,7 +268,7 @@ export default class TopicBroadcaster implements Broadcaster { } /** Resolves the (requiredTopics, require) pair for requireAcknowledgmentFromAllHostsForTopics. */ - private resolveAllHostsRequirement (): { requiredTopics: string[], require: RequireMode } { + private resolveAllHostsRequirement(): { requiredTopics: string[]; require: RequireMode } { const r = this.requireAcknowledgmentFromAllHostsForTopics if (r === 'any') return { requiredTopics: this.topics, require: 'any' } if (Array.isArray(r)) return { requiredTopics: r, require: 'all' } @@ -280,17 +276,23 @@ export default class TopicBroadcaster implements Broadcaster { return { requiredTopics: this.topics, require: 'all' } } - private checkAllHostsRequirement (hostAcknowledgments: Record>): BroadcastFailure | null { + private checkAllHostsRequirement( + hostAcknowledgments: Record> + ): BroadcastFailure | null { const { requiredTopics, require } = this.resolveAllHostsRequirement() if (requiredTopics.length === 0) return null if (!this.checkAcknowledgmentFromAllHosts(hostAcknowledgments, requiredTopics, require)) { - return { status: 'error', code: 'ERR_REQUIRE_ACK_FROM_ALL_HOSTS_FAILED', description: 'Not all hosts acknowledged the required topics.' } + return { + status: 'error', + code: 'ERR_REQUIRE_ACK_FROM_ALL_HOSTS_FAILED', + description: 'Not all hosts acknowledged the required topics.' + } } return null } /** Resolves the (requiredTopics, require) pair for requireAcknowledgmentFromAnyHostForTopics. */ - private resolveAnyHostRequirement (): { requiredTopics: string[], require: RequireMode } { + private resolveAnyHostRequirement(): { requiredTopics: string[]; require: RequireMode } { const r = this.requireAcknowledgmentFromAnyHostForTopics if (r === 'all') return { requiredTopics: this.topics, require: 'all' } if (r === 'any') return { requiredTopics: this.topics, require: 'any' } @@ -298,19 +300,36 @@ export default class TopicBroadcaster implements Broadcaster { return { requiredTopics: [], require: 'all' } } - private checkAnyHostRequirement (hostAcknowledgments: Record>): BroadcastFailure | null { + private checkAnyHostRequirement( + hostAcknowledgments: Record> + ): BroadcastFailure | null { const { requiredTopics, require } = this.resolveAnyHostRequirement() if (requiredTopics.length === 0) return null if (!this.checkAcknowledgmentFromAnyHost(hostAcknowledgments, requiredTopics, require)) { - return { status: 'error', code: 'ERR_REQUIRE_ACK_FROM_ANY_HOST_FAILED', description: 'No host acknowledged the required topics.' } + return { + status: 'error', + code: 'ERR_REQUIRE_ACK_FROM_ANY_HOST_FAILED', + description: 'No host acknowledged the required topics.' + } } return null } - private checkSpecificHostsRequirement (hostAcknowledgments: Record>): BroadcastFailure | null { + private checkSpecificHostsRequirement( + hostAcknowledgments: Record> + ): BroadcastFailure | null { if (Object.keys(this.requireAcknowledgmentFromSpecificHostsForTopics).length === 0) return null - if (!this.checkAcknowledgmentFromSpecificHosts(hostAcknowledgments, this.requireAcknowledgmentFromSpecificHostsForTopics)) { - return { status: 'error', code: 'ERR_REQUIRE_ACK_FROM_SPECIFIC_HOSTS_FAILED', description: 'Specific hosts did not acknowledge the required topics.' } + if ( + !this.checkAcknowledgmentFromSpecificHosts( + hostAcknowledgments, + this.requireAcknowledgmentFromSpecificHostsForTopics + ) + ) { + return { + status: 'error', + code: 'ERR_REQUIRE_ACK_FROM_SPECIFIC_HOSTS_FAILED', + description: 'Specific hosts did not acknowledge the required topics.' + } } return null } @@ -318,7 +337,7 @@ export default class TopicBroadcaster implements Broadcaster { /** * Returns true if `acknowledgedTopics` satisfies the given requirement against `requiredTopics`. */ - private topicsMatchRequirement ( + private topicsMatchRequirement( acknowledgedTopics: Set, requiredTopics: string[], require: RequireMode @@ -329,27 +348,27 @@ export default class TopicBroadcaster implements Broadcaster { return requiredTopics.some(t => acknowledgedTopics.has(t)) } - private checkAcknowledgmentFromAllHosts ( + private checkAcknowledgmentFromAllHosts( hostAcknowledgments: Record>, requiredTopics: string[], require: RequireMode ): boolean { - return Object.values(hostAcknowledgments).every( - acknowledged => this.topicsMatchRequirement(acknowledged, requiredTopics, require) + return Object.values(hostAcknowledgments).every(acknowledged => + this.topicsMatchRequirement(acknowledged, requiredTopics, require) ) } - private checkAcknowledgmentFromAnyHost ( + private checkAcknowledgmentFromAnyHost( hostAcknowledgments: Record>, requiredTopics: string[], require: RequireMode ): boolean { - return Object.values(hostAcknowledgments).some( - acknowledged => this.topicsMatchRequirement(acknowledged, requiredTopics, require) + return Object.values(hostAcknowledgments).some(acknowledged => + this.topicsMatchRequirement(acknowledged, requiredTopics, require) ) } - private checkAcknowledgmentFromSpecificHosts ( + private checkAcknowledgmentFromSpecificHosts( hostAcknowledgments: Record>, requirements: Record ): boolean { @@ -383,7 +402,7 @@ export default class TopicBroadcaster implements Broadcaster { * * @returns A mapping of URLs for hosts interested in this transaction. Keys are URLs, values are which of our topics the specific host cares about. */ - private async findInterestedHosts (): Promise>> { + private async findInterestedHosts(): Promise>> { // Handle the local network preset if (this.networkPreset === 'local') { const resultSet = new Set() @@ -418,7 +437,7 @@ export default class TopicBroadcaster implements Broadcaster { * Performs the actual SHIP lookup to discover interested hosts. * @private */ - private async fetchInterestedHosts (): Promise>> { + private async fetchInterestedHosts(): Promise>> { // Find all SHIP advertisements for the topics we care about const results: Record> = {} const answer = await this.resolver.query( @@ -438,15 +457,12 @@ export default class TopicBroadcaster implements Broadcaster { const tx = Transaction.fromBEEF(output.beef) const script = tx.outputs[output.outputIndex].lockingScript const parsed = OverlayAdminTokenTemplate.decode(script) - if ( - !this.topics.includes(parsed.topicOrService) || - parsed.protocol !== 'SHIP' - ) { + if (!this.topics.includes(parsed.topicOrService) || parsed.protocol !== 'SHIP') { continue } results[parsed.domain] ??= new Set() results[parsed.domain].add(parsed.topicOrService) - } catch (_notShipOutput) { + } catch { // Output could not be decoded as an overlay admin token — not a SHIP advertisement; skip continue } diff --git a/packages/sdk/src/primitives/BigNumber.ts b/packages/sdk/src/primitives/BigNumber.ts index fb9e82e17..902a44472 100644 --- a/packages/sdk/src/primitives/BigNumber.ts +++ b/packages/sdk/src/primitives/BigNumber.ts @@ -4,10 +4,8 @@ import ReductionContext from './ReductionContext.js' /** Comparison result: -1 (less than), 0 (equal), or 1 (greater than). */ type CompareResult = 1 | 0 | -1 -const BufferCtor = - typeof globalThis === 'undefined' ? undefined : (globalThis as any).Buffer -const CAN_USE_BUFFER = - BufferCtor != null && typeof BufferCtor.from === 'function' +const BufferCtor = typeof globalThis === 'undefined' ? undefined : (globalThis as any).Buffer +const CAN_USE_BUFFER = BufferCtor != null && typeof BufferCtor.from === 'function' const HEX_CHAR_TO_VALUE = new Int8Array(256).fill(-1) for (let i = 0; i < 10; i++) { HEX_CHAR_TO_VALUE[48 + i] = i // '0'-'9' @@ -29,31 +27,50 @@ export default class BigNumber { * @privateinitializer */ public static readonly zeros: string[] = [ - '', '0', '00', '000', '0000', '00000', '000000', '0000000', '00000000', - '000000000', '0000000000', '00000000000', '000000000000', '0000000000000', - '00000000000000', '000000000000000', '0000000000000000', '00000000000000000', - '000000000000000000', '0000000000000000000', '00000000000000000000', - '000000000000000000000', '0000000000000000000000', '00000000000000000000000', - '000000000000000000000000', '0000000000000000000000000' + '', + '0', + '00', + '000', + '0000', + '00000', + '000000', + '0000000', + '00000000', + '000000000', + '0000000000', + '00000000000', + '000000000000', + '0000000000000', + '00000000000000', + '000000000000000', + '0000000000000000', + '00000000000000000', + '000000000000000000', + '0000000000000000000', + '00000000000000000000', + '000000000000000000000', + '0000000000000000000000', + '00000000000000000000000', + '000000000000000000000000', + '0000000000000000000000000' ] /** * @privateinitializer */ static readonly groupSizes: number[] = [ - 0, 0, 25, 16, 12, 11, 10, 9, 8, 8, 7, 7, 7, 7, 6, 6, 6, 6, 6, 6, 6, 5, 5, 5, - 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5 + 0, 0, 25, 16, 12, 11, 10, 9, 8, 8, 7, 7, 7, 7, 6, 6, 6, 6, 6, 6, 6, 5, 5, 5, 5, 5, 5, 5, 5, 5, + 5, 5, 5, 5, 5, 5, 5 ] /** * @privateinitializer */ static readonly groupBases: number[] = [ - 0, 0, 33554432, 43046721, 16777216, 48828125, 60466176, 40353607, 16777216, - 43046721, 10000000, 19487171, 35831808, 62748517, 7529536, 11390625, - 16777216, 24137569, 34012224, 47045881, 64000000, 4084101, 5153632, 6436343, - 7962624, 9765625, 11881376, 14348907, 17210368, 20511149, 24300000, - 28629151, 33554432, 39135393, 45435424, 52521875, 60466176 + 0, 0, 33554432, 43046721, 16777216, 48828125, 60466176, 40353607, 16777216, 43046721, 10000000, + 19487171, 35831808, 62748517, 7529536, 11390625, 16777216, 24137569, 34012224, 47045881, + 64000000, 4084101, 5153632, 6436343, 7962624, 9765625, 11881376, 14348907, 17210368, 20511149, + 24300000, 28629151, 33554432, 39135393, 45435424, 52521875, 60466176 ] /** @@ -94,14 +111,14 @@ export default class BigNumber { * * @property negative */ - public get negative (): number { + public get negative(): number { return this._sign } /** * Sets the negative flag. Only 0 (positive) or 1 (negative) are allowed. */ - public set negative (val: number) { + public set negative(val: number) { this.assert(val === 0 || val === 1, 'Negative property must be 0 or 1') const newSign = val === 1 ? 1 : 0 if (this._magnitude === 0n) { @@ -111,7 +128,7 @@ export default class BigNumber { } } - private get _computedWordsArray (): number[] { + private get _computedWordsArray(): number[] { if (this._magnitude === 0n) return [0] const arr: number[] = [] let temp = this._magnitude @@ -127,7 +144,7 @@ export default class BigNumber { * * @property words */ - public get words (): number[] { + public get words(): number[] { if ( !Number.isSafeInteger(this._nominalWordLength) || this._nominalWordLength < 1 || @@ -139,7 +156,7 @@ export default class BigNumber { if (this._nominalWordLength <= computed.length) { return computed } - const paddedWords = new Array(this._nominalWordLength).fill(0) + const paddedWords = Array.from({ length: this._nominalWordLength }).fill(0) for (let i = 0; i < computed.length; i++) { paddedWords[i] = computed[i] } @@ -149,13 +166,14 @@ export default class BigNumber { /** * Sets the words array representing the value of the big number. */ - public set words (newWords: number[]) { + public set words(newWords: number[]) { const oldSign = this._sign let newMagnitude = 0n const len = newWords.length > 0 ? newWords.length : 1 for (let i = len - 1; i >= 0; i--) { const wordVal = newWords[i] ?? 0 - newMagnitude = (newMagnitude << BigNumber.WORD_SIZE_BIGINT) | BigInt(wordVal & Number(BigNumber.WORD_MASK)) + newMagnitude = + (newMagnitude << BigNumber.WORD_SIZE_BIGINT) | BigInt(wordVal & Number(BigNumber.WORD_MASK)) } this._magnitude = newMagnitude this._sign = oldSign @@ -168,7 +186,7 @@ export default class BigNumber { * * @property length */ - public get length (): number { + public get length(): number { return Math.max(1, this._nominalWordLength) } @@ -179,7 +197,7 @@ export default class BigNumber { * @param num - The value to be checked. * @returns - Returns a boolean value determining whether or not the checked num parameter is a BigNumber. */ - static isBN (num: any): boolean { + static isBN(num: any): boolean { if (num instanceof BigNumber) return true return ( num !== null && @@ -197,7 +215,9 @@ export default class BigNumber { * @param right - The second BigNumber to be compared. * @returns - Returns the bigger BigNumber between left and right. */ - static max (left: BigNumber, right: BigNumber): BigNumber { return left.cmp(right) > 0 ? left : right } + static max(left: BigNumber, right: BigNumber): BigNumber { + return left.cmp(right) > 0 ? left : right + } /** * Returns the smaller value between two BigNumbers @@ -207,7 +227,9 @@ export default class BigNumber { * @param right - The second BigNumber to be compared. * @returns - Returns the smaller value between left and right. */ - static min (left: BigNumber, right: BigNumber): BigNumber { return left.cmp(right) < 0 ? left : right } + static min(left: BigNumber, right: BigNumber): BigNumber { + return left.cmp(right) < 0 ? left : right + } /** * @constructor @@ -216,7 +238,7 @@ export default class BigNumber { * @param base - The base of number provided. By default is 10. * @param endian - The endianness provided. By default is 'big endian'. */ - constructor ( + constructor( number: number | string | number[] | bigint | undefined = 0, base: number | 'be' | 'le' | 'hex' = 10, endian: 'be' | 'le' = 'be' @@ -224,16 +246,35 @@ export default class BigNumber { this.red = null number ??= 0 - if (number === null) { this._initializeState(0n, 0); return } - if (typeof number === 'bigint') { this._initializeState(number < 0n ? -number : number, number < 0n ? 1 : 0); this.normSign(); return } + if (number === null) { + this._initializeState(0n, 0) + return + } + if (typeof number === 'bigint') { + this._initializeState(number < 0n ? -number : number, number < 0n ? 1 : 0) + this.normSign() + return + } let effectiveBase: number | 'hex' = base let effectiveEndian: 'be' | 'le' = endian - if (base === 'le' || base === 'be') { effectiveEndian = base; effectiveBase = 10 } + if (base === 'le' || base === 'be') { + effectiveEndian = base + effectiveBase = 10 + } - if (typeof number === 'number') { this.initNumber(number, effectiveEndian); return } - if (Array.isArray(number)) { this.initArray(number, effectiveEndian); return } - if (typeof number === 'string') { this._initFromString(number, effectiveBase, effectiveEndian); return } + if (typeof number === 'number') { + this.initNumber(number, effectiveEndian) + return + } + if (Array.isArray(number)) { + this.initArray(number, effectiveEndian) + return + } + if (typeof number === 'string') { + this._initFromString(number, effectiveBase, effectiveEndian) + return + } if (number !== 0) { this.assert(false, 'Unsupported input type for BigNumber constructor') @@ -242,16 +283,36 @@ export default class BigNumber { } } - private _initFromString (number: string, effectiveBase: number | 'hex', effectiveEndian: 'be' | 'le'): void { + private _initFromString( + number: string, + effectiveBase: number | 'hex', + effectiveEndian: 'be' | 'le' + ): void { if (effectiveBase === 'hex') effectiveBase = 16 // eslint-disable-next-line no-bitwise -- ToInt32 (ECMA-262); required for integer base validation. - this.assert(typeof effectiveBase === 'number' && effectiveBase === (effectiveBase | 0) && effectiveBase >= 2 && effectiveBase <= 36, 'Base must be an integer between 2 and 36') + this.assert( + typeof effectiveBase === 'number' && + effectiveBase === (effectiveBase | 0) && + effectiveBase >= 2 && + effectiveBase <= 36, + 'Base must be an integer between 2 and 36' + ) const originalNumberStr = number.toString().replace(/\s+/g, '') - let start = 0; let sign = 0 - if (originalNumberStr.startsWith('-')) { start++; sign = 1 } else if (originalNumberStr.startsWith('+')) { start++ } + let start = 0 + let sign = 0 + if (originalNumberStr.startsWith('-')) { + start++ + sign = 1 + } else if (originalNumberStr.startsWith('+')) { + start++ + } const numStr = originalNumberStr.substring(start) - if (numStr.length === 0) { this._initializeState(0n, (sign === 1 && originalNumberStr.startsWith('-')) ? 1 : 0); this.normSign(); return } + if (numStr.length === 0) { + this._initializeState(0n, sign === 1 && originalNumberStr.startsWith('-') ? 1 : 0) + this.normSign() + return + } if (effectiveBase === 16) { this._initFromHexString(numStr, sign, effectiveEndian) @@ -260,31 +321,47 @@ export default class BigNumber { } } - private _initFromHexString (numStr: string, sign: number, effectiveEndian: 'be' | 'le'): void { + private _initFromHexString(numStr: string, sign: number, effectiveEndian: 'be' | 'le'): void { if (effectiveEndian === 'le') { - const bytes: number[] = []; let hexStr = numStr + const bytes: number[] = [] + let hexStr = numStr if (hexStr.length % 2 !== 0) hexStr = '0' + hexStr for (let i = 0; i < hexStr.length; i += 2) { - const byteHex = hexStr.substring(i, i + 2); const byteVal = Number.parseInt(byteHex, 16) + const byteHex = hexStr.substring(i, i + 2) + const byteVal = Number.parseInt(byteHex, 16) if (Number.isNaN(byteVal)) throw new Error('Invalid character in ' + hexStr) bytes.push(byteVal) } - this.initArray(bytes, 'le'); this._sign = sign; this.normSign() + this.initArray(bytes, 'le') + this._sign = sign + this.normSign() } else { let tempMagnitude: bigint - try { tempMagnitude = BigInt('0x' + numStr) } catch (_bigIntParseError) { throw new Error('Invalid character in ' + numStr) } - this._initializeState(tempMagnitude, sign); this.normSign() + try { + tempMagnitude = BigInt('0x' + numStr) + } catch { + throw new Error('Invalid character in ' + numStr) + } + this._initializeState(tempMagnitude, sign) + this.normSign() } } - private _initFromNonHexString (numStr: string, base: number, sign: number, effectiveEndian: 'be' | 'le'): void { + private _initFromNonHexString( + numStr: string, + base: number, + sign: number, + effectiveEndian: 'be' | 'le' + ): void { try { this._parseBaseString(numStr, base) - this._sign = sign; this.normSign() + this._sign = sign + this.normSign() if (effectiveEndian === 'le') { const currentSign = this._sign this.initArray(this.toArray('be'), 'le') - this._sign = currentSign; this.normSign() + this._sign = currentSign + this.normSign() } } catch (err) { const error = err as Error @@ -299,7 +376,7 @@ export default class BigNumber { } } - private _bigIntToStringInBase (num: bigint, base: number): string { + private _bigIntToStringInBase(num: bigint, base: number): string { if (num === 0n) return '0' if (base < 2 || base > 36) throw new Error('Base must be between 2 and 36') @@ -315,8 +392,12 @@ export default class BigNumber { return result } - private _parseBaseString (numberStr: string, base: number): void { - if (numberStr.length === 0) { this._magnitude = 0n; this._finishInitialization(); return } + private _parseBaseString(numberStr: string, base: number): void { + if (numberStr.length === 0) { + this._magnitude = 0n + this._finishInitialization() + return + } this._magnitude = 0n const bigBase = BigInt(base) @@ -352,7 +433,7 @@ export default class BigNumber { this._finishInitialization() } - private _parseBaseWord (str: string, base: number): number { + private _parseBaseWord(str: string, base: number): number { let r = 0 for (let i = 0; i < str.length; i++) { const charCode = str.codePointAt(i) as number @@ -368,13 +449,13 @@ export default class BigNumber { return r } - private _initializeState (magnitude: bigint, sign: 0 | 1): void { + private _initializeState(magnitude: bigint, sign: 0 | 1): void { this._magnitude = magnitude - this._sign = (magnitude === 0n) ? 0 : sign + this._sign = magnitude === 0n ? 0 : sign this._finishInitialization() } - private _finishInitialization (): void { + private _finishInitialization(): void { if (this._magnitude === 0n) { this._nominalWordLength = 1 this._sign = 0 @@ -384,10 +465,15 @@ export default class BigNumber { } } - private assert (val: unknown, msg: string = 'Assertion failed'): void { if (!(val as boolean)) throw new Error(msg) } + private assert(val: unknown, msg: string = 'Assertion failed'): void { + if (!(val as boolean)) throw new Error(msg) + } - private initNumber (number: number, endian: 'be' | 'le' = 'be'): this { - this.assert(BigInt(Math.abs(number)) <= BigNumber.MAX_NUMBER_CONSTRUCTOR_MAG_BIGINT, 'The number is larger than 2 ^ 53 (unsafe)') + private initNumber(number: number, endian: 'be' | 'le' = 'be'): this { + this.assert( + BigInt(Math.abs(number)) <= BigNumber.MAX_NUMBER_CONSTRUCTOR_MAG_BIGINT, + 'The number is larger than 2 ^ 53 (unsafe)' + ) this.assert(number % 1 === 0, 'Number must be an integer for BigNumber conversion') this._initializeState(BigInt(Math.abs(number)), number < 0 ? 1 : 0) if (endian === 'le') { @@ -400,38 +486,64 @@ export default class BigNumber { return this } - private initArray (bytes: number[], endian: 'be' | 'le'): this { - if (bytes.length === 0) { this._initializeState(0n, 0); return this } + private initArray(bytes: number[], endian: 'be' | 'le'): this { + if (bytes.length === 0) { + this._initializeState(0n, 0) + return this + } let magnitude = 0n if (endian === 'be') { for (const byte of bytes) magnitude = (magnitude << 8n) | BigInt(byte & 0xff) } else { - for (let i = bytes.length - 1; i >= 0; i--) magnitude = (magnitude << 8n) | BigInt(bytes[i] & 0xff) + for (let i = bytes.length - 1; i >= 0; i--) + magnitude = (magnitude << 8n) | BigInt(bytes[i] & 0xff) } this._initializeState(magnitude, 0) return this } - copy (dest: BigNumber): void { dest._magnitude = this._magnitude; dest._sign = this._sign; dest._nominalWordLength = this._nominalWordLength; dest.red = this.red } - static move (dest: BigNumber, src: BigNumber): void { dest._magnitude = src._magnitude; dest._sign = src._sign; dest._nominalWordLength = src._nominalWordLength; dest.red = src.red } - clone (): BigNumber { const r = new BigNumber(0n); this.copy(r); return r } + copy(dest: BigNumber): void { + dest._magnitude = this._magnitude + dest._sign = this._sign + dest._nominalWordLength = this._nominalWordLength + dest.red = this.red + } + static move(dest: BigNumber, src: BigNumber): void { + dest._magnitude = src._magnitude + dest._sign = src._sign + dest._nominalWordLength = src._nominalWordLength + dest.red = src.red + } + clone(): BigNumber { + const r = new BigNumber(0n) + this.copy(r) + return r + } - expand (size: number): this { + expand(size: number): this { this.assert( - Number.isSafeInteger(size) && - size >= 0 && - size <= BigNumber.MAX_NOMINAL_WORD_LENGTH, + Number.isSafeInteger(size) && size >= 0 && size <= BigNumber.MAX_NOMINAL_WORD_LENGTH, 'Expand size must be a non-negative safe integer within the supported word limit' ) this._nominalWordLength = Math.max(this._nominalWordLength, size, 1) return this } - strip (): this { this._finishInitialization(); return this.normSign() } - normSign (): this { if (this._magnitude === 0n) { this._sign = 0 } return this } - inspect (): string { return (this.red === null ? '' } + strip(): this { + this._finishInitialization() + return this.normSign() + } + normSign(): this { + if (this._magnitude === 0n) { + this._sign = 0 + } + return this + } + inspect(): string { + return (this.red === null ? '' + } - private _getMinimalHex (): string { + private _getMinimalHex(): string { if (this._magnitude === 0n) return '0' return this._magnitude.toString(16) } @@ -444,13 +556,14 @@ export default class BigNumber { * @param padding - Represents the minimum number of digits to represent the BigNumber as a string. Default is 1. * @returns The string representation of the BigNumber instance */ - toString (base: number | 'hex' = 10, padding: number = 1): string { + toString(base: number | 'hex' = 10, padding: number = 1): string { if (base === 16 || base === 'hex') { // For toString('hex', N), N is the 'multiple-of-N characters' rule from bn.js tests // For toString(16, P) where P=1 (default) or P=0, it means minimal hex. let hexStr = this._getMinimalHex() // e.g., "f", "123", "0" - if (padding > 1) { // N-multiple rule for characters + if (padding > 1) { + // N-multiple rule for characters // Ensure hexStr is even length if not "0" to represent full bytes before applying multiple rule if (hexStr !== '0' && hexStr.length % 2 !== 0) { hexStr = '0' + hexStr @@ -465,14 +578,17 @@ export default class BigNumber { return (this.isNeg() ? '-' : '') + hexStr } - if (typeof base !== 'number' || base < 2 || base > 36 || base % 1 !== 0) throw new Error('Base should be an integer between 2 and 36') + if (typeof base !== 'number' || base < 2 || base > 36 || base % 1 !== 0) + throw new Error('Base should be an integer between 2 and 36') return this.toBaseString(base, padding) } - private toBaseString (base: number, padding: number): string { + private toBaseString(base: number, padding: number): string { if (this._magnitude === 0n) { let out = '0' - if (padding > 1) { while (out.length < padding) out = '0' + out } + if (padding > 1) { + while (out.length < padding) out = '0' + out + } return out } @@ -494,12 +610,14 @@ export default class BigNumber { out = (tempMag > 0n ? this._zeroPaddedChunk(chunkStr, groupSize) : chunkStr) + out } - if (padding > 0) { while (out.length < padding) out = '0' + out } + if (padding > 0) { + while (out.length < padding) out = '0' + out + } return (this._sign === 1 ? '-' : '') + out } /** Returns a chunk string zero-padded to groupSize (used by toBaseString for interior chunks). */ - private _zeroPaddedChunk (chunkStr: string, groupSize: number): string { + private _zeroPaddedChunk(chunkStr: string, groupSize: number): string { const zerosToPrepend = groupSize - chunkStr.length if (zerosToPrepend <= 0) return chunkStr if (zerosToPrepend < BigNumber.zeros.length) return BigNumber.zeros[zerosToPrepend] + chunkStr @@ -514,9 +632,10 @@ export default class BigNumber { * @throws If the BigNumber instance cannot be safely stored in a JavaScript number * @returns The JavaScript number representation of the BigNumber instance. */ - toNumber (): number { + toNumber(): number { const val = this._getSignedValue() - if (val > BigNumber.MAX_SAFE_INTEGER_BIGINT || val < BigNumber.MIN_SAFE_INTEGER_BIGINT) throw new Error('Number can only safely store up to 53 bits') + if (val > BigNumber.MAX_SAFE_INTEGER_BIGINT || val < BigNumber.MIN_SAFE_INTEGER_BIGINT) + throw new Error('Number can only safely store up to 53 bits') return Number(val) } @@ -526,7 +645,7 @@ export default class BigNumber { * @method toBigInt * @returns bigint value for this BigNumber. */ - toBigInt (): bigint { + toBigInt(): bigint { return this._getSignedValue() } @@ -536,12 +655,12 @@ export default class BigNumber { * @method toJSON * @returns The JSON string representation of the BigNumber instance. */ - toJSON (): string { + toJSON(): string { const hex = this._getMinimalHex() return (this.isNeg() ? '-' : '') + hex } - private toArrayLikeGeneric (res: number[], isLE: boolean): void { + private toArrayLikeGeneric(res: number[], isLE: boolean): void { let tempMag = this._magnitude let position = isLE ? 0 : res.length - 1 const increment = isLE ? 1 : -1 @@ -567,7 +686,7 @@ export default class BigNumber { * @param length - Optional length of the output array. * @returns Array of bytes representing the BigNumber. */ - toArray (endian: 'le' | 'be' = 'be', length?: number): number[] { + toArray(endian: 'le' | 'be' = 'be', length?: number): number[] { this.strip() const actualByteLength = this.byteLength() const reqLength = length ?? Math.max(1, actualByteLength) @@ -575,7 +694,7 @@ export default class BigNumber { this.assert(actualByteLength <= reqLength, 'byte array longer than desired length') this.assert(reqLength > 0, 'Requested array length <= 0') - const res = new Array(reqLength).fill(0) + const res = Array.from({ length: reqLength }).fill(0) if (this._magnitude === 0n && reqLength > 0) return res if (this._magnitude === 0n && reqLength === 0) return [] @@ -589,7 +708,12 @@ export default class BigNumber { * @method bitLength * @returns The bit length of the BigNumber. */ - bitLength (): number { if (this._magnitude === 0n) { return 0 } return this._magnitude.toString(2).length } + bitLength(): number { + if (this._magnitude === 0n) { + return 0 + } + return this._magnitude.toString(2).length + } /** * Converts a BigNumber to an array of bits. * @@ -597,10 +721,10 @@ export default class BigNumber { * @param num - The BigNumber to convert. * @returns An array of bits. */ - static toBitArray (num: BigNumber): Array<0 | 1> { + static toBitArray(num: BigNumber): Array<0 | 1> { const len = num.bitLength() if (len === 0) return [] - const w = new Array<0 | 1>(len) + const w = Array.from({ length: len }) const mag = num._magnitude for (let bit = 0; bit < len; bit++) { w[bit] = ((mag >> BigInt(bit)) & 1n) === 0n ? 0 : 1 @@ -611,7 +735,9 @@ export default class BigNumber { /** * Instance version of {@link toBitArray}. */ - toBitArray (): Array<0 | 1> { return BigNumber.toBitArray(this) } + toBitArray(): Array<0 | 1> { + return BigNumber.toBitArray(this) + } /** * Returns the number of trailing zero bits in the big number. @@ -624,7 +750,7 @@ export default class BigNumber { * const bn = new BigNumber('8'); // binary: 1000 * const zeroBits = bn.zeroBits(); // 3 */ - zeroBits (): number { + zeroBits(): number { if (this._magnitude === 0n) return 0 let c = 0 let t = this._magnitude @@ -641,11 +767,18 @@ export default class BigNumber { * @method byteLength * @returns The byte length of the BigNumber. */ - byteLength (): number { if (this._magnitude === 0n) { return 0 } return Math.ceil(this.bitLength() / 8) } + byteLength(): number { + if (this._magnitude === 0n) { + return 0 + } + return Math.ceil(this.bitLength() / 8) + } - private _getSignedValue (): bigint { return this._sign === 1 ? -this._magnitude : this._magnitude } + private _getSignedValue(): bigint { + return this._sign === 1 ? -this._magnitude : this._magnitude + } - private _setValueFromSigned (sVal: bigint): void { + private _setValueFromSigned(sVal: bigint): void { if (sVal < 0n) { this._magnitude = -sVal this._sign = 1 @@ -657,18 +790,19 @@ export default class BigNumber { this.normSign() } - toTwos (width: number): BigNumber { + toTwos(width: number): BigNumber { this.assert(width >= 0) const Bw = BigInt(width) let v = this._getSignedValue() if (this._sign === 1 && this._magnitude !== 0n) v = (1n << Bw) + v - const m = (1n << Bw) - 1n; v &= m + const m = (1n << Bw) - 1n + v &= m const r = new BigNumber(0n) r._initializeState(v, 0) return r } - fromTwos (width: number): BigNumber { + fromTwos(width: number): BigNumber { this.assert(width >= 0) const Bw = BigInt(width) const m = this._magnitude @@ -681,11 +815,24 @@ export default class BigNumber { return this.clone() } - isNeg (): boolean { return this._sign === 1 && this._magnitude !== 0n } - neg (): BigNumber { return this.clone().ineg() } - ineg (): this { if (this._magnitude !== 0n) { this._sign = this._sign === 1 ? 0 : 1 } return this } + isNeg(): boolean { + return this._sign === 1 && this._magnitude !== 0n + } + neg(): BigNumber { + return this.clone().ineg() + } + ineg(): this { + if (this._magnitude !== 0n) { + this._sign = this._sign === 1 ? 0 : 1 + } + return this + } - private _iuop (num: BigNumber, op: (a: bigint, b: bigint) => bigint, isXor: boolean = false): this { + private _iuop( + num: BigNumber, + op: (a: bigint, b: bigint) => bigint, + isXor: boolean = false + ): this { const newMag = op(this._magnitude, num._magnitude) let targetNominalLength = this._nominalWordLength if (isXor) targetNominalLength = Math.max(this.length, num.length) @@ -696,26 +843,61 @@ export default class BigNumber { return this.strip() } - iuor (num: BigNumber): this { return this._iuop(num, (a, b) => a | b) } - iuand (num: BigNumber): this { return this._iuop(num, (a, b) => a & b) } - iuxor (num: BigNumber): this { return this._iuop(num, (a, b) => a ^ b, true) } - private _iop (num: BigNumber, op: (a: bigint, b: bigint) => bigint, isXor: boolean = false): this { this.assert(this._sign === 0 && num._sign === 0); return this._iuop(num, op, isXor) } - ior (num: BigNumber): this { return this._iop(num, (a, b) => a | b) } - iand (num: BigNumber): this { return this._iop(num, (a, b) => a & b) } - ixor (num: BigNumber): this { return this._iop(num, (a, b) => a ^ b, true) } - private _uop_new (num: BigNumber, opName: 'iuor' | 'iuand' | 'iuxor'): BigNumber { if (this.length >= num.length) { return this.clone()[opName](num) } return num.clone()[opName](this) } - or (num: BigNumber): BigNumber { this.assert(this._sign === 0 && num._sign === 0); return this._uop_new(num, 'iuor') } - uor (num: BigNumber): BigNumber { return this._uop_new(num, 'iuor') } - and (num: BigNumber): BigNumber { this.assert(this._sign === 0 && num._sign === 0); return this._uop_new(num, 'iuand') } - uand (num: BigNumber): BigNumber { return this._uop_new(num, 'iuand') } - xor (num: BigNumber): BigNumber { this.assert(this._sign === 0 && num._sign === 0); return this._uop_new(num, 'iuxor') } - uxor (num: BigNumber): BigNumber { return this._uop_new(num, 'iuxor') } - - inotn (width: number): this { + iuor(num: BigNumber): this { + return this._iuop(num, (a, b) => a | b) + } + iuand(num: BigNumber): this { + return this._iuop(num, (a, b) => a & b) + } + iuxor(num: BigNumber): this { + return this._iuop(num, (a, b) => a ^ b, true) + } + private _iop(num: BigNumber, op: (a: bigint, b: bigint) => bigint, isXor: boolean = false): this { + this.assert(this._sign === 0 && num._sign === 0) + return this._iuop(num, op, isXor) + } + ior(num: BigNumber): this { + return this._iop(num, (a, b) => a | b) + } + iand(num: BigNumber): this { + return this._iop(num, (a, b) => a & b) + } + ixor(num: BigNumber): this { + return this._iop(num, (a, b) => a ^ b, true) + } + private _uop_new(num: BigNumber, opName: 'iuor' | 'iuand' | 'iuxor'): BigNumber { + if (this.length >= num.length) { + return this.clone()[opName](num) + } + return num.clone()[opName](this) + } + or(num: BigNumber): BigNumber { + this.assert(this._sign === 0 && num._sign === 0) + return this._uop_new(num, 'iuor') + } + uor(num: BigNumber): BigNumber { + return this._uop_new(num, 'iuor') + } + and(num: BigNumber): BigNumber { + this.assert(this._sign === 0 && num._sign === 0) + return this._uop_new(num, 'iuand') + } + uand(num: BigNumber): BigNumber { + return this._uop_new(num, 'iuand') + } + xor(num: BigNumber): BigNumber { + this.assert(this._sign === 0 && num._sign === 0) + return this._uop_new(num, 'iuxor') + } + uxor(num: BigNumber): BigNumber { + return this._uop_new(num, 'iuxor') + } + + inotn(width: number): this { this.assert(typeof width === 'number' && width >= 0) const Bw = BigInt(width) const m = (1n << Bw) - 1n - this._magnitude = (~this._magnitude) & m + this._magnitude = ~this._magnitude & m const wfw = width === 0 ? 1 : Math.ceil(width / BigNumber.wordSize) this._nominalWordLength = Math.max(1, wfw) this.strip() @@ -723,14 +905,39 @@ export default class BigNumber { return this } - notn (width: number): BigNumber { return this.clone().inotn(width) } - setn (bit: number, val: any): this { this.assert(typeof bit === 'number' && bit >= 0); const Bb = BigInt(bit); if (val === 1 || val === true) this._magnitude |= (1n << Bb); else this._magnitude &= ~(1n << Bb); const wnb = Math.floor(bit / BigNumber.wordSize) + 1; this._nominalWordLength = Math.max(this._nominalWordLength, wnb); this._finishInitialization(); return this.strip() } + notn(width: number): BigNumber { + return this.clone().inotn(width) + } + setn(bit: number, val: any): this { + this.assert(typeof bit === 'number' && bit >= 0) + const Bb = BigInt(bit) + if (val === 1 || val === true) this._magnitude |= 1n << Bb + else this._magnitude &= ~(1n << Bb) + const wnb = Math.floor(bit / BigNumber.wordSize) + 1 + this._nominalWordLength = Math.max(this._nominalWordLength, wnb) + this._finishInitialization() + return this.strip() + } - iadd (num: BigNumber): this { this._setValueFromSigned(this._getSignedValue() + num._getSignedValue()); return this } - add (num: BigNumber): BigNumber { const r = new BigNumber(0n); r._setValueFromSigned(this._getSignedValue() + num._getSignedValue()); return r } - isub (num: BigNumber): this { this._setValueFromSigned(this._getSignedValue() - num._getSignedValue()); return this } - sub (num: BigNumber): BigNumber { const r = new BigNumber(0n); r._setValueFromSigned(this._getSignedValue() - num._getSignedValue()); return r } - mul (num: BigNumber): BigNumber { + iadd(num: BigNumber): this { + this._setValueFromSigned(this._getSignedValue() + num._getSignedValue()) + return this + } + add(num: BigNumber): BigNumber { + const r = new BigNumber(0n) + r._setValueFromSigned(this._getSignedValue() + num._getSignedValue()) + return r + } + isub(num: BigNumber): this { + this._setValueFromSigned(this._getSignedValue() - num._getSignedValue()) + return this + } + sub(num: BigNumber): BigNumber { + const r = new BigNumber(0n) + r._setValueFromSigned(this._getSignedValue() - num._getSignedValue()) + return r + } + mul(num: BigNumber): BigNumber { const r = new BigNumber(0n) r._magnitude = this._magnitude * num._magnitude r._sign = r._magnitude === 0n ? 0 : ((this._sign ^ num._sign) as 0 | 1) @@ -739,7 +946,7 @@ export default class BigNumber { return r.normSign() } - imul (num: BigNumber): this { + imul(num: BigNumber): this { this._magnitude *= num._magnitude this._sign = this._magnitude === 0n ? 0 : ((this._sign ^ num._sign) as 0 | 1) this._nominalWordLength = this.length + num.length @@ -747,9 +954,16 @@ export default class BigNumber { return this.normSign() } - imuln (num: number): this { this.assert(typeof num === 'number', 'Assertion failed'); this.assert(Math.abs(num) <= BigNumber.MAX_IMULN_ARG, 'Assertion failed'); this._setValueFromSigned(this._getSignedValue() * BigInt(num)); return this } - muln (num: number): BigNumber { return this.clone().imuln(num) } - sqr (): BigNumber { + imuln(num: number): this { + this.assert(typeof num === 'number', 'Assertion failed') + this.assert(Math.abs(num) <= BigNumber.MAX_IMULN_ARG, 'Assertion failed') + this._setValueFromSigned(this._getSignedValue() * BigInt(num)) + return this + } + muln(num: number): BigNumber { + return this.clone().imuln(num) + } + sqr(): BigNumber { const r = new BigNumber(0n) r._magnitude = this._magnitude * this._magnitude r._sign = 0 @@ -758,7 +972,7 @@ export default class BigNumber { return r } - isqr (): this { + isqr(): this { this._magnitude *= this._magnitude this._sign = 0 this._nominalWordLength = this.length * 2 @@ -766,7 +980,7 @@ export default class BigNumber { return this } - pow (num: BigNumber): BigNumber { + pow(num: BigNumber): BigNumber { this.assert(num._sign === 0, 'Exponent for pow must be non-negative') if (num.isZero()) return new BigNumber(1n) @@ -792,16 +1006,17 @@ export default class BigNumber { return res } - private static normalizeNonNegativeBigInt (value: number | bigint, label: string): bigint { + private static normalizeNonNegativeBigInt(value: number | bigint, label: string): bigint { if (typeof value === 'number') { - if (!Number.isFinite(value) || !Number.isInteger(value) || value < 0) throw new Error(`${label} must be a non-negative integer`) + if (!Number.isFinite(value) || !Number.isInteger(value) || value < 0) + throw new Error(`${label} must be a non-negative integer`) return BigInt(value) } if (value < 0n) throw new Error(`${label} must be a non-negative integer`) return value } - iushln (bits: number | bigint): this { + iushln(bits: number | bigint): this { const normalizedBits = BigNumber.normalizeNonNegativeBigInt(bits, 'Shift bits') if (normalizedBits === 0n) return this this._magnitude <<= normalizedBits @@ -809,12 +1024,12 @@ export default class BigNumber { return this.strip() } - ishln (bits: number | bigint): this { + ishln(bits: number | bigint): this { this.assert(this._sign === 0, 'ishln requires positive number') return this.iushln(bits) } - iushrn (bits: number | bigint, hint?: number, extended?: BigNumber): this { + iushrn(bits: number | bigint, hint?: number, extended?: BigNumber): this { const normalizedBits = BigNumber.normalizeNonNegativeBigInt(bits, 'Shift bits') if (normalizedBits === 0n) { if (extended != null) extended._initializeState(0n, 0) @@ -830,22 +1045,30 @@ export default class BigNumber { return this.strip() } - ishrn (bits: number | bigint, hint?: number, extended?: BigNumber): this { + ishrn(bits: number | bigint, hint?: number, extended?: BigNumber): this { this.assert(this._sign === 0, 'ishrn requires positive number') return this.iushrn(bits, hint, extended) } - shln (bits: number | bigint): BigNumber { return this.clone().ishln(bits) } - ushln (bits: number | bigint): BigNumber { return this.clone().iushln(bits) } - shrn (bits: number | bigint): BigNumber { return this.clone().ishrn(bits) } - ushrn (bits: number | bigint): BigNumber { return this.clone().iushrn(bits) } + shln(bits: number | bigint): BigNumber { + return this.clone().ishln(bits) + } + ushln(bits: number | bigint): BigNumber { + return this.clone().iushln(bits) + } + shrn(bits: number | bigint): BigNumber { + return this.clone().ishrn(bits) + } + ushrn(bits: number | bigint): BigNumber { + return this.clone().iushrn(bits) + } - testn (bit: number): boolean { + testn(bit: number): boolean { this.assert(typeof bit === 'number' && bit >= 0) return ((this._magnitude >> BigInt(bit)) & 1n) !== 0n } - imaskn (bits: number): this { + imaskn(bits: number): this { this.assert(typeof bits === 'number' && bits >= 0) this.assert(this._sign === 0, 'imaskn works only with positive numbers') const Bb = BigInt(bits) @@ -858,14 +1081,39 @@ export default class BigNumber { return this.strip() } - maskn (bits: number): BigNumber { return this.clone().imaskn(bits) } - iaddn (num: number): this { this.assert(typeof num === 'number'); this.assert(Math.abs(num) <= BigNumber.MAX_IMULN_ARG, 'num is too large'); this._setValueFromSigned(this._getSignedValue() + BigInt(num)); return this } - _iaddn (num: number): this { return this.iaddn(num) } - isubn (num: number): this { this.assert(typeof num === 'number'); this.assert(Math.abs(num) <= BigNumber.MAX_IMULN_ARG, 'Assertion failed'); this._setValueFromSigned(this._getSignedValue() - BigInt(num)); return this } - addn (num: number): BigNumber { return this.clone().iaddn(num) } subn (num: number): BigNumber { return this.clone().isubn(num) } - iabs (): this { this._sign = 0; return this } abs (): BigNumber { return this.clone().iabs() } + maskn(bits: number): BigNumber { + return this.clone().imaskn(bits) + } + iaddn(num: number): this { + this.assert(typeof num === 'number') + this.assert(Math.abs(num) <= BigNumber.MAX_IMULN_ARG, 'num is too large') + this._setValueFromSigned(this._getSignedValue() + BigInt(num)) + return this + } + _iaddn(num: number): this { + return this.iaddn(num) + } + isubn(num: number): this { + this.assert(typeof num === 'number') + this.assert(Math.abs(num) <= BigNumber.MAX_IMULN_ARG, 'Assertion failed') + this._setValueFromSigned(this._getSignedValue() - BigInt(num)) + return this + } + addn(num: number): BigNumber { + return this.clone().iaddn(num) + } + subn(num: number): BigNumber { + return this.clone().isubn(num) + } + iabs(): this { + this._sign = 0 + return this + } + abs(): BigNumber { + return this.clone().iabs() + } - divmod (num: BigNumber, mode?: 'div' | 'mod', positive?: boolean): any { + divmod(num: BigNumber, mode?: 'div' | 'mod', positive?: boolean): any { this.assert(!num.isZero(), 'Division by zero') if (this.isZero()) { const z = new BigNumber(0n) @@ -878,33 +1126,38 @@ export default class BigNumber { return { div: this._bigNumberFromSigned(dV), mod: this._bigNumberFromSigned(mV) } } - private _computeMod (tV: bigint, nV: bigint, mode?: 'div' | 'mod', positive?: boolean): bigint | null { + private _computeMod( + tV: bigint, + nV: bigint, + mode?: 'div' | 'mod', + positive?: boolean + ): bigint | null { if (mode === 'div') return null let mV = tV % nV if (positive === true && mV < 0n) mV += nV < 0n ? -nV : nV return mV } - private _bigNumberFromSigned (v: bigint | null): BigNumber | null { + private _bigNumberFromSigned(v: bigint | null): BigNumber | null { if (v === null) return null const r = new BigNumber(0n) r._setValueFromSigned(v) return r } - div (num: BigNumber): BigNumber { + div(num: BigNumber): BigNumber { return this.divmod(num, 'div', false).div as BigNumber } - mod (num: BigNumber): BigNumber { + mod(num: BigNumber): BigNumber { return this.divmod(num, 'mod', false).mod as BigNumber } - umod (num: BigNumber): BigNumber { + umod(num: BigNumber): BigNumber { return this.divmod(num, 'mod', true).mod as BigNumber } - divRound (num: BigNumber): BigNumber { + divRound(num: BigNumber): BigNumber { this.assert(!num.isZero()) const tV = this._getSignedValue() const nV = num._getSignedValue() @@ -913,7 +1166,9 @@ export default class BigNumber { const m = tV % nV if (m === 0n) { - const r = new BigNumber(0n); r._setValueFromSigned(d); return r + const r = new BigNumber(0n) + r._setValueFromSigned(d) + return r } const absM = m < 0n ? -m : m @@ -926,10 +1181,12 @@ export default class BigNumber { d -= 1n } } - const r = new BigNumber(0n); r._setValueFromSigned(d); return r + const r = new BigNumber(0n) + r._setValueFromSigned(d) + return r } - modrn (numArg: number): number { + modrn(numArg: number): number { this.assert(numArg !== 0, 'Division by zero in modrn') const absDivisor = BigInt(Math.abs(numArg)) if (absDivisor === 0n) throw new Error('Division by zero in modrn') @@ -938,20 +1195,24 @@ export default class BigNumber { return numArg < 0 ? Number(-remainderMag) : Number(remainderMag) } - idivn (num: number): this { + idivn(num: number): this { this.assert(num !== 0) this.assert(Math.abs(num) <= BigNumber.MAX_IMULN_ARG, 'num is too large') this._setValueFromSigned(this._getSignedValue() / BigInt(num)) return this } - divn (num: number): BigNumber { return this.clone().idivn(num) } + divn(num: number): BigNumber { + return this.clone().idivn(num) + } - egcd (p: BigNumber): { a: BigNumber, b: BigNumber, gcd: BigNumber } { + egcd(p: BigNumber): { a: BigNumber; b: BigNumber; gcd: BigNumber } { this.assert(p._sign === 0, 'p must not be negative') this.assert(!p.isZero(), 'p must not be zero') let uV = this._getSignedValue() - let vV = p._magnitude; let a = 1n; let pa = 0n + let vV = p._magnitude + let a = 1n + let pa = 0n let b = 0n let pb = 1n while (vV !== 0n) { @@ -975,7 +1236,7 @@ export default class BigNumber { return { a: ra, b: rb, gcd: rg } } - gcd (num: BigNumber): BigNumber { + gcd(num: BigNumber): BigNumber { let u = this._magnitude let v = num._magnitude if (u === 0n) { @@ -998,7 +1259,7 @@ export default class BigNumber { return res } - invm (num: BigNumber): BigNumber { + invm(num: BigNumber): BigNumber { this.assert(!num.isZero() && num._sign === 0, 'Modulus for invm must be positive and non-zero') const eg = this.egcd(num) if (!eg.gcd.eqn(1)) { @@ -1007,33 +1268,161 @@ export default class BigNumber { return eg.a.umod(num) } - isEven (): boolean { return this._magnitude % 2n === 0n } isOdd (): boolean { return this._magnitude % 2n === 1n } - andln (num: number): number { this.assert(num >= 0); return Number(this._magnitude & BigInt(num)) } - bincn (bit: number): this { this.assert(typeof bit === 'number' && bit >= 0); const BVal = 1n << BigInt(bit); this._setValueFromSigned(this._getSignedValue() + BVal); return this } - isZero (): boolean { return this._magnitude === 0n } - cmpn (num: number): CompareResult { this.assert(Math.abs(num) <= BigNumber.MAX_IMULN_ARG, 'Number is too big'); const tV = this._getSignedValue(); const nV = BigInt(num); if (tV < nV) { return -1 } if (tV > nV) { return 1 } return 0 } - cmp (num: BigNumber): CompareResult { const tV = this._getSignedValue(); const nV = num._getSignedValue(); if (tV < nV) { return -1 } if (tV > nV) { return 1 } return 0 } - ucmp (num: BigNumber): CompareResult { if (this._magnitude < num._magnitude) { return -1 } if (this._magnitude > num._magnitude) { return 1 } return 0 } - gtn (num: number): boolean { return this.cmpn(num) === 1 } gt (num: BigNumber): boolean { return this.cmp(num) === 1 } gten (num: number): boolean { return this.cmpn(num) >= 0 } gte (num: BigNumber): boolean { return this.cmp(num) >= 0 } - ltn (num: number): boolean { return this.cmpn(num) === -1 } lt (num: BigNumber): boolean { return this.cmp(num) === -1 } lten (num: number): boolean { return this.cmpn(num) <= 0 } lte (num: BigNumber): boolean { return this.cmp(num) <= 0 } - eqn (num: number): boolean { return this.cmpn(num) === 0 } eq (num: BigNumber): boolean { return this.cmp(num) === 0 } - - toRed (ctx: ReductionContext): BigNumber { this.assert(this.red == null, 'Already a number in reduction context'); this.assert(this._sign === 0, 'toRed works only with positives'); return ctx.convertTo(this).forceRed(ctx) } - fromRed (): BigNumber { this.assert(this.red, 'fromRed works only with numbers in reduction context'); return this.red.convertFrom(this) } - forceRed (ctx: ReductionContext): this { this.red = ctx; return this } - redAdd (num: BigNumber): BigNumber { this.assert(this.red, 'redAdd works only with red numbers'); return this.red.add(this, num) } - redIAdd (num: BigNumber): BigNumber { this.assert(this.red, 'redIAdd works only with red numbers'); return this.red.iadd(this, num) } - redSub (num: BigNumber): BigNumber { this.assert(this.red, 'redSub works only with red numbers'); return this.red.sub(this, num) } - redISub (num: BigNumber): BigNumber { this.assert(this.red, 'redISub works only with red numbers'); return this.red.isub(this, num) } - redShl (num: number): BigNumber { this.assert(this.red, 'redShl works only with red numbers'); return this.red.shl(this, num) } - redMul (num: BigNumber): BigNumber { this.assert(this.red, 'redMul works only with red numbers'); this.red.verify2(this, num); return this.red.mul(this, num) } - redIMul (num: BigNumber): BigNumber { this.assert(this.red, 'redIMul works only with red numbers'); this.red.verify2(this, num); return this.red.imul(this, num) } - redSqr (): BigNumber { this.assert(this.red, 'redSqr works only with red numbers'); this.red.verify1(this); return this.red.sqr(this) } - redISqr (): BigNumber { this.assert(this.red, 'redISqr works only with red numbers'); this.red.verify1(this); return this.red.isqr(this) } - redSqrt (): BigNumber { this.assert(this.red, 'redSqrt works only with red numbers'); this.red.verify1(this); return this.red.sqrt(this) } - redInvm (): BigNumber { this.assert(this.red, 'redInvm works only with red numbers'); this.red.verify1(this); return this.red.invm(this) } - redNeg (): BigNumber { this.assert(this.red, 'redNeg works only with red numbers'); this.red.verify1(this); return this.red.neg(this) } - redPow (num: BigNumber): BigNumber { this.assert(this.red != null && num.red == null, 'redPow(normalNum)'); this.red.verify1(this); return this.red.pow(this, num) } + isEven(): boolean { + return this._magnitude % 2n === 0n + } + isOdd(): boolean { + return this._magnitude % 2n === 1n + } + andln(num: number): number { + this.assert(num >= 0) + return Number(this._magnitude & BigInt(num)) + } + bincn(bit: number): this { + this.assert(typeof bit === 'number' && bit >= 0) + const BVal = 1n << BigInt(bit) + this._setValueFromSigned(this._getSignedValue() + BVal) + return this + } + isZero(): boolean { + return this._magnitude === 0n + } + cmpn(num: number): CompareResult { + this.assert(Math.abs(num) <= BigNumber.MAX_IMULN_ARG, 'Number is too big') + const tV = this._getSignedValue() + const nV = BigInt(num) + if (tV < nV) { + return -1 + } + if (tV > nV) { + return 1 + } + return 0 + } + cmp(num: BigNumber): CompareResult { + const tV = this._getSignedValue() + const nV = num._getSignedValue() + if (tV < nV) { + return -1 + } + if (tV > nV) { + return 1 + } + return 0 + } + ucmp(num: BigNumber): CompareResult { + if (this._magnitude < num._magnitude) { + return -1 + } + if (this._magnitude > num._magnitude) { + return 1 + } + return 0 + } + gtn(num: number): boolean { + return this.cmpn(num) === 1 + } + gt(num: BigNumber): boolean { + return this.cmp(num) === 1 + } + gten(num: number): boolean { + return this.cmpn(num) >= 0 + } + gte(num: BigNumber): boolean { + return this.cmp(num) >= 0 + } + ltn(num: number): boolean { + return this.cmpn(num) === -1 + } + lt(num: BigNumber): boolean { + return this.cmp(num) === -1 + } + lten(num: number): boolean { + return this.cmpn(num) <= 0 + } + lte(num: BigNumber): boolean { + return this.cmp(num) <= 0 + } + eqn(num: number): boolean { + return this.cmpn(num) === 0 + } + eq(num: BigNumber): boolean { + return this.cmp(num) === 0 + } + + toRed(ctx: ReductionContext): BigNumber { + this.assert(this.red == null, 'Already a number in reduction context') + this.assert(this._sign === 0, 'toRed works only with positives') + return ctx.convertTo(this).forceRed(ctx) + } + fromRed(): BigNumber { + this.assert(this.red, 'fromRed works only with numbers in reduction context') + return this.red.convertFrom(this) + } + forceRed(ctx: ReductionContext): this { + this.red = ctx + return this + } + redAdd(num: BigNumber): BigNumber { + this.assert(this.red, 'redAdd works only with red numbers') + return this.red.add(this, num) + } + redIAdd(num: BigNumber): BigNumber { + this.assert(this.red, 'redIAdd works only with red numbers') + return this.red.iadd(this, num) + } + redSub(num: BigNumber): BigNumber { + this.assert(this.red, 'redSub works only with red numbers') + return this.red.sub(this, num) + } + redISub(num: BigNumber): BigNumber { + this.assert(this.red, 'redISub works only with red numbers') + return this.red.isub(this, num) + } + redShl(num: number): BigNumber { + this.assert(this.red, 'redShl works only with red numbers') + return this.red.shl(this, num) + } + redMul(num: BigNumber): BigNumber { + this.assert(this.red, 'redMul works only with red numbers') + this.red.verify2(this, num) + return this.red.mul(this, num) + } + redIMul(num: BigNumber): BigNumber { + this.assert(this.red, 'redIMul works only with red numbers') + this.red.verify2(this, num) + return this.red.imul(this, num) + } + redSqr(): BigNumber { + this.assert(this.red, 'redSqr works only with red numbers') + this.red.verify1(this) + return this.red.sqr(this) + } + redISqr(): BigNumber { + this.assert(this.red, 'redISqr works only with red numbers') + this.red.verify1(this) + return this.red.isqr(this) + } + redSqrt(): BigNumber { + this.assert(this.red, 'redSqrt works only with red numbers') + this.red.verify1(this) + return this.red.sqrt(this) + } + redInvm(): BigNumber { + this.assert(this.red, 'redInvm works only with red numbers') + this.red.verify1(this) + return this.red.invm(this) + } + redNeg(): BigNumber { + this.assert(this.red, 'redNeg works only with red numbers') + this.red.verify1(this) + return this.red.neg(this) + } + redPow(num: BigNumber): BigNumber { + this.assert(this.red != null && num.red == null, 'redPow(normalNum)') + this.red.verify1(this) + return this.red.pow(this, num) + } /** * Creates a BigNumber from a hexadecimal string. @@ -1048,7 +1437,7 @@ export default class BigNumber { * const exampleHex = 'a1b2c3'; * const bigNumber = BigNumber.fromHex(exampleHex); */ - static fromHex (hex: string, endian?: 'le' | 'be' | 'little' | 'big'): BigNumber { + static fromHex(hex: string, endian?: 'le' | 'be' | 'little' | 'big'): BigNumber { let eE: 'le' | 'be' = 'be' if (endian === 'little' || endian === 'le') eE = 'le' return new BigNumber(hex, 16, eE) @@ -1065,7 +1454,7 @@ export default class BigNumber { * const bigNumber = new BigNumber(255) * const hex = bigNumber.toHex() */ - toHex (byteLength: number = 0): string { + toHex(byteLength: number = 0): string { if (this.isZero() && byteLength === 0) return '' let hexStr = this._getMinimalHex() // Raw hex: "0", "f", "10", "123" @@ -1091,7 +1480,9 @@ export default class BigNumber { * @param str - The JSON-serialized string to create a BigNumber from. * @returns Returns a BigNumber created from the JSON input string. */ - static fromJSON (str: string): BigNumber { return new BigNumber(str, 16) } + static fromJSON(str: string): BigNumber { + return new BigNumber(str, 16) + } /** * Creates a BigNumber from a number. @@ -1101,7 +1492,9 @@ export default class BigNumber { * @param n - The number to create a BigNumber from. * @returns Returns a BigNumber equivalent to the input number. */ - static fromNumber (n: number): BigNumber { return new BigNumber(n) } + static fromNumber(n: number): BigNumber { + return new BigNumber(n) + } /** * Creates a BigNumber from a string, considering an optional base. @@ -1112,7 +1505,9 @@ export default class BigNumber { * @param base - The base used for conversion. If not provided, base 10 is assumed. * @returns Returns a BigNumber equivalent to the string after conversion from the specified base. */ - static fromString (str: string, base?: number | 'hex'): BigNumber { return new BigNumber(str, base) } + static fromString(str: string, base?: number | 'hex'): BigNumber { + return new BigNumber(str, base) + } /** * Creates a BigNumber from a signed magnitude number. @@ -1123,7 +1518,7 @@ export default class BigNumber { * @param endian - Defines endianess. If not provided, big endian is assumed. * @returns Returns a BigNumber equivalent to the signed magnitude number interpreted with specified endianess. */ - static fromSm (bytes: number[], endian: 'big' | 'little' = 'big'): BigNumber { + static fromSm(bytes: number[], endian: 'big' | 'little' = 'big'): BigNumber { if (bytes.length === 0) return new BigNumber(0n) const beBytes = bytes.slice() @@ -1159,7 +1554,7 @@ export default class BigNumber { * @param endian - Defines endianess. If not provided, big endian is assumed. * @returns Returns an array equivalent to this BigNumber interpreted as a signed magnitude with specified endianess. */ - toSm (endian: 'big' | 'little' = 'big'): number[] { + toSm(endian: 'big' | 'little' = 'big'): number[] { if (this._magnitude === 0n) { return this._sign === 1 ? [0x80] : [] } @@ -1168,7 +1563,7 @@ export default class BigNumber { if (hex.length % 2 !== 0) hex = '0' + hex const byteLen = hex.length / 2 - const bytes = new Array(byteLen) + const bytes = Array.from({ length: byteLen }) for (let i = 0, j = 0; i < hex.length; i += 2) { const high = HEX_CHAR_TO_VALUE[hex.codePointAt(i) as number] const low = HEX_CHAR_TO_VALUE[hex.codePointAt(i + 1) as number] @@ -1202,7 +1597,7 @@ export default class BigNumber { * @returns Returns a BigNumber equivalent to the "bits" value in a block header. * @throws Will throw an error if `strict` is `true` and the number has negative bit set. */ - static fromBits (bits: number, strict: boolean = false): BigNumber { + static fromBits(bits: number, strict: boolean = false): BigNumber { const nSize = bits >>> 24 const nWordCompact = bits & 0x007fffff const isNegativeFromBit = (bits & 0x00800000) !== 0 @@ -1237,7 +1632,7 @@ export default class BigNumber { * @method toBits * @returns Returns a number equivalent to the "bits" value in a block header. */ - toBits (): number { + toBits(): number { this.strip() if (this.isZero() && !this.isNeg()) return 0 @@ -1249,17 +1644,20 @@ export default class BigNumber { // Remove leading zeros from byte array, if any (toArray('be') might already do this if no length specified) let firstNonZeroIdx = 0 - while (firstNonZeroIdx < mB.length - 1 && mB[firstNonZeroIdx] === 0) { // Keep last byte if it's [0] + while (firstNonZeroIdx < mB.length - 1 && mB[firstNonZeroIdx] === 0) { + // Keep last byte if it's [0] firstNonZeroIdx++ } mB = mB.slice(firstNonZeroIdx) let nSize = mB.length - if (nSize === 0 && !bnAbs.isZero()) { // Should not happen if bnAbs is truly non-zero and toArray is correct + if (nSize === 0 && !bnAbs.isZero()) { + // Should not happen if bnAbs is truly non-zero and toArray is correct mB = [0] // Should not be needed if toArray works for small numbers nSize = 1 } - if (bnAbs.isZero()) { // if original was, e.g., -0, bnAbs is 0. + if (bnAbs.isZero()) { + // if original was, e.g., -0, bnAbs is 0. nSize = 0 // Size for 0 is 0, unless it's negative 0 to be encoded mB = [] } @@ -1272,11 +1670,13 @@ export default class BigNumber { for (let i = 0; i < nSize; i++) { nWordNum = (nWordNum << 8) | mB[i] } - } else { // nSize > 3 + } else { + // nSize > 3 nWordNum = (mB[0] << 16) | (mB[1] << 8) | mB[2] } - if ((nWordNum & 0x00800000) !== 0 && nSize <= 0xff) { // MSB of 3-byte mantissa is set + if ((nWordNum & 0x00800000) !== 0 && nSize <= 0xff) { + // MSB of 3-byte mantissa is set nWordNum >>>= 8 // Shift mantissa over by one byte nSize++ // Increase size component by one } @@ -1296,12 +1696,13 @@ export default class BigNumber { * @param maxNumSize - The maximum allowed size for the number. * @returns Returns a BigNumber equivalent to the number used in a Bitcoin script. */ - static fromScriptNum ( + static fromScriptNum( num: number[], requireMinimal: boolean = false, maxNumSize?: number ): BigNumber { - if (maxNumSize !== undefined && num.length > maxNumSize) throw new Error('script number overflow') + if (maxNumSize !== undefined && num.length > maxNumSize) + throw new Error('script number overflow') if (num.length === 0) return new BigNumber(0n) if (requireMinimal) { if ((num.at(-1) & 0x7f) === 0) { @@ -1319,7 +1720,9 @@ export default class BigNumber { * @method toScriptNum * @returns Returns the equivalent to this BigNumber as a Bitcoin script number. */ - toScriptNum (): number[] { return this.toSm('little') } + toScriptNum(): number[] { + return this.toSm('little') + } /** * Compute the multiplicative inverse of the current BigNumber in the modulus field specified by `p`. @@ -1336,8 +1739,8 @@ export default class BigNumber { * does not provide constant-time guarantees. This implementation is suitable * for browser and single-tenant environments but is not hardened against * high-resolution timing attacks in shared CPU contexts. - */ - _invmp (p: BigNumber): BigNumber { + */ + _invmp(p: BigNumber): BigNumber { this.assert(p._sign === 0, 'p must not be negative for _invmp') this.assert(!p.isZero(), 'p must not be zero for _invmp') @@ -1377,7 +1780,7 @@ export default class BigNumber { * @param out - The BigNumber where to store the result. * @returns The BigNumber resulting from the multiplication operation. */ - mulTo (num: BigNumber, out: BigNumber): BigNumber { + mulTo(num: BigNumber, out: BigNumber): BigNumber { out._magnitude = this._magnitude * num._magnitude out._sign = out._magnitude === 0n ? 0 : ((this._sign ^ num._sign) as 0 | 1) out._nominalWordLength = this.length + num.length diff --git a/packages/sdk/src/primitives/Curve.ts b/packages/sdk/src/primitives/Curve.ts index 1e2994e10..4620d3e2e 100644 --- a/packages/sdk/src/primitives/Curve.ts +++ b/packages/sdk/src/primitives/Curve.ts @@ -23,7 +23,8 @@ export default class Curve { tinv: BigNumber zeroA: boolean threeA: boolean - endo: { beta: BigNumber, lambda: BigNumber, basis: Array<{ a: BigNumber, b: BigNumber }> } | undefined // beta, lambda, basis + endo: + { beta: BigNumber; lambda: BigNumber; basis: Array<{ a: BigNumber; b: BigNumber }> } | undefined // beta, lambda, basis _endoWnafT1: BigNumber[] _endoWnafT2: BigNumber[] _wnafT1: BigNumber[] @@ -33,17 +34,14 @@ export default class Curve { _bitLength: number // Represent num in a w-NAF form - static assert ( - expression: unknown, - message: string = 'Elliptic curve assertion failed' - ): void { + static assert(expression: unknown, message: string = 'Elliptic curve assertion failed'): void { if (!(expression as boolean)) { throw new Error(message) } } - getNAF (num: BigNumber, w: number, bits: number): number[] { - const naf = new Array(Math.max(num.bitLength(), bits) + 1) + getNAF(num: BigNumber, w: number, bits: number): number[] { + const naf = Array.from({ length: Math.max(num.bitLength(), bits) + 1 }, () => 0) naf.fill(0) const ws = 1 << (w + 1) @@ -71,7 +69,7 @@ export default class Curve { } // Represent k1, k2 in a Joint Sparse Form - getJSF (k1: BigNumber, k2: BigNumber): number[][] { + getJSF(k1: BigNumber, k2: BigNumber): number[][] { const jsf: number[][] = [[], []] k1 = k1.clone() @@ -128,9 +126,9 @@ export default class Curve { return jsf } - static cachedProperty (obj, name: string, computer): void { + static cachedProperty(obj, name: string, computer): void { const key = '_' + name - obj.prototype[name] = function cachedProperty () { + obj.prototype[name] = function cachedProperty() { if (this[key] === undefined) { this[key] = computer.call(this) } @@ -138,15 +136,15 @@ export default class Curve { } } - static parseBytes (bytes: string | number[]): number[] { + static parseBytes(bytes: string | number[]): number[] { return typeof bytes === 'string' ? toArray(bytes, 'hex') : bytes } - static intFromLE (bytes: number[]): BigNumber { + static intFromLE(bytes: number[]): BigNumber { return new BigNumber(bytes, 'hex', 'le') } - constructor () { + constructor() { if (globalCurve === undefined) { /* eslint-disable-next-line @typescript-eslint/no-this-alias */ globalCurve = this @@ -944,8 +942,7 @@ export default class Curve { // Precomputed endomorphism beta: '7ae96a2b657c07106e64479eac3434e99cf0497512f58995c1396c28719501ee', - lambda: - '5363ad4cc05c30e0a5261c028812645a122e22ea20816678df02967c1b23bd72', + lambda: '5363ad4cc05c30e0a5261c028812645a122e22ea20816678df02967c1b23bd72', basis: [ { a: '3086d221a7d46bcde86c90e49284eb15', @@ -977,13 +974,23 @@ export default class Curve { // Curve configuration, optional this.n = new BigNumber(conf.n, 16) - this.g = Point.fromJSON(conf.g as [string, string, { doubles?: { step: number, points: Array<[string, string]> }, naf?: { wnd: number, points: Array<[string, string]> } }], conf.gRed) + this.g = Point.fromJSON( + conf.g as [ + string, + string, + { + doubles?: { step: number; points: Array<[string, string]> } + naf?: { wnd: number; points: Array<[string, string]> } + } + ], + conf.gRed + ) // Temporary arrays - this._wnafT1 = new Array(4) - this._wnafT2 = new Array(4) - this._wnafT3 = new Array(4) - this._wnafT4 = new Array(4) + this._wnafT1 = Array.from({ length: 4 }, () => undefined as unknown as BigNumber) + this._wnafT2 = Array.from({ length: 4 }, () => undefined as unknown as BigNumber) + this._wnafT3 = Array.from({ length: 4 }, () => undefined as unknown as BigNumber) + this._wnafT4 = Array.from({ length: 4 }, () => undefined as unknown as BigNumber) this._bitLength = this.n.bitLength() this.redN = this.n.toRed(this.red) @@ -996,17 +1003,17 @@ export default class Curve { // If the curve is endomorphic, precalculate beta and lambda this.endo = this._getEndomorphism(conf) - this._endoWnafT1 = new Array(4) - this._endoWnafT2 = new Array(4) + this._endoWnafT1 = Array.from({ length: 4 }, () => undefined as unknown as BigNumber) + this._endoWnafT2 = Array.from({ length: 4 }, () => undefined as unknown as BigNumber) } - _getEndomorphism (conf): - | { - beta: BigNumber - lambda: BigNumber - basis: Array<{ a: BigNumber, b: BigNumber }> - } - | undefined { + _getEndomorphism(conf): + | { + beta: BigNumber + lambda: BigNumber + basis: Array<{ a: BigNumber; b: BigNumber }> + } + | undefined { // No efficient endomorphism if (!this.zeroA || this.p.modrn(3) !== 1) { return @@ -1040,9 +1047,9 @@ export default class Curve { } const gMulX = this.g.mul(lambdas[0])?.x - const gXRedMulBeta = (this.g.x == null) ? undefined : this.g.x.redMul(beta) + const gXRedMulBeta = this.g.x == null ? undefined : this.g.x.redMul(beta) - if ((gMulX != null) && (gXRedMulBeta != null) && gMulX.cmp(gXRedMulBeta) === 0) { + if (gMulX != null && gXRedMulBeta != null && gMulX.cmp(gXRedMulBeta) === 0) { lambda = lambdas[0] } else { lambda = lambdas[1] @@ -1052,10 +1059,12 @@ export default class Curve { } const gMulX = this.g.mul(lambda)?.x - const gXRedMulBeta = (this.g.x == null) ? undefined : this.g.x.redMul(beta) + const gXRedMulBeta = this.g.x == null ? undefined : this.g.x.redMul(beta) - if ((gMulX == null) || (gXRedMulBeta == null)) { - throw new Error('Lambda computation failed: g.mul(lambda).x or g.x.redMul(beta) is undefined.') + if (gMulX == null || gXRedMulBeta == null) { + throw new Error( + 'Lambda computation failed: g.mul(lambda).x or g.x.redMul(beta) is undefined.' + ) } Curve.assert( @@ -1068,7 +1077,7 @@ export default class Curve { } // Get basis vectors, used for balanced length-two representation - let basis: Array<{ a: BigNumber, b: BigNumber }> + let basis: Array<{ a: BigNumber; b: BigNumber }> if (typeof conf.basis === 'object' && conf.basis !== null) { basis = conf.basis.map(function (vec) { return { @@ -1087,7 +1096,7 @@ export default class Curve { } } - _getEndoRoots (num: BigNumber): [BigNumber, BigNumber] { + _getEndoRoots(num: BigNumber): [BigNumber, BigNumber] { // Find roots of for x^2 + x + 1 in F // Root = (-1 +- Sqrt(-3)) / 2 // @@ -1102,9 +1111,9 @@ export default class Curve { return [l1, l2] } - _getEndoBasis ( + _getEndoBasis( lambda: BigNumber - ): [{ a: BigNumber, b: BigNumber }, { a: BigNumber, b: BigNumber }] { + ): [{ a: BigNumber; b: BigNumber }, { a: BigNumber; b: BigNumber }] { // aprxSqrt >= sqrt(this.n) const aprxSqrt = this.n.ushrn(Math.floor(this.n.bitLength() / 2)) @@ -1157,12 +1166,7 @@ export default class Curve { } // Ensure a0 and b0 have been assigned - if ( - a0 === undefined || - b0 === undefined || - a1 === undefined || - b1 === undefined - ) { + if (a0 === undefined || b0 === undefined || a1 === undefined || b1 === undefined) { throw new Error('Failed to compute Endo Basis values') } @@ -1193,7 +1197,7 @@ export default class Curve { ] } - _endoSplit (k: BigNumber): { k1: BigNumber, k2: BigNumber } { + _endoSplit(k: BigNumber): { k1: BigNumber; k2: BigNumber } { if (this.endo == null) { throw new Error('Endomorphism is not defined.') } @@ -1215,7 +1219,7 @@ export default class Curve { return { k1, k2 } } - validate (point: Point): boolean { + validate(point: Point): boolean { if (point.inf) { return true } diff --git a/packages/sdk/src/primitives/DRBG.ts b/packages/sdk/src/primitives/DRBG.ts index f8b23da4b..03d73ffe3 100644 --- a/packages/sdk/src/primitives/DRBG.ts +++ b/packages/sdk/src/primitives/DRBG.ts @@ -30,7 +30,7 @@ export default class DRBG { K: number[] V: number[] - constructor (entropy: number[] | string, nonce: number[] | string) { + constructor(entropy: number[] | string, nonce: number[] | string) { const entropyBytes = toArray(entropy, 'hex') const nonceBytes = toArray(nonce, 'hex') @@ -44,8 +44,8 @@ export default class DRBG { const seedMaterial = entropyBytes.concat(nonceBytes) - this.K = new Array(32) - this.V = new Array(32) + this.K = Array.from({ length: 32 }) + this.V = Array.from({ length: 32 }) for (let i = 0; i < 32; i++) { this.K[i] = 0x00 this.V[i] = 0x01 @@ -63,7 +63,7 @@ export default class DRBG { * @example * const hmac = drbg.hmac(); */ - hmac (): SHA256HMAC { + hmac(): SHA256HMAC { return new SHA256HMAC(this.K) } @@ -78,7 +78,7 @@ export default class DRBG { * @example * drbg.update('e13af...'); */ - update (seed?: number[]): void { + update(seed?: number[]): void { let kmac = this.hmac().update(this.V).update([0x00]) if (seed !== undefined) { kmac = kmac.update(seed) @@ -104,7 +104,7 @@ export default class DRBG { * @example * const randomHex = drbg.generate(256); */ - generate (len: number): string { + generate(len: number): string { let temp: number[] = [] while (temp.length < len) { this.V = this.hmac().update(this.V).digest() diff --git a/packages/sdk/src/primitives/Hash.ts b/packages/sdk/src/primitives/Hash.ts index d363db05d..95af5318a 100644 --- a/packages/sdk/src/primitives/Hash.ts +++ b/packages/sdk/src/primitives/Hash.ts @@ -1,12 +1,8 @@ - // @ts-nocheck /* eslint-disable @typescript-eslint/naming-convention */ import { assertValidHex, normalizeHex } from './hex.js' -const assert = ( - expression: unknown, - message: string = 'Hash assertion failed' -): void => { +const assert = (expression: unknown, message: string = 'Hash assertion failed'): void => { if (!(expression as boolean)) { throw new Error(message) } @@ -54,12 +50,7 @@ abstract class BaseHash { padLength: number hmacStrength: number - constructor ( - blockSize: number, - outSize: number, - hmacStrength: number, - padLength: number - ) { + constructor(blockSize: number, outSize: number, hmacStrength: number, padLength: number) { this.pending = null this.pendingTotal = 0 this.blockSize = blockSize @@ -72,15 +63,15 @@ abstract class BaseHash { this._delta32 = this.blockSize / 32 } - _update (msg: number[], start: number): void { + _update(_msg: number[], _start: number): void { throw new Error('Not implemented') } - _digest (): number[] { + _digest(): number[] { throw new Error('Not implemented') } - _digestHex (): string { + _digestHex(): string { throw new Error('Not implemented') } @@ -97,7 +88,7 @@ abstract class BaseHash { * @example * sha256.update('Hello World', 'utf8'); */ - update (msg: number[] | string, enc?: 'hex' | 'utf8'): this { + update(msg: number[] | string, enc?: 'hex' | 'utf8'): this { // Convert message to array, pad it, and join into 32bit blocks msg = toArray(msg, enc) if (this.pending == null) { @@ -137,7 +128,7 @@ abstract class BaseHash { * @example * const hash = sha256.digest(); */ - digest (): number[] { + digest(): number[] { this.update(this._pad()) assert(this.pending === null) @@ -154,7 +145,7 @@ abstract class BaseHash { * @example * const hash = sha256.digestHex(); */ - digestHex (): string { + digestHex(): string { this.update(this._pad()) assert(this.pending === null) @@ -169,7 +160,7 @@ abstract class BaseHash { * * @returns Returns an array denoting the padding. */ - private _pad (): number[] { + private _pad(): number[] { const len = this.pendingTotal if (!Number.isSafeInteger(len) || len < 0) { throw new Error('Message too long for this hash function') @@ -177,7 +168,7 @@ abstract class BaseHash { const bytes = this._delta8 const k = bytes - ((len + this.padLength) % bytes) - const res = new Array(k + this.padLength) + const res = Array.from({ length: k + this.padLength }) res[0] = 0x80 let i: number for (i = 1; i < k; i++) { @@ -192,7 +183,7 @@ abstract class BaseHash { } if (this.endian === 'big') { - const lenArray = new Array(lengthBytes) + const lenArray = Array.from({ length: lengthBytes }) for (let b = lengthBytes - 1; b >= 0; b--) { lenArray[b] = Number(totalBits & 0xffn) @@ -213,7 +204,7 @@ abstract class BaseHash { } } -function isSurrogatePair (msg: string, i: number): boolean { +function isSurrogatePair(msg: string, i: number): boolean { if ((msg.charCodeAt(i) & 0xfc00) !== 0xd800) { return false } @@ -235,7 +226,7 @@ function isSurrogatePair (msg: string, i: number): boolean { * Apache License 2.0 * https://github.com/google/closure-library/blob/master/LICENSE */ -function appendUtf8CodeUnit (msg: string, i: number, out: number[]): number { +function appendUtf8CodeUnit(msg: string, i: number, out: number[]): number { let c = msg.charCodeAt(i) if (c < 128) { out.push(c) @@ -247,19 +238,14 @@ function appendUtf8CodeUnit (msg: string, i: number, out: number[]): number { } if (isSurrogatePair(msg, i)) { c = 0x10000 + ((c & 0x03ff) << 10) + (msg.charCodeAt(i + 1) & 0x03ff) - out.push( - (c >> 18) | 240, - ((c >> 12) & 63) | 128, - ((c >> 6) & 63) | 128, - (c & 63) | 128 - ) + out.push((c >> 18) | 240, ((c >> 12) & 63) | 128, ((c >> 6) & 63) | 128, (c & 63) | 128) return i + 1 } out.push((c >> 12) | 224, ((c >> 6) & 63) | 128, (c & 63) | 128) return i } -function utf8StringToArray (msg: string): number[] { +function utf8StringToArray(msg: string): number[] { const res: number[] = [] let i = 0 while (i < msg.length) { @@ -269,7 +255,7 @@ function utf8StringToArray (msg: string): number[] { return res } -function hexStringToArray (msg: string): number[] { +function hexStringToArray(msg: string): number[] { assertValidHex(msg) const normalized = normalizeHex(msg) const res: number[] = [] @@ -279,7 +265,7 @@ function hexStringToArray (msg: string): number[] { return res } -function numberArrayToByteArray (msg: number[]): number[] { +function numberArrayToByteArray(msg: number[]): number[] { const res: number[] = [] for (let i = 0; i < msg.length; i++) { res[i] = Math.trunc(msg[i]) @@ -293,10 +279,7 @@ function numberArrayToByteArray (msg: number[]): number[] { * @param enc Optional. Encoding to use if msg is string. Default is 'utf8'. * @returns array of byte values from msg. If msg is an array, a copy is returned. */ -export function toArray ( - msg: number[] | string, - enc?: 'hex' | 'utf8' -): number[] { +export function toArray(msg: number[] | string, enc?: 'hex' | 'utf8'): number[] { if (Array.isArray(msg)) { return msg.slice() } @@ -316,11 +299,11 @@ export function toArray ( * Use `swapBytes32()` for explicit byte swapping, or `realHtonl()` for * standards-compliant host-to-network conversion. */ -export function htonl (w: number): number { +export function htonl(w: number): number { return swapBytes32(w) } -function toHex32 (msg: number[], endian?: 'little' | 'big'): string { +function toHex32(msg: number[], endian?: 'little' | 'big'): string { let res = '' for (let w of msg) { if (endian === 'little') { @@ -331,7 +314,7 @@ function toHex32 (msg: number[], endian?: 'little' | 'big'): string { return res } -function zero8 (word: string): string { +function zero8(word: string): string { if (word.length === 7) { return '0' + word } else if (word.length === 6) { @@ -351,28 +334,25 @@ function zero8 (word: string): string { } } -const BufferCtor = - typeof globalThis === 'undefined' ? undefined : (globalThis as any).Buffer -const CAN_USE_BUFFER = - BufferCtor != null && typeof BufferCtor.from === 'function' +const BufferCtor = typeof globalThis === 'undefined' ? undefined : (globalThis as any).Buffer +const CAN_USE_BUFFER = BufferCtor != null && typeof BufferCtor.from === 'function' const HEX_DIGITS = '0123456789abcdef' -const HEX_BYTE_STRINGS = new Array(256) +const HEX_BYTE_STRINGS = Array.from({ length: 256 }) for (let i = 0; i < HEX_BYTE_STRINGS.length; i++) { HEX_BYTE_STRINGS[i] = HEX_DIGITS[(i >> 4) & 0xf] + HEX_DIGITS[i & 0xf] } -function bytesToHex (data: Uint8Array): string { +function bytesToHex(data: Uint8Array): string { if (CAN_USE_BUFFER) { return BufferCtor.from(data).toString('hex') } - const out = new Array(data.length) + const out = Array.from({ length: data.length }) for (let i = 0; i < data.length; i++) out[i] = HEX_BYTE_STRINGS[data[i]] return out.join('') } const NODE_CRYPTO = (() => { - const processLike = - typeof globalThis === 'undefined' ? undefined : (globalThis as any).process + const processLike = typeof globalThis === 'undefined' ? undefined : (globalThis as any).process const getBuiltinModule = processLike?.getBuiltinModule if (typeof getBuiltinModule === 'function') { try { @@ -387,7 +367,7 @@ const NODE_CRYPTO = (() => { type HashInput = Uint8Array | number[] | string -function toHashBytes (msg: HashInput, enc?: 'hex' | 'utf8'): Uint8Array { +function toHashBytes(msg: HashInput, enc?: 'hex' | 'utf8'): Uint8Array { if (msg instanceof Uint8Array) { return msg } @@ -397,7 +377,7 @@ function toHashBytes (msg: HashInput, enc?: 'hex' | 'utf8'): Uint8Array { return Uint8Array.from(toArray(msg, enc)) } -function toHashKeyBytes (key: HashInput): Uint8Array { +function toHashKeyBytes(key: HashInput): Uint8Array { return typeof key === 'string' ? toHashBytes(key, 'hex') : toHashBytes(key) } @@ -406,7 +386,7 @@ interface FallbackHashLike { digest: () => Uint8Array } -function updateNativeOrFallback ( +function updateNativeOrFallback( native: any, fallback: FallbackHashLike | undefined, data: Uint8Array @@ -418,25 +398,19 @@ function updateNativeOrFallback ( } } -function digestNativeOrFallback ( - native: any, - fallback: FallbackHashLike | undefined -): number[] { +function digestNativeOrFallback(native: any, fallback: FallbackHashLike | undefined): number[] { if (native != null) return Array.from(native.digest()) if (fallback != null) return Array.from(fallback.digest()) return [] } -function digestHexNativeOrFallback ( - native: any, - fallback: FallbackHashLike | undefined -): string { +function digestHexNativeOrFallback(native: any, fallback: FallbackHashLike | undefined): string { if (native != null) return native.digest('hex') if (fallback != null) return bytesToHex(fallback.digest()) return '' } -function createNodeHash (algorithm: string): any { +function createNodeHash(algorithm: string): any { const createHash = NODE_CRYPTO?.createHash if (typeof createHash !== 'function') return undefined try { @@ -446,7 +420,7 @@ function createNodeHash (algorithm: string): any { } } -function createNodeHmac (algorithm: string, keyBytes: Uint8Array): any { +function createNodeHmac(algorithm: string, keyBytes: Uint8Array): any { const createHmac = NODE_CRYPTO?.createHmac if (typeof createHmac !== 'function') return undefined try { @@ -456,7 +430,7 @@ function createNodeHmac (algorithm: string, keyBytes: Uint8Array): any { } } -function digestWithNodeHash ( +function digestWithNodeHash( algorithm: string, msg: HashInput, enc?: 'hex' | 'utf8' @@ -467,7 +441,7 @@ function digestWithNodeHash ( return hash.digest() } -function digestWithNodeHmac ( +function digestWithNodeHmac( algorithm: string, key: HashInput, msg: HashInput, @@ -479,10 +453,10 @@ function digestWithNodeHmac ( return hmac.digest() } -function join32 (msg, start, end, endian): number[] { +function join32(msg, start, end, endian): number[] { const len = end - start assert(len % 4 === 0) - const res = new Array(len / 4) + const res = Array.from({ length: len / 4 }) for (let i = 0, k: number = start; i < res.length; i++, k += 4) { let w if (endian === 'big') { @@ -495,8 +469,8 @@ function join32 (msg, start, end, endian): number[] { return res } -function split32 (msg: number[], endian: 'big' | 'little'): number[] { - const res = new Array(msg.length * 4) +function split32(msg: number[], endian: 'big' | 'little'): number[] { + const res = Array.from({ length: msg.length * 4 }) for (let i = 0, k = 0; i < msg.length; i++, k += 4) { const m = msg[i] if (endian === 'big') { @@ -514,37 +488,31 @@ function split32 (msg: number[], endian: 'big' | 'little'): number[] { return res } -function rotr32 (w: number, b: number): number { +function rotr32(w: number, b: number): number { return (w >>> b) | (w << (32 - b)) } -function rotl32 (w: number, b: number): number { +function rotl32(w: number, b: number): number { return (w << b) | (w >>> (32 - b)) } -function sum32 (a: number, b: number): number { +function sum32(a: number, b: number): number { return (a + b) >>> 0 } -function SUM32_3 (a: number, b: number, c: number): number { +function SUM32_3(a: number, b: number, c: number): number { return (a + b + c) >>> 0 } -function SUM32_4 (a: number, b: number, c: number, d: number): number { +function SUM32_4(a: number, b: number, c: number, d: number): number { return (a + b + c + d) >>> 0 } -function SUM32_5 ( - a: number, - b: number, - c: number, - d: number, - e: number -): number { +function SUM32_5(a: number, b: number, c: number, d: number, e: number): number { return (a + b + c + d + e) >>> 0 } -function FT_1 (s, x, y, z): number { +function FT_1(s, x, y, z): number { if (s === 0) { return ch32(x, y, z) } @@ -557,63 +525,59 @@ function FT_1 (s, x, y, z): number { return 0 } -function ch32 (x, y, z): number { +function ch32(x, y, z): number { return (x & y) ^ (~x & z) } -function maj32 (x, y, z): number { +function maj32(x, y, z): number { return (x & y) ^ (x & z) ^ (y & z) } -function p32 (x, y, z): number { +function p32(x, y, z): number { return x ^ y ^ z } -function S0_256 (x): number { +function S0_256(x): number { return rotr32(x, 2) ^ rotr32(x, 13) ^ rotr32(x, 22) } -function S1_256 (x): number { +function S1_256(x): number { return rotr32(x, 6) ^ rotr32(x, 11) ^ rotr32(x, 25) } -function G0_256 (x): number { +function G0_256(x): number { return rotr32(x, 7) ^ rotr32(x, 18) ^ (x >>> 3) } -function G1_256 (x): number { +function G1_256(x): number { return rotr32(x, 17) ^ rotr32(x, 19) ^ (x >>> 10) } const r = [ - 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 7, 4, 13, 1, 10, 6, 15, - 3, 12, 0, 9, 5, 2, 14, 11, 8, 3, 10, 14, 4, 9, 15, 8, 1, 2, 7, 0, 6, 13, 11, - 5, 12, 1, 9, 11, 10, 0, 8, 12, 4, 13, 3, 7, 15, 14, 5, 6, 2, 4, 0, 5, 9, 7, - 12, 2, 10, 14, 1, 3, 8, 11, 6, 15, 13 + 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 7, 4, 13, 1, 10, 6, 15, 3, 12, 0, 9, 5, 2, + 14, 11, 8, 3, 10, 14, 4, 9, 15, 8, 1, 2, 7, 0, 6, 13, 11, 5, 12, 1, 9, 11, 10, 0, 8, 12, 4, 13, 3, + 7, 15, 14, 5, 6, 2, 4, 0, 5, 9, 7, 12, 2, 10, 14, 1, 3, 8, 11, 6, 15, 13 ] const rh = [ - 5, 14, 7, 0, 9, 2, 11, 4, 13, 6, 15, 8, 1, 10, 3, 12, 6, 11, 3, 7, 0, 13, 5, - 10, 14, 15, 8, 12, 4, 9, 1, 2, 15, 5, 1, 3, 7, 14, 6, 9, 11, 8, 12, 2, 10, 0, - 4, 13, 8, 6, 4, 1, 3, 11, 15, 0, 5, 12, 2, 13, 9, 7, 10, 14, 12, 15, 10, 4, 1, - 5, 8, 7, 6, 2, 13, 14, 0, 3, 9, 11 + 5, 14, 7, 0, 9, 2, 11, 4, 13, 6, 15, 8, 1, 10, 3, 12, 6, 11, 3, 7, 0, 13, 5, 10, 14, 15, 8, 12, 4, + 9, 1, 2, 15, 5, 1, 3, 7, 14, 6, 9, 11, 8, 12, 2, 10, 0, 4, 13, 8, 6, 4, 1, 3, 11, 15, 0, 5, 12, 2, + 13, 9, 7, 10, 14, 12, 15, 10, 4, 1, 5, 8, 7, 6, 2, 13, 14, 0, 3, 9, 11 ] const s = [ - 11, 14, 15, 12, 5, 8, 7, 9, 11, 13, 14, 15, 6, 7, 9, 8, 7, 6, 8, 13, 11, 9, 7, - 15, 7, 12, 15, 9, 11, 7, 13, 12, 11, 13, 6, 7, 14, 9, 13, 15, 14, 8, 13, 6, 5, - 12, 7, 5, 11, 12, 14, 15, 14, 15, 9, 8, 9, 14, 5, 6, 8, 6, 5, 12, 9, 15, 5, - 11, 6, 8, 13, 12, 5, 12, 13, 14, 11, 8, 5, 6 + 11, 14, 15, 12, 5, 8, 7, 9, 11, 13, 14, 15, 6, 7, 9, 8, 7, 6, 8, 13, 11, 9, 7, 15, 7, 12, 15, 9, + 11, 7, 13, 12, 11, 13, 6, 7, 14, 9, 13, 15, 14, 8, 13, 6, 5, 12, 7, 5, 11, 12, 14, 15, 14, 15, 9, + 8, 9, 14, 5, 6, 8, 6, 5, 12, 9, 15, 5, 11, 6, 8, 13, 12, 5, 12, 13, 14, 11, 8, 5, 6 ] const sh = [ - 8, 9, 9, 11, 13, 15, 15, 5, 7, 7, 8, 11, 14, 14, 12, 6, 9, 13, 15, 7, 12, 8, - 9, 11, 7, 7, 12, 7, 6, 15, 13, 11, 9, 7, 15, 11, 8, 6, 6, 14, 12, 13, 5, 14, - 13, 13, 7, 5, 15, 5, 8, 11, 14, 14, 6, 14, 6, 9, 12, 9, 12, 5, 15, 8, 8, 5, - 12, 9, 12, 5, 14, 6, 8, 13, 6, 5, 15, 13, 11, 11 + 8, 9, 9, 11, 13, 15, 15, 5, 7, 7, 8, 11, 14, 14, 12, 6, 9, 13, 15, 7, 12, 8, 9, 11, 7, 7, 12, 7, + 6, 15, 13, 11, 9, 7, 15, 11, 8, 6, 6, 14, 12, 13, 5, 14, 13, 13, 7, 5, 15, 5, 8, 11, 14, 14, 6, + 14, 6, 9, 12, 9, 12, 5, 15, 8, 8, 5, 12, 9, 12, 5, 14, 6, 8, 13, 6, 5, 15, 13, 11, 11 ] -function f (j, x, y, z): number { +function f(j, x, y, z): number { if (j <= 15) { return x ^ y ^ z } else if (j <= 31) { @@ -627,7 +591,7 @@ function f (j, x, y, z): number { } } -function K (j): number { +function K(j): number { if (j <= 15) { return 0x00000000 } else if (j <= 31) { @@ -641,7 +605,7 @@ function K (j): number { } } -function Kh (j): number { +function Kh(j): number { if (j <= 15) { return 0x50a28be6 } else if (j <= 31) { @@ -675,7 +639,7 @@ function Kh (j): number { export class RIPEMD160 extends BaseHash { h: number[] - constructor () { + constructor() { super(512, 160, 192, 64) this.endian = 'little' @@ -683,7 +647,7 @@ export class RIPEMD160 extends BaseHash { this.endian = 'little' } - _update (msg: number[], start: number): void { + _update(msg: number[], start: number): void { let A = this.h[0] let B = this.h[1] let C = this.h[2] @@ -696,22 +660,13 @@ export class RIPEMD160 extends BaseHash { let Eh = E let T for (let j = 0; j < 80; j++) { - T = sum32( - rotl32(SUM32_4(A, f(j, B, C, D), msg[r[j] + start], K(j)), s[j]), - E - ) + T = sum32(rotl32(SUM32_4(A, f(j, B, C, D), msg[r[j] + start], K(j)), s[j]), E) A = E E = D D = rotl32(C, 10) C = B B = T - T = sum32( - rotl32( - SUM32_4(Ah, f(79 - j, Bh, Ch, Dh), msg[rh[j] + start], Kh(j)), - sh[j] - ), - Eh - ) + T = sum32(rotl32(SUM32_4(Ah, f(79 - j, Bh, Ch, Dh), msg[rh[j] + start], Kh(j)), sh[j]), Eh) Ah = Eh Eh = Dh Dh = rotl32(Ch, 10) @@ -726,11 +681,11 @@ export class RIPEMD160 extends BaseHash { this.h[0] = T } - _digest (): number[] { + _digest(): number[] { return split32(this.h, 'little') } - _digestHex (): string { + _digestHex(): string { return toHex32(this.h, 'little') } } @@ -758,23 +713,23 @@ export class SHA256 { private readonly h?: FastSHA256 private readonly native?: any - constructor () { + constructor() { this.native = createNodeHash('sha256') if (this.native == null) { this.h = new FastSHA256() } } - update (msg: HashInput, enc?: 'hex' | 'utf8'): this { + update(msg: HashInput, enc?: 'hex' | 'utf8'): this { updateNativeOrFallback(this.native, this.h, toHashBytes(msg, enc)) return this } - digest (): number[] { + digest(): number[] { return digestNativeOrFallback(this.native, this.h) } - digestHex (): string { + digestHex(): string { return digestHexNativeOrFallback(this.native, this.h) } } @@ -803,14 +758,14 @@ export class SHA1 extends BaseHash { W: number[] k: number[] - constructor () { + constructor() { super(512, 160, 80, 64) this.k = [0x5a827999, 0x6ed9eba1, 0x8f1bbcdc, 0xca62c1d6] this.h = [0x67452301, 0xefcdab89, 0x98badcfe, 0x10325476, 0xc3d2e1f0] - this.W = new Array(80) + this.W = Array.from({ length: 80 }) } - _update (msg: number[], start?: number): void { + _update(msg: number[], start?: number): void { const W = this.W // Default start to 0 @@ -850,11 +805,11 @@ export class SHA1 extends BaseHash { this.h[4] = sum32(this.h[4], e) } - _digest (): number[] { + _digest(): number[] { return split32(this.h, 'big') } - _digestHex (): string { + _digestHex(): string { return toHex32(this.h, 'big') } } @@ -882,23 +837,23 @@ export class SHA512 { private readonly h?: FastSHA512 private readonly native?: any - constructor () { + constructor() { this.native = createNodeHash('sha512') if (this.native == null) { this.h = new FastSHA512() } } - update (msg: HashInput, enc?: 'hex' | 'utf8'): this { + update(msg: HashInput, enc?: 'hex' | 'utf8'): this { updateNativeOrFallback(this.native, this.h, toHashBytes(msg, enc)) return this } - digest (): number[] { + digest(): number[] { return digestNativeOrFallback(this.native, this.h) } - digestHex (): string { + digestHex(): string { return digestHexNativeOrFallback(this.native, this.h) } } @@ -934,7 +889,7 @@ export class SHA256HMAC { * @example * const myHMAC = new SHA256HMAC('deadbeef'); */ - constructor (key: HashInput) { + constructor(key: HashInput) { const k = toHashKeyBytes(key) this.native = createNodeHmac('sha256', k) if (this.native == null) { @@ -953,7 +908,7 @@ export class SHA256HMAC { * @example * myHMAC.update('deadbeef', 'hex'); */ - update (msg: HashInput, enc?: 'hex'): this { + update(msg: HashInput, enc?: 'hex'): this { updateNativeOrFallback(this.native, this.h, toHashBytes(msg, enc)) return this } @@ -967,7 +922,7 @@ export class SHA256HMAC { * @example * let hashedMessage = myHMAC.digest(); */ - digest (): number[] { + digest(): number[] { return digestNativeOrFallback(this.native, this.h) } @@ -980,7 +935,7 @@ export class SHA256HMAC { * @example * let hashedMessage = myHMAC.digestHex(); */ - digestHex (): string { + digestHex(): string { return digestHexNativeOrFallback(this.native, this.h) } } @@ -990,7 +945,7 @@ export class SHA1HMAC { outer: SHA1 blockSize = 64 - constructor (key: number[] | string) { + constructor(key: number[] | string) { key = toArray(key, 'hex') // Shorten key, if needed if (key.length > this.blockSize) { @@ -1015,17 +970,17 @@ export class SHA1HMAC { this.outer = new SHA1().update(key) } - update (msg: number[] | string, enc?: 'hex'): this { + update(msg: number[] | string, enc?: 'hex'): this { this.inner.update(msg, enc) return this } - digest (): number[] { + digest(): number[] { this.outer.update(this.inner.digest()) return this.outer.digest() } - digestHex (): string { + digestHex(): string { this.outer.update(this.inner.digest()) return this.outer.digestHex() } @@ -1062,7 +1017,7 @@ export class SHA512HMAC { * @example * const myHMAC = new SHA512HMAC('deadbeef'); */ - constructor (key: HashInput) { + constructor(key: HashInput) { const k = toHashKeyBytes(key) this.native = createNodeHmac('sha512', k) if (this.native == null) { @@ -1081,7 +1036,7 @@ export class SHA512HMAC { * @example * myHMAC.update('deadbeef', 'hex'); */ - update (msg: HashInput, enc?: 'hex' | 'utf8'): this { + update(msg: HashInput, enc?: 'hex' | 'utf8'): this { updateNativeOrFallback(this.native, this.h, toHashBytes(msg, enc)) return this } @@ -1095,7 +1050,7 @@ export class SHA512HMAC { * @example * let hashedMessage = myHMAC.digest(); */ - digest (): number[] { + digest(): number[] { return digestNativeOrFallback(this.native, this.h) } @@ -1108,30 +1063,24 @@ export class SHA512HMAC { * @example * let hashedMessage = myHMAC.digestHex(); */ - digestHex (): string { + digestHex(): string { return digestHexNativeOrFallback(this.native, this.h) } } -function sha256Bytes (msg: HashInput, enc?: 'hex' | 'utf8'): Uint8Array { +function sha256Bytes(msg: HashInput, enc?: 'hex' | 'utf8'): Uint8Array { const native = digestWithNodeHash('sha256', msg, enc) if (native != null) return native return new FastSHA256().update(toHashBytes(msg, enc)).digest() } -function sha512Bytes ( - msg: HashInput, - enc?: 'hex' | 'utf8' -): Uint8Array { +function sha512Bytes(msg: HashInput, enc?: 'hex' | 'utf8'): Uint8Array { const native = digestWithNodeHash('sha512', msg, enc) if (native != null) return native return new FastSHA512().update(toHashBytes(msg, enc)).digest() } -function ripemd160Bytes ( - msg: HashInput, - enc?: 'hex' | 'utf8' -): Uint8Array | undefined { +function ripemd160Bytes(msg: HashInput, enc?: 'hex' | 'utf8'): Uint8Array | undefined { return digestWithNodeHash('ripemd160', msg, enc) } @@ -1146,10 +1095,7 @@ function ripemd160Bytes ( * @example * const digest = ripemd160('Hello, world!'); */ -export const ripemd160 = ( - msg: number[] | string, - enc?: 'hex' | 'utf8' -): number[] => { +export const ripemd160 = (msg: number[] | string, enc?: 'hex' | 'utf8'): number[] => { const native = ripemd160Bytes(msg, enc) if (native != null) return Array.from(native) return new RIPEMD160().update(msg, enc).digest() @@ -1166,10 +1112,7 @@ export const ripemd160 = ( * @example * const digest = sha1('Hello, world!'); */ -export const sha1 = ( - msg: number[] | string, - enc?: 'hex' | 'utf8' -): number[] => { +export const sha1 = (msg: number[] | string, enc?: 'hex' | 'utf8'): number[] => { return new SHA1().update(msg, enc).digest() } @@ -1251,11 +1194,7 @@ export const hash160 = (msg: HashInput, enc?: 'hex' | 'utf8'): number[] => { * @example * const digest = sha256hmac('deadbeef', 'ffff001d'); */ -export const sha256hmac = ( - key: HashInput, - msg: HashInput, - enc?: 'hex' -): number[] => { +export const sha256hmac = (key: HashInput, msg: HashInput, enc?: 'hex'): number[] => { const native = digestWithNodeHmac('sha256', key, msg, enc) if (native != null) return Array.from(native) return new SHA256HMAC(key).update(msg, enc).digest() @@ -1273,11 +1212,7 @@ export const sha256hmac = ( * @example * const digest = sha512hmac('deadbeef', 'ffff001d'); */ -export const sha512hmac = ( - key: HashInput, - msg: HashInput, - enc?: 'hex' -): number[] => { +export const sha512hmac = (key: HashInput, msg: HashInput, enc?: 'hex'): number[] => { const native = digestWithNodeHmac('sha512', key, msg, enc) if (native != null) return Array.from(native) return new SHA512HMAC(key).update(msg, enc).digest() @@ -1285,33 +1220,35 @@ export const sha512hmac = ( // BEGIN fast-pbkdf2 helpers // Utils -function isBytes (a: unknown): a is Uint8Array { +function isBytes(a: unknown): a is Uint8Array { return a instanceof Uint8Array || (ArrayBuffer.isView(a) && a.constructor.name === 'Uint8Array') } -function anumber (n: number): void { +function anumber(n: number): void { if (!Number.isSafeInteger(n) || n < 0) { throw new Error(`positive integer expected, got ${n}`) } } -function abytes (b: Uint8Array | undefined, ...lengths: number[]): void { +function abytes(b: Uint8Array | undefined, ...lengths: number[]): void { if (!isBytes(b)) throw new Error('Uint8Array expected') if (lengths.length > 0 && !lengths.includes(b.length)) { const lens = lengths.join(',') throw new Error(`Uint8Array expected of length ${lens}, got length=${b.length}`) } } -function ahash (h: IHash): void { - if (typeof h !== 'function' || typeof h.create !== 'function') { throw new TypeError('Hash should be wrapped by utils.createHasher') } +function ahash(h: IHash): void { + if (typeof h !== 'function' || typeof h.create !== 'function') { + throw new TypeError('Hash should be wrapped by utils.createHasher') + } anumber(h.outputLen) anumber(h.blockLen) } -function aexists (instance: any, checkFinished = true): void { +function aexists(instance: any, checkFinished = true): void { if (instance.destroyed === true) throw new Error('Hash instance has been destroyed') if (checkFinished && instance.finished === true) { throw new Error('Hash#digest() has already been called') } } -function aoutput (out: any, instance: any): void { +function aoutput(out: any, instance: any): void { abytes(out) const min: number = instance.outputLen as number if (out.length < min) { @@ -1319,32 +1256,26 @@ function aoutput (out: any, instance: any): void { } } type TypedArray = - | Int8Array - | Uint8ClampedArray - | Uint8Array - | Uint16Array - | Int16Array - | Uint32Array - | Int32Array - -function clean (...arrays: TypedArray[]): void { + Int8Array | Uint8ClampedArray | Uint8Array | Uint16Array | Int16Array | Uint32Array | Int32Array + +function clean(...arrays: TypedArray[]): void { for (const arr of arrays) arr.fill(0) } -function createView (arr: TypedArray): DataView { +function createView(arr: TypedArray): DataView { return new DataView(arr.buffer, arr.byteOffset, arr.byteLength) } -function toBytes (data: Input): Uint8Array { +function toBytes(data: Input): Uint8Array { if (typeof data === 'string') data = utf8ToBytes(data) abytes(data) return data } -function utf8ToBytes (str: string): Uint8Array { +function utf8ToBytes(str: string): Uint8Array { if (typeof str !== 'string') throw new Error('string expected') return new Uint8Array(new TextEncoder().encode(str)) } type Input = string | Uint8Array type KDFInput = string | Uint8Array -function kdfInputToBytes (data: KDFInput): Uint8Array { +function kdfInputToBytes(data: KDFInput): Uint8Array { if (typeof data === 'string') data = utf8ToBytes(data) abytes(data) return data @@ -1365,14 +1296,14 @@ interface Hasher> { abstract class Hash> { abstract blockLen: number abstract outputLen: number - abstract update (buf: Input): this - abstract digestInto (buf: Uint8Array): void - abstract digest (): Uint8Array - abstract destroy (): void - abstract _cloneInto (to?: T): T - abstract clone (): T -} -function createHasher> (hashCons: () => Hash): Hasher { + abstract update(buf: Input): this + abstract digestInto(buf: Uint8Array): void + abstract digest(): Uint8Array + abstract destroy(): void + abstract _cloneInto(to?: T): T + abstract clone(): T +} +function createHasher>(hashCons: () => Hash): Hasher { const hashC = (msg: Input): Uint8Array => hashCons().update(toBytes(msg)).digest() const tmp = hashCons() hashC.outputLen = tmp.outputLen @@ -1384,12 +1315,12 @@ function createHasher> (hashCons: () => Hash): Hasher { // u64 helpers const U32_MASK64 = BigInt(2 ** 32 - 1) const _32n = BigInt(32) -function fromBig (n: bigint, le = false): { h: number, l: number } { +function fromBig(n: bigint, le = false): { h: number; l: number } { if (le) return { h: Number(n & U32_MASK64), l: Number((n >> _32n) & U32_MASK64) } // eslint-disable-next-line no-bitwise -- ToInt32 (ECMA-262); not truncation. Required for SHA arithmetic. return { h: Number((n >> _32n) & U32_MASK64) | 0, l: Number(n & U32_MASK64) | 0 } } -function split (lst: bigint[], le = false): Uint32Array[] { +function split(lst: bigint[], le = false): Uint32Array[] { const len = lst.length const Ah = new Uint32Array(len) const Al = new Uint32Array(len) @@ -1406,17 +1337,20 @@ const rotrSH = (h: number, l: number, s: number): number => (h >>> s) | (l << (3 const rotrSL = (h: number, l: number, s: number): number => (h << (32 - s)) | (l >>> s) const rotrBH = (h: number, l: number, s: number): number => (h << (64 - s)) | (l >>> (s - 32)) const rotrBL = (h: number, l: number, s: number): number => (h >>> (s - 32)) | (l << (64 - s)) -function add (Ah: number, Al: number, Bh: number, Bl: number): { h: number, l: number } { +function add(Ah: number, Al: number, Bh: number, Bl: number): { h: number; l: number } { const l = (Al >>> 0) + (Bl >>> 0) // eslint-disable-next-line no-bitwise -- ToInt32 (ECMA-262); not truncation. Required for SHA arithmetic. return { h: (Ah + Bh + ((l / 2 ** 32) | 0)) | 0, l: l | 0 } } const add3L = (Al: number, Bl: number, Cl: number): number => (Al >>> 0) + (Bl >>> 0) + (Cl >>> 0) // eslint-disable-next-line no-bitwise -- ToInt32 (ECMA-262); not truncation. Required for SHA arithmetic. -const add3H = (low: number, Ah: number, Bh: number, Ch: number): number => (Ah + Bh + Ch + ((low / 2 ** 32) | 0)) | 0 -const add4L = (Al: number, Bl: number, Cl: number, Dl: number): number => (Al >>> 0) + (Bl >>> 0) + (Cl >>> 0) + (Dl >>> 0) +const add3H = (low: number, Ah: number, Bh: number, Ch: number): number => + (Ah + Bh + Ch + ((low / 2 ** 32) | 0)) | 0 +const add4L = (Al: number, Bl: number, Cl: number, Dl: number): number => + (Al >>> 0) + (Bl >>> 0) + (Cl >>> 0) + (Dl >>> 0) // eslint-disable-next-line no-bitwise -- ToInt32 (ECMA-262); not truncation. Required for SHA arithmetic. -const add4H = (low: number, Ah: number, Bh: number, Ch: number, Dh: number): number => (Ah + Bh + Ch + Dh + ((low / 2 ** 32) | 0)) | 0 +const add4H = (low: number, Ah: number, Bh: number, Ch: number, Dh: number): number => + (Ah + Bh + Ch + Dh + ((low / 2 ** 32) | 0)) | 0 const add5L = (Al: number, Bl: number, Cl: number, Dl: number, El: number): number => (Al >>> 0) + (Bl >>> 0) + (Cl >>> 0) + (Dl >>> 0) + (El >>> 0) // eslint-disable-next-line no-bitwise -- ToInt32 (ECMA-262); not truncation. Required for SHA arithmetic. @@ -1435,7 +1369,7 @@ abstract class HashMD> extends Hash { protected length = 0 protected pos = 0 protected destroyed = false - constructor (blockLen: number, outputLen: number, padOffset: number, isLE: boolean) { + constructor(blockLen: number, outputLen: number, padOffset: number, isLE: boolean) { super() this.blockLen = blockLen this.outputLen = outputLen @@ -1445,12 +1379,12 @@ abstract class HashMD> extends Hash { this.view = createView(this.buffer) } - protected abstract process (buf: DataView, offset: number): void - protected abstract get (): number[] - protected abstract set (...args: number[]): void - abstract destroy (): void - protected abstract roundClean (): void - update (data: Input): this { + protected abstract process(buf: DataView, offset: number): void + protected abstract get(): number[] + protected abstract set(...args: number[]): void + abstract destroy(): void + protected abstract roundClean(): void + update(data: Input): this { aexists(this) data = toBytes(data) abytes(data) @@ -1476,7 +1410,7 @@ abstract class HashMD> extends Hash { return this } - digestInto (out: Uint8Array): void { + digestInto(out: Uint8Array): void { aexists(this) aoutput(out, this) this.finished = true @@ -1500,7 +1434,7 @@ abstract class HashMD> extends Hash { for (let i = 0; i < outLen; i++) oview.setUint32(4 * i, state[i], isLE) } - digest (): Uint8Array { + digest(): Uint8Array { const { buffer, outputLen } = this this.digestInto(buffer) const res = buffer.slice(0, outputLen) @@ -1508,7 +1442,7 @@ abstract class HashMD> extends Hash { return res } - _cloneInto (to?: T): T { + _cloneInto(to?: T): T { to ||= new (this.constructor as any)() as T to.set(...this.get()) const { blockLen, buffer, length, finished, destroyed, pos } = this @@ -1520,11 +1454,11 @@ abstract class HashMD> extends Hash { return to } - clone (): T { + clone(): T { return this._cloneInto() } } -function setBigUint64 (view: DataView, byteOffset: number, value: bigint, isLE: boolean): void { +function setBigUint64(view: DataView, byteOffset: number, value: bigint, isLE: boolean): void { if (typeof view.setBigUint64 === 'function') return view.setBigUint64(byteOffset, value, isLE) const _32n = BigInt(32) const _u32_max = BigInt(0xffffffff) @@ -1538,21 +1472,17 @@ function setBigUint64 (view: DataView, byteOffset: number, value: bigint, isLE: // sha256 fast constants const SHA256_IV = Uint32Array.from([ - 0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a, - 0x510e527f, 0x9b05688c, 0x1f83d9ab, 0x5be0cd19 + 0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a, 0x510e527f, 0x9b05688c, 0x1f83d9ab, 0x5be0cd19 ]) const K256 = Uint32Array.from([ - 0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, - 0x923f82a4, 0xab1c5ed5, 0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, - 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174, 0xe49b69c1, 0xefbe4786, - 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da, - 0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147, - 0x06ca6351, 0x14292967, 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13, - 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85, 0xa2bfe8a1, 0xa81a664b, - 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070, - 0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, - 0x5b9cca4f, 0x682e6ff3, 0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, - 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2 + 0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5, + 0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174, + 0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da, + 0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967, + 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85, + 0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070, + 0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3, + 0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2 ]) const SHA256_W = new Uint32Array(64) @@ -1573,16 +1503,16 @@ class FastSHA256 extends HashMD { protected G = SHA256_IV[6] | 0 // eslint-disable-next-line no-bitwise -- ToInt32 (ECMA-262); not truncation. Required for SHA arithmetic. protected H = SHA256_IV[7] | 0 - constructor (outputLen = 32) { + constructor(outputLen = 32) { super(64, outputLen, 8, false) } - protected get (): number[] { + protected get(): number[] { const { A, B, C, D, E, F, G, H } = this return [A, B, C, D, E, F, G, H] } - protected set ( + protected set( A: number, B: number, C: number, @@ -1610,7 +1540,7 @@ class FastSHA256 extends HashMD { this.H = H | 0 } - protected process (view: DataView, offset: number): void { + protected process(view: DataView, offset: number): void { for (let i = 0; i < 16; i++, offset += 4) { SHA256_W[i] = view.getUint32(offset) } @@ -1645,11 +1575,11 @@ class FastSHA256 extends HashMD { this.H = sum32(this.H, H) } - protected roundClean (): void { + protected roundClean(): void { clean(SHA256_W) } - destroy (): void { + destroy(): void { clean(this.buffer) this.set(0, 0, 0, 0, 0, 0, 0, 0) } @@ -1662,89 +1592,90 @@ const SHA512_IV = Uint32Array.from([ 0x510e527f, 0xade682d1, 0x9b05688c, 0x2b3e6c1f, 0x1f83d9ab, 0xfb41bd6b, 0x5be0cd19, 0x137e2179 ]) const K512 = (() => - split([ - '0x428a2f98d728ae22', - '0x7137449123ef65cd', - '0xb5c0fbcfec4d3b2f', - '0xe9b5dba58189dbbc', - '0x3956c25bf348b538', - '0x59f111f1b605d019', - '0x923f82a4af194f9b', - '0xab1c5ed5da6d8118', - '0xd807aa98a3030242', - '0x12835b0145706fbe', - '0x243185be4ee4b28c', - '0x550c7dc3d5ffb4e2', - '0x72be5d74f27b896f', - '0x80deb1fe3b1696b1', - '0x9bdc06a725c71235', - '0xc19bf174cf692694', - '0xe49b69c19ef14ad2', - '0xefbe4786384f25e3', - '0x0fc19dc68b8cd5b5', - '0x240ca1cc77ac9c65', - '0x2de92c6f592b0275', - '0x4a7484aa6ea6e483', - '0x5cb0a9dcbd41fbd4', - '0x76f988da831153b5', - '0x983e5152ee66dfab', - '0xa831c66d2db43210', - '0xb00327c898fb213f', - '0xbf597fc7beef0ee4', - '0xc6e00bf33da88fc2', - '0xd5a79147930aa725', - '0x06ca6351e003826f', - '0x142929670a0e6e70', - '0x27b70a8546d22ffc', - '0x2e1b21385c26c926', - '0x4d2c6dfc5ac42aed', - '0x53380d139d95b3df', - '0x650a73548baf63de', - '0x766a0abb3c77b2a8', - '0x81c2c92e47edaee6', - '0x92722c851482353b', - '0xa2bfe8a14cf10364', - '0xa81a664bbc423001', - '0xc24b8b70d0f89791', - '0xc76c51a30654be30', - '0xd192e819d6ef5218', - '0xd69906245565a910', - '0xf40e35855771202a', - '0x106aa07032bbd1b8', - '0x19a4c116b8d2d0c8', - '0x1e376c085141ab53', - '0x2748774cdf8eeb99', - '0x34b0bcb5e19b48a8', - '0x391c0cb3c5c95a63', - '0x4ed8aa4ae3418acb', - '0x5b9cca4f7763e373', - '0x682e6ff3d6b2b8a3', - '0x748f82ee5defb2fc', - '0x78a5636f43172f60', - '0x84c87814a1f0ab72', - '0x8cc702081a6439ec', - '0x90befffa23631e28', - '0xa4506cebde82bde9', - '0xbef9a3f7b2c67915', - '0xc67178f2e372532b', - '0xca273eceea26619c', - '0xd186b8c721c0c207', - '0xeada7dd6cde0eb1e', - '0xf57d4f7fee6ed178', - '0x06f067aa72176fba', - '0x0a637dc5a2c898a6', - '0x113f9804bef90dae', - '0x1b710b35131c471b', - '0x28db77f523047d84', - '0x32caab7b40c72493', - '0x3c9ebe0a15c9bebc', - '0x431d67c49c100d4c', - '0x4cc5d4becb3e42b6', - '0x597f299cfc657e2a', - '0x5fcb6fab3ad6faec', - '0x6c44198c4a475817' - ].map(BigInt)) -)() + split( + [ + '0x428a2f98d728ae22', + '0x7137449123ef65cd', + '0xb5c0fbcfec4d3b2f', + '0xe9b5dba58189dbbc', + '0x3956c25bf348b538', + '0x59f111f1b605d019', + '0x923f82a4af194f9b', + '0xab1c5ed5da6d8118', + '0xd807aa98a3030242', + '0x12835b0145706fbe', + '0x243185be4ee4b28c', + '0x550c7dc3d5ffb4e2', + '0x72be5d74f27b896f', + '0x80deb1fe3b1696b1', + '0x9bdc06a725c71235', + '0xc19bf174cf692694', + '0xe49b69c19ef14ad2', + '0xefbe4786384f25e3', + '0x0fc19dc68b8cd5b5', + '0x240ca1cc77ac9c65', + '0x2de92c6f592b0275', + '0x4a7484aa6ea6e483', + '0x5cb0a9dcbd41fbd4', + '0x76f988da831153b5', + '0x983e5152ee66dfab', + '0xa831c66d2db43210', + '0xb00327c898fb213f', + '0xbf597fc7beef0ee4', + '0xc6e00bf33da88fc2', + '0xd5a79147930aa725', + '0x06ca6351e003826f', + '0x142929670a0e6e70', + '0x27b70a8546d22ffc', + '0x2e1b21385c26c926', + '0x4d2c6dfc5ac42aed', + '0x53380d139d95b3df', + '0x650a73548baf63de', + '0x766a0abb3c77b2a8', + '0x81c2c92e47edaee6', + '0x92722c851482353b', + '0xa2bfe8a14cf10364', + '0xa81a664bbc423001', + '0xc24b8b70d0f89791', + '0xc76c51a30654be30', + '0xd192e819d6ef5218', + '0xd69906245565a910', + '0xf40e35855771202a', + '0x106aa07032bbd1b8', + '0x19a4c116b8d2d0c8', + '0x1e376c085141ab53', + '0x2748774cdf8eeb99', + '0x34b0bcb5e19b48a8', + '0x391c0cb3c5c95a63', + '0x4ed8aa4ae3418acb', + '0x5b9cca4f7763e373', + '0x682e6ff3d6b2b8a3', + '0x748f82ee5defb2fc', + '0x78a5636f43172f60', + '0x84c87814a1f0ab72', + '0x8cc702081a6439ec', + '0x90befffa23631e28', + '0xa4506cebde82bde9', + '0xbef9a3f7b2c67915', + '0xc67178f2e372532b', + '0xca273eceea26619c', + '0xd186b8c721c0c207', + '0xeada7dd6cde0eb1e', + '0xf57d4f7fee6ed178', + '0x06f067aa72176fba', + '0x0a637dc5a2c898a6', + '0x113f9804bef90dae', + '0x1b710b35131c471b', + '0x28db77f523047d84', + '0x32caab7b40c72493', + '0x3c9ebe0a15c9bebc', + '0x431d67c49c100d4c', + '0x4cc5d4becb3e42b6', + '0x597f299cfc657e2a', + '0x5fcb6fab3ad6faec', + '0x6c44198c4a475817' + ].map(BigInt) + ))() const SHA512_Kh = (() => K512[0])() const SHA512_Kl = (() => K512[1])() const SHA512_W_H = new Uint32Array(80) @@ -1783,16 +1714,16 @@ class FastSHA512 extends HashMD { protected Hh = SHA512_IV[14] | 0 // eslint-disable-next-line no-bitwise -- ToInt32 (ECMA-262); not truncation. Required for SHA arithmetic. protected Hl = SHA512_IV[15] | 0 - constructor (outputLen = 64) { + constructor(outputLen = 64) { super(128, outputLen, 16, false) } - protected get (): number[] { + protected get(): number[] { const { Ah, Al, Bh, Bl, Ch, Cl, Dh, Dl, Eh, El, Fh, Fl, Gh, Gl, Hh, Hl } = this return [Ah, Al, Bh, Bl, Ch, Cl, Dh, Dl, Eh, El, Fh, Fl, Gh, Gl, Hh, Hl] } - protected set ( + protected set( Ah: number, Al: number, Bh: number, @@ -1844,7 +1775,7 @@ class FastSHA512 extends HashMD { this.Hl = Hl | 0 } - protected process (view: DataView, offset: number): void { + protected process(view: DataView, offset: number): void { for (let i = 0; i < 16; i++, offset += 8) { SHA512_W_H[i] = view.getUint32(offset) SHA512_W_L[i] = view.getUint32(offset + 4) @@ -1925,11 +1856,11 @@ class FastSHA512 extends HashMD { this.set(Ah, Al, Bh, Bl, Ch, Cl, Dh, Dl, Eh, El, Fh, Fl, Gh, Gl, Hh, Hl) } - protected roundClean (): void { + protected roundClean(): void { clean(SHA512_W_H, SHA512_W_L) } - destroy (): void { + destroy(): void { clean(this.buffer) this.set(0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0) } @@ -1943,12 +1874,17 @@ class HMAC> extends Hash> { outputLen: number private finished = false private destroyed = false - constructor (hash: (msg: Input) => Uint8Array & { create: () => T, blockLen: number, outputLen: number }, _key: Input) { + constructor( + hash: (msg: Input) => Uint8Array & { create: () => T; blockLen: number; outputLen: number }, + _key: Input + ) { super() ahash(hash) const key = toBytes(_key) this.iHash = hash.create() as T - if (typeof (this.iHash as any).update !== 'function') { throw new TypeError('Expected instance of class which extends utils.Hash') } + if (typeof (this.iHash as any).update !== 'function') { + throw new TypeError('Expected instance of class which extends utils.Hash') + } this.blockLen = this.iHash.blockLen this.outputLen = this.iHash.outputLen const blockLen = this.blockLen @@ -1962,13 +1898,13 @@ class HMAC> extends Hash> { clean(pad) } - update (buf: Input): this { + update(buf: Input): this { aexists(this) this.iHash.update(buf) return this } - digestInto (out: Uint8Array): void { + digestInto(out: Uint8Array): void { aexists(this) abytes(out, this.outputLen) this.finished = true @@ -1978,13 +1914,13 @@ class HMAC> extends Hash> { this.destroy() } - digest (): Uint8Array { + digest(): Uint8Array { const out = new Uint8Array(this.oHash.outputLen) this.digestInto(out) return out } - _cloneInto (to?: HMAC): HMAC { + _cloneInto(to?: HMAC): HMAC { to ||= Object.create(Object.getPrototypeOf(this), {}) const { oHash, iHash, finished, destroyed, blockLen, outputLen } = this to = to as this @@ -1997,18 +1933,25 @@ class HMAC> extends Hash> { return to } - clone (): HMAC { + clone(): HMAC { return this._cloneInto() } - destroy (): void { + destroy(): void { this.destroyed = true this.oHash.destroy() this.iHash.destroy() } } -function pbkdf2Core (hash: (msg: Input) => Uint8Array & { create: () => FastSHA512, blockLen: number, outputLen: number }, password: KDFInput, salt: KDFInput, opts: { c: number, dkLen?: number }): Uint8Array { +function pbkdf2Core( + hash: ( + msg: Input + ) => Uint8Array & { create: () => FastSHA512; blockLen: number; outputLen: number }, + password: KDFInput, + salt: KDFInput, + opts: { c: number; dkLen?: number } +): Uint8Array { ahash(hash) const { c, dkLen } = Object.assign({ dkLen: 32 }, opts) anumber(c) @@ -2045,7 +1988,12 @@ const hmac = (hash: any, key: Input, message: Input): Uint8Array => new HMAC(hash, key).update(message).digest() hmac.create = (hash: any, key: Input) => new HMAC(hash, key) -function pbkdf2Fast (password: Uint8Array, salt: Uint8Array, iterations: number, keylen: number): Uint8Array { +function pbkdf2Fast( + password: Uint8Array, + salt: Uint8Array, + iterations: number, + keylen: number +): Uint8Array { return pbkdf2Core(sha512Fast, password, salt, { c: iterations, dkLen: keylen }) } // END fast-pbkdf2 helpers @@ -2061,7 +2009,7 @@ function pbkdf2Fast (password: Uint8Array, salt: Uint8Array, iterations: number, * * @returns The computed key */ -export function pbkdf2 ( +export function pbkdf2( password: number[], salt: number[], iterations: number, @@ -2073,13 +2021,7 @@ export function pbkdf2 ( } const pbkdf2Sync = NODE_CRYPTO?.pbkdf2Sync if (typeof pbkdf2Sync === 'function') { - const out = pbkdf2Sync( - toHashBytes(password), - toHashBytes(salt), - iterations, - keylen, - digest - ) + const out = pbkdf2Sync(toHashBytes(password), toHashBytes(salt), iterations, keylen, digest) return Array.from(out) } const p = Uint8Array.from(password) @@ -2106,12 +2048,8 @@ export function pbkdf2 ( * @example * swapBytes32(0x11223344) // → 0x44332211 */ -export function swapBytes32 (w: number): number { - const res = - (w >>> 24) | - ((w >>> 8) & 0xff00) | - ((w << 8) & 0xff0000) | - ((w & 0xff) << 24) +export function swapBytes32(w: number): number { + const res = (w >>> 24) | ((w >>> 8) & 0xff00) | ((w << 8) & 0xff0000) | ((w & 0xff) << 24) return res >>> 0 } @@ -2147,6 +2085,6 @@ const isLittleEndian = (() => { * @example * realHtonl(0x11223344) // → 0x44332211 on little-endian systems */ -export function realHtonl (w: number): number { - return isLittleEndian ? swapBytes32(w) : (w >>> 0) +export function realHtonl(w: number): number { + return isLittleEndian ? swapBytes32(w) : w >>> 0 } diff --git a/packages/sdk/src/primitives/K256.ts b/packages/sdk/src/primitives/K256.ts index e8ffbcfbc..090d1c746 100644 --- a/packages/sdk/src/primitives/K256.ts +++ b/packages/sdk/src/primitives/K256.ts @@ -22,11 +22,8 @@ export default class K256 extends Mersenne { * @example * const k256 = new K256(); */ - constructor () { - super( - 'k256', - 'ffffffff ffffffff ffffffff ffffffff ffffffff ffffffff fffffffe fffffc2f' - ) + constructor() { + super('k256', 'ffffffff ffffffff ffffffff ffffffff ffffffff ffffffff fffffffe fffffc2f') } /** @@ -42,13 +39,16 @@ export default class K256 extends Mersenne { * const output = new BigNumber(0); * k256.split(input, output); */ - split (input: BigNumber, output: BigNumber): void { + split(input: BigNumber, output: BigNumber): void { const mask = 0x3fffff // 22 bits const inputWords = input.words // Access via getter const inputNominalLength = input.length // Access via getter, respects _nominalWordLength const outLen = Math.min(inputNominalLength, 9) - const tempOutputWords = new Array(outLen + (inputNominalLength > 9 ? 1 : 0)).fill(0) + const tempOutputWords = Array.from( + { length: outLen + (inputNominalLength > 9 ? 1 : 0) }, + () => 0 + ) for (let i = 0; i < outLen; i++) { tempOutputWords[i] = inputWords[i] @@ -56,7 +56,7 @@ export default class K256 extends Mersenne { let currentOutputWordCount = outLen if (inputNominalLength <= 9) { - const finalOutputWords = new Array(currentOutputWordCount) + const finalOutputWords = Array.from({ length: currentOutputWordCount }, () => 0) for (let i = 0; i < currentOutputWordCount; ++i) finalOutputWords[i] = tempOutputWords[i] output.words = finalOutputWords // Use setter @@ -68,31 +68,34 @@ export default class K256 extends Mersenne { let prev = inputWords[9] tempOutputWords[currentOutputWordCount++] = prev & mask - const finalOutputWords = new Array(currentOutputWordCount) + const finalOutputWords = Array.from({ length: currentOutputWordCount }, () => 0) for (let i = 0; i < currentOutputWordCount; ++i) finalOutputWords[i] = tempOutputWords[i] output.words = finalOutputWords // Use setter for output // For input modification - const tempInputNewWords = new Array(Math.max(1, inputNominalLength - 9)).fill(0) + const tempInputNewWords = Array.from({ length: Math.max(1, inputNominalLength - 9) }, () => 0) let currentInputNewWordCount = 0 for (let i = 10; i < inputNominalLength; i++) { const next = Math.trunc(inputWords[i]) - if (currentInputNewWordCount < tempInputNewWords.length) { // Boundary check + if (currentInputNewWordCount < tempInputNewWords.length) { + // Boundary check tempInputNewWords[currentInputNewWordCount++] = ((next & mask) << 4) | (prev >>> 22) } prev = next } prev >>>= 22 - if (currentInputNewWordCount < tempInputNewWords.length) { // Boundary check + if (currentInputNewWordCount < tempInputNewWords.length) { + // Boundary check tempInputNewWords[currentInputNewWordCount++] = prev - } else if (prev !== 0 && tempInputNewWords.length > 0) { // If prev is non-zero but no space, this is an issue. + } else if (prev !== 0 && tempInputNewWords.length > 0) { + // If prev is non-zero but no space, this is an issue. // This case implies original logic might have relied on array auto-expansion or specific length handling // For safety, if there's still a carry and no space, the array should have been bigger. // However, the original logic `input.length -= 9` suggests truncation. } - const finalInputNewWords = new Array(currentInputNewWordCount) + const finalInputNewWords = Array.from({ length: currentInputNewWordCount }, () => 0) for (let i = 0; i < currentInputNewWordCount; ++i) finalInputNewWords[i] = tempInputNewWords[i] input.words = finalInputNewWords // Use setter, which will strip and set magnitude } @@ -109,12 +112,12 @@ export default class K256 extends Mersenne { * const number = new BigNumber(12345); * const result = k256.imulK(number); */ - imulK (num: BigNumber): BigNumber { + imulK(num: BigNumber): BigNumber { const currentWords = num.words // Get current words based on _magnitude and _nominalWordLength const originalNominalLength = num.length // Getter const newNominalLength = originalNominalLength + 2 - const tempWords = new Array(newNominalLength).fill(0) + const tempWords = Array.from({ length: newNominalLength }, () => 0) for (let i = 0; i < originalNominalLength; i++) { tempWords[i] = currentWords[i] @@ -122,7 +125,8 @@ export default class K256 extends Mersenne { // tempWords is now effectively num.words expanded with zeroes let lo = 0 - for (let i = 0; i < newNominalLength; i++) { // Iterate up to new expanded length + for (let i = 0; i < newNominalLength; i++) { + // Iterate up to new expanded length const w = Math.trunc(tempWords[i]) lo += w * 0x3d1 // 0x3d1 = 977 tempWords[i] = lo & 0x3ffffff // 26-bit mask diff --git a/packages/sdk/src/primitives/Point.ts b/packages/sdk/src/primitives/Point.ts index 951deac83..1a093dc85 100644 --- a/packages/sdk/src/primitives/Point.ts +++ b/packages/sdk/src/primitives/Point.ts @@ -3,19 +3,18 @@ import JPoint from './JacobianPoint.js' import BigNumber from './BigNumber.js' import { toArray, toHex } from './utils.js' -function ctSwap ( - swap: bigint, - a: JacobianPointBI, - b: JacobianPointBI -): void { +function ctSwap(swap: bigint, a: JacobianPointBI, b: JacobianPointBI): void { const mask = -swap const swapX = (a.X ^ b.X) & mask const swapY = (a.Y ^ b.Y) & mask const swapZ = (a.Z ^ b.Z) & mask - a.X ^= swapX; b.X ^= swapX - a.Y ^= swapY; b.Y ^= swapY - a.Z ^= swapZ; b.Z ^= swapZ + a.X ^= swapX + b.X ^= swapX + a.Y ^= swapY + b.Y ^= swapY + a.Z ^= swapZ + b.Z ^= swapZ } // ----------------------------------------------------------------------------- @@ -29,11 +28,11 @@ export const BI_THREE = 3n export const BI_FOUR = 4n export const BI_EIGHT = 8n -export const P_BIGINT = 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEFFFFFC2Fn -export const N_BIGINT = 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141n +export const P_BIGINT = 0xfffffffffffffffffffffffffffffffffffffffffffffffffffffffefffffc2fn +export const N_BIGINT = 0xfffffffffffffffffffffffffffffffebaaedce6af48a03bbfd25e8cd0364141n export const MASK_256 = (1n << 256n) - 1n // 0xffff…ffff (256 sones) -export function red (x: bigint): bigint { +export function red(x: bigint): bigint { // first fold let hi = x >> 256n x = (x & MASK_256) + (hi << 32n) + hi * 977n @@ -47,13 +46,21 @@ export function red (x: bigint): bigint { return x } -export const biMod = (a: bigint): bigint => red((a % P_BIGINT + P_BIGINT) % P_BIGINT) +export const biMod = (a: bigint): bigint => red(((a % P_BIGINT) + P_BIGINT) % P_BIGINT) export const biModSub = (a: bigint, b: bigint): bigint => (a >= b ? a - b : P_BIGINT - (b - a)) export const biModMul = (a: bigint, b: bigint): bigint => red(a * b) export const biModAdd = (a: bigint, b: bigint): bigint => red(a + b) -export const biModInv = (a: bigint): bigint => { // binary‑ext GCD - let lm = BI_ONE; let hm = BI_ZERO; let low = biMod(a); let high = P_BIGINT - while (low > BI_ONE) { const r = high / low; [lm, hm] = [hm - lm * r, lm]; [low, high] = [high - low * r, low] } +export const biModInv = (a: bigint): bigint => { + // binary‑ext GCD + let lm = BI_ONE + let hm = BI_ZERO + let low = biMod(a) + let high = P_BIGINT + while (low > BI_ONE) { + const r = high / low + ;[lm, hm] = [hm - lm * r, lm] + ;[low, high] = [high - low * r, low] + } return biMod(lm) } export const biModSqr = (a: bigint): bigint => biModMul(a, a) @@ -93,13 +100,21 @@ const toBigInt = (x: BigNumber | number | number[] | string): bigint => { } // Generator point coordinates as bigint constants -export const GX_BIGINT = BigInt('0x79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798') -export const GY_BIGINT = BigInt('0x483ada7726a3c4655da4fbfc0e1108a8fd17b448a68554199c47d08ffb10d4b8') +export const GX_BIGINT = BigInt( + '0x79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798' +) +export const GY_BIGINT = BigInt( + '0x483ada7726a3c4655da4fbfc0e1108a8fd17b448a68554199c47d08ffb10d4b8' +) // Cache for precomputed windowed tables keyed by 'window:x:y' const WNAF_TABLE_CACHE: Map = new Map() -export interface JacobianPointBI { X: bigint, Y: bigint, Z: bigint } +export interface JacobianPointBI { + X: bigint + Y: bigint + Z: bigint +} export const jpDouble = (P: JacobianPointBI): JacobianPointBI => { const { X: X1, Y: Y1, Z: Z1 } = P @@ -109,10 +124,7 @@ export const jpDouble = (P: JacobianPointBI): JacobianPointBI => { const S = biModMul(BI_FOUR, biModMul(X1, Y1sq)) const M = biModMul(BI_THREE, biModMul(X1, X1)) const X3 = biModSub(biModMul(M, M), biModMul(BI_TWO, S)) - const Y3 = biModSub( - biModMul(M, biModSub(S, X3)), - biModMul(BI_EIGHT, biModMul(Y1sq, Y1sq)) - ) + const Y3 = biModSub(biModMul(M, biModSub(S, X3)), biModMul(BI_EIGHT, biModMul(Y1sq, Y1sq))) const Z3 = biModMul(BI_TWO, biModMul(Y1, Z1)) return { X: X3, Y: Y3, Z: Z3 } } @@ -158,7 +170,7 @@ export const jpNeg = (P: JacobianPointBI): JacobianPointBI => { // coordinates. Returns Q = k * P0 as a JacobianPoint. export const scalarMultiplyWNAF = ( k: bigint, - P0: { x: bigint, y: bigint }, + P0: { x: bigint; y: bigint }, window: number = 5 ): JacobianPointBI => { const key = `${window}:${P0.x.toString(16)}:${P0.y.toString(16)}` @@ -166,7 +178,7 @@ export const scalarMultiplyWNAF = ( if (tbl === undefined) { // Convert affine to Jacobian and pre-compute odd multiples const tblSize = 1 << (window - 1) // e.g. w=5 → 16 entries - tbl = new Array(tblSize) + tbl = Array.from({ length: tblSize }) const P: JacobianPointBI = { X: P0.x, Y: P0.y, Z: BI_ONE } tbl[0] = P const twoP = jpDouble(P) @@ -217,8 +229,10 @@ export const modMulN = (a: bigint, b: bigint): bigint => modN(a * b) /** modular inverse modulo n with plain extended‑gcd (not constant‑time) */ export const modInvN = (a: bigint): bigint => { - let lm = 1n; let hm = 0n - let low = modN(a); let high = N_BIGINT + let lm = 1n + let hm = 0n + let low = modN(a) + let high = N_BIGINT while (low > 1n) { const q = high / low ;[lm, hm] = [hm - lm * q, lm] @@ -244,7 +258,7 @@ export default class Point extends BasePoint { y: BigNumber | null inf: boolean - static _assertOnCurve (p: Point): Point { + static _assertOnCurve(p: Point): Point { if (!p.validate()) { throw new Error('Invalid point') } @@ -267,7 +281,7 @@ export default class Point extends BasePoint { * const derPoint = [ 2, 18, 123, 108, 125, 83, 1, 251, 164, 214, 16, 119, 200, 216, 210, 193, 251, 193, 129, 67, 97, 146, 210, 216, 77, 254, 18, 6, 150, 190, 99, 198, 128 ]; * const point = Point.fromDER(derPoint); */ - static fromDER (bytes: number[]): Point { + static fromDER(bytes: number[]): Point { const len = 32 if ( (bytes[0] === 0x04 || bytes[0] === 0x06 || bytes[0] === 0x07) && @@ -283,19 +297,11 @@ export default class Point extends BasePoint { } } - const res = new Point( - bytes.slice(1, 1 + len), - bytes.slice(1 + len, 1 + 2 * len) - ) + const res = new Point(bytes.slice(1, 1 + len), bytes.slice(1 + len, 1 + 2 * len)) return Point._assertOnCurve(res) - } else if ( - (bytes[0] === 0x02 || bytes[0] === 0x03) && - bytes.length - 1 === len - ) { - return Point._assertOnCurve( - Point.fromX(bytes.slice(1, 1 + len), bytes[0] === 0x03) - ) + } else if ((bytes[0] === 0x02 || bytes[0] === 0x03) && bytes.length - 1 === len) { + return Point._assertOnCurve(Point.fromX(bytes.slice(1, 1 + len), bytes[0] === 0x03)) } throw new Error('Unknown point format') } @@ -317,7 +323,7 @@ export default class Point extends BasePoint { * const pointStr = 'abcdef'; * const point = Point.fromString(pointStr); */ - static fromString (str: string): Point { + static fromString(str: string): Point { const bytes = toArray(str, 'hex') return Point._assertOnCurve(Point.fromDER(bytes)) } @@ -337,7 +343,7 @@ export default class Point extends BasePoint { * const xCoordinate = new BigNumber('10'); * const point = Point.fromX(xCoordinate, true); */ - static fromX (x: BigNumber | number | number[] | string, odd: boolean): Point { + static fromX(x: BigNumber | number | number[] | string, odd: boolean): Point { let xBigInt = toBigInt(x) xBigInt = biMod(xBigInt) @@ -373,7 +379,7 @@ export default class Point extends BasePoint { * const serializedPoint = '{"x":52,"y":15}'; * const point = Point.fromJSON(serializedPoint, true); */ - static fromJSON (obj: string | any[], isRed: boolean): Point { + static fromJSON(obj: string | any[], isRed: boolean): Point { if (typeof obj === 'string') { obj = JSON.parse(obj) } @@ -399,9 +405,7 @@ export default class Point extends BasePoint { typeof pre.doubles === 'object' && pre.doubles !== null ? { step: pre.doubles.step, - points: [res].concat( - pre.doubles.points.map(obj2point) - ) + points: [res].concat(pre.doubles.points.map(obj2point)) } : undefined, @@ -409,9 +413,7 @@ export default class Point extends BasePoint { typeof pre.naf === 'object' && pre.naf !== null ? { wnd: pre.naf.wnd, - points: [res].concat( - pre.naf.points.map(obj2point) - ) + points: [res].concat(pre.naf.points.map(obj2point)) } : undefined } @@ -429,7 +431,7 @@ export default class Point extends BasePoint { * new Point('abc123', 'def456'); * new Point(null, null); // Generates Infinity point. */ - constructor ( + constructor( x: BigNumber | number | number[] | string | null, y: BigNumber | number | number[] | string | null, isRed: boolean = true @@ -475,7 +477,7 @@ export default class Point extends BasePoint { * const aPoint = new Point(x, y); * const isValid = aPoint.validate(); */ - validate (): boolean { + validate(): boolean { if (this.inf || this.x == null || this.y == null) return false try { @@ -507,7 +509,7 @@ export default class Point extends BasePoint { * const encodedPointArray = aPoint.encode(); * const encodedPointHex = aPoint.encode(true, 'hex'); */ - encode (compact: boolean = true, enc?: 'hex'): number[] | string { + encode(compact: boolean = true, enc?: 'hex'): number[] | string { if (this.inf) { if (enc === 'hex') return '00' return [0x00] @@ -538,7 +540,7 @@ export default class Point extends BasePoint { * const aPoint = new Point(x, y); * const stringPoint = aPoint.toString(); */ - toString (): string { + toString(): string { return this.encode(true, 'hex') as string } @@ -552,13 +554,13 @@ export default class Point extends BasePoint { * const aPoint = new Point(x, y); * const jsonPoint = aPoint.toJSON(); */ - toJSON (): [ + toJSON(): [ BigNumber | null, BigNumber | null, { - doubles: { step: any, points: any[] } | undefined - naf: { wnd: any, points: any[] } | undefined - }?, + doubles: { step: any; points: any[] } | undefined + naf: { wnd: any; points: any[] } | undefined + }? ] { if (this.precomputed == null) { return [this.x, this.y] @@ -570,19 +572,19 @@ export default class Point extends BasePoint { typeof this.precomputed === 'object' && this.precomputed !== null ? { doubles: - this.precomputed.doubles == null - ? undefined - : { - step: this.precomputed.doubles.step, - points: this.precomputed.doubles.points.slice(1) - }, + this.precomputed.doubles == null + ? undefined + : { + step: this.precomputed.doubles.step, + points: this.precomputed.doubles.points.slice(1) + }, naf: - this.precomputed.naf == null - ? undefined - : { - wnd: this.precomputed.naf.wnd, - points: this.precomputed.naf.points.slice(1) - } + this.precomputed.naf == null + ? undefined + : { + wnd: this.precomputed.naf.wnd, + points: this.precomputed.naf.points.slice(1) + } } : undefined ] @@ -598,7 +600,7 @@ export default class Point extends BasePoint { * const aPoint = new Point(x, y); * console.log(aPoint.inspect()); */ - inspect (): string { + inspect(): string { if (this.isInfinity()) { return '' } @@ -620,7 +622,7 @@ export default class Point extends BasePoint { * const p = new Point(null, null); * console.log(p.isInfinity()); // outputs: true */ - isInfinity (): boolean { + isInfinity(): boolean { return this.inf } @@ -636,7 +638,7 @@ export default class Point extends BasePoint { * const p2 = new Point(2, 3); * const result = p1.add(p2); */ - add (p: Point): Point { + add(p: Point): Point { // O + P = P if (this.inf) { return p @@ -690,7 +692,7 @@ export default class Point extends BasePoint { * const P = new Point('123', '456'); * const result = P.dbl(); * */ - dbl (): Point { + dbl(): Point { if (this.inf) return this if (this.x === null || this.y === null) { throw new Error('Point coordinates cannot be null') @@ -715,7 +717,7 @@ export default class Point extends BasePoint { * const P = new Point('123', '456'); * const x = P.getX(); */ - getX (): BigNumber { + getX(): BigNumber { return (this.x ?? new BigNumber(0)).fromRed() } @@ -726,7 +728,7 @@ export default class Point extends BasePoint { * const P = new Point('123', '456'); * const x = P.getX(); */ - getY (): BigNumber { + getY(): BigNumber { return (this.y ?? new BigNumber(0)).fromRed() } @@ -741,7 +743,7 @@ export default class Point extends BasePoint { * const p = new Point(1, 2); * const result = p.mul(2); // this doubles the Point */ - mul (k: BigNumber | number | number[] | string): Point { + mul(k: BigNumber | number | number[] | string): Point { if (!BigNumber.isBN(k)) { k = new BigNumber(k as number, 16) } @@ -794,7 +796,7 @@ export default class Point extends BasePoint { return result } - mulCT (k: BigNumber | number | number[] | string): Point { + mulCT(k: BigNumber | number | number[] | string): Point { if (!BigNumber.isBN(k)) { k = new BigNumber(k as any, 16) } @@ -810,15 +812,9 @@ export default class Point extends BasePoint { kBig = biMod(kBig) if (kBig === 0n) return new Point(null, null) - const Px = - this === this.curve.g - ? GX_BIGINT - : BigInt('0x' + this.getX().toString(16)) + const Px = this === this.curve.g ? GX_BIGINT : BigInt('0x' + this.getX().toString(16)) - const Py = - this === this.curve.g - ? GY_BIGINT - : BigInt('0x' + this.getY().toString(16)) + const Py = this === this.curve.g ? GY_BIGINT : BigInt('0x' + this.getY().toString(16)) let R0: JacobianPointBI = { X: 0n, Y: 1n, Z: 0n } let R1: JacobianPointBI = { X: Px, Y: Py, Z: 1n } @@ -858,7 +854,7 @@ export default class Point extends BasePoint { * const p2 = new Point(2, 3); * const result = p1.mulAdd(2, p2, 3); */ - mulAdd (k1: BigNumber, p2: Point, k2: BigNumber): Point { + mulAdd(k1: BigNumber, p2: Point, k2: BigNumber): Point { const points = [this, p2] const coeffs = [k1, k2] return this._endoWnafMulAdd(points, coeffs) as Point @@ -879,7 +875,7 @@ export default class Point extends BasePoint { * const p2 = new Point(2, 3); * const result = p1.jmulAdd(2, p2, 3); */ - jmulAdd (k1: BigNumber, p2: Point, k2: BigNumber): JPoint { + jmulAdd(k1: BigNumber, p2: Point, k2: BigNumber): JPoint { const points = [this, p2] const coeffs = [k1, k2] return this._endoWnafMulAdd(points, coeffs, true) as JPoint @@ -898,11 +894,13 @@ export default class Point extends BasePoint { * const p2 = new Point(5, 20); * const areEqual = p1.eq(p2); // returns true */ - eq (p: Point): boolean { + eq(p: Point): boolean { return ( this === p || (this.inf === p.inf && - (this.inf || ((this.x ?? new BigNumber(0)).cmp(p.x ?? new BigNumber(0)) === 0 && (this.y ?? new BigNumber(0)).cmp(p.y ?? new BigNumber(0)) === 0))) + (this.inf || + ((this.x ?? new BigNumber(0)).cmp(p.x ?? new BigNumber(0)) === 0 && + (this.y ?? new BigNumber(0)).cmp(p.y ?? new BigNumber(0)) === 0))) ) } @@ -915,7 +913,7 @@ export default class Point extends BasePoint { * const P = new Point('123', '456'); * const result = P.neg(); */ - neg (_precompute?: boolean): Point { + neg(_precompute?: boolean): Point { if (this.inf) { return this } @@ -924,18 +922,20 @@ export default class Point extends BasePoint { const pre = this.precomputed const negate = (p: Point): Point => p.neg() res.precomputed = { - naf: pre.naf == null - ? undefined - : { - wnd: pre.naf.wnd, - points: pre.naf.points.map(negate) as BasePoint[] - }, - doubles: pre.doubles == null - ? undefined - : { - step: pre.doubles.step, - points: pre.doubles.points.map((p) => (p as Point).neg()) - }, + naf: + pre.naf == null + ? undefined + : { + wnd: pre.naf.wnd, + points: pre.naf.points.map(negate) as BasePoint[] + }, + doubles: + pre.doubles == null + ? undefined + : { + step: pre.doubles.step, + points: pre.doubles.points.map(p => (p as Point).neg()) + }, beta: undefined } } @@ -957,7 +957,7 @@ export default class Point extends BasePoint { * const p = new Point(5, 20); * const doubledPoint = p.dblp(10); // returns the point after "doubled" 10 times */ - dblp (k: number): Point { + dblp(k: number): Point { /* eslint-disable @typescript-eslint/no-this-alias */ let r: Point = this for (let i = 0; i < k; i++) { @@ -977,7 +977,7 @@ export default class Point extends BasePoint { * const point = new Point(xCoordinate, yCoordinate); * const jacobianPoint = point.toJ(); */ - toJ (): JPoint { + toJ(): JPoint { if (this.inf) { return new JPoint(null, null, null) } @@ -985,7 +985,7 @@ export default class Point extends BasePoint { return res } - private _getBeta (): undefined | Point { + private _getBeta(): undefined | Point { if (typeof this.curve.endo !== 'object') { return } @@ -1034,7 +1034,7 @@ export default class Point extends BasePoint { return beta } - private _fixedNafMul (k: BigNumber): Point { + private _fixedNafMul(k: BigNumber): Point { if (typeof this.precomputed !== 'object' || this.precomputed === null) { throw new Error('_fixedNafMul requires precomputed values for the point') } @@ -1070,16 +1070,17 @@ export default class Point extends BasePoint { return a.toP() } - private _wnafMulAdd ( + private _wnafMulAdd( defW: number, points: Point[], coeffs: BigNumber[], len: number, jacobianResult?: boolean ): BasePoint { - const wndWidth: number[] = this.curve._wnafT1.map(num => num.toNumber()) // Convert BigNumber to number - const wnd: Point[][] = this.curve._wnafT2.map(() => []) // Initialize as empty Point[][] array - const naf: number[][] = this.curve._wnafT3.map(() => []) // Initialize as empty number[][] array + const scratchLength = this.curve._wnafT1.length + const wndWidth: number[] = Array.from({ length: scratchLength }) + const wnd: Point[][] = Array.from({ length: scratchLength }, () => []) + const naf: number[][] = Array.from({ length: scratchLength }, () => []) // Fill all arrays let max = 0 @@ -1095,33 +1096,22 @@ export default class Point extends BasePoint { const a = i - 1 const b = i if (wndWidth[a] !== 1 || wndWidth[b] !== 1) { - naf[a] = this.curve.getNAF( - coeffs[a], - wndWidth[a], - this.curve._bitLength - ) - naf[b] = this.curve.getNAF( - coeffs[b], - wndWidth[b], - this.curve._bitLength - ) + naf[a] = this.curve.getNAF(coeffs[a], wndWidth[a], this.curve._bitLength) + naf[b] = this.curve.getNAF(coeffs[b], wndWidth[b], this.curve._bitLength) max = Math.max(naf[a].length, max) max = Math.max(naf[b].length, max) continue } - const comb: any[] = [ - points[a] /* 1 */, - null /* 3 */, - null /* 5 */, - points[b] /* 7 */ - ] + const comb: any[] = [points[a] /* 1 */, null /* 3 */, null /* 5 */, points[b] /* 7 */] // Try to avoid Projective points, if possible if ((points[a].y ?? new BigNumber(0)).cmp(points[b].y ?? new BigNumber(0)) === 0) { comb[1] = points[a].add(points[b]) comb[2] = points[a].toJ().mixedAdd(points[b].neg()) - } else if ((points[a].y ?? new BigNumber(0)).cmp((points[b].y ?? new BigNumber(0)).redNeg()) === 0) { + } else if ( + (points[a].y ?? new BigNumber(0)).cmp((points[b].y ?? new BigNumber(0)).redNeg()) === 0 + ) { comb[1] = points[a].toJ().mixedAdd(points[b]) comb[2] = points[a].add(points[b].neg()) } else { @@ -1130,14 +1120,14 @@ export default class Point extends BasePoint { } const index = [ - -3 /* -1 -1 */, -1 /* -1 0 */, -5 /* -1 1 */, -7 /* 0 -1 */, - 0 /* 0 0 */, 7 /* 0 1 */, 5 /* 1 -1 */, 1 /* 1 0 */, 3 /* 1 1 */ + -3 /* -1 -1 */, -1 /* -1 0 */, -5 /* -1 1 */, -7 /* 0 -1 */, 0 /* 0 0 */, 7 /* 0 1 */, + 5 /* 1 -1 */, 1 /* 1 0 */, 3 /* 1 1 */ ] const jsf = this.curve.getJSF(coeffs[a], coeffs[b]) max = Math.max(jsf[0].length, max) - naf[a] = new Array(max) - naf[b] = new Array(max) + naf[a] = Array.from({ length: max }) + naf[b] = Array.from({ length: max }) for (let j = 0; j < max; j++) { const ja = Math.trunc(jsf[0][j]) const jb = Math.trunc(jsf[1][j]) @@ -1157,7 +1147,8 @@ export default class Point extends BasePoint { let zero = true for (let j = 0; j < len; j++) { tmp[j] = new BigNumber(typeof naf[j][i] === 'number' ? naf[j][i] : 0) // Ensure type consistency - if (!tmp[j].isZero()) { // Use BigNumber's built-in comparison + if (!tmp[j].isZero()) { + // Use BigNumber's built-in comparison zero = false } } @@ -1182,11 +1173,14 @@ export default class Point extends BasePoint { const z = tmp[j] let p - if (z.cmpn(0) === 0) { // Check if z is 0 + if (z.cmpn(0) === 0) { + // Check if z is 0 continue - } else if (z.isNeg()) { // If z is negative + } else if (z.isNeg()) { + // If z is negative p = wnd[j][z.neg().sub(one).div(two).toNumber()].neg() - } else { // If z is positive + } else { + // If z is positive p = wnd[j][z.sub(one).div(two).toNumber()] } @@ -1209,13 +1203,13 @@ export default class Point extends BasePoint { } } - private _endoWnafMulAdd ( + private _endoWnafMulAdd( points: Point[], coeffs: BigNumber[], // Explicitly type coeffs jacobianResult?: boolean ): BasePoint { - const npoints: Point[] = new Array(points.length * 2) - const ncoeffs: BigNumber[] = new Array(points.length * 2) + const npoints: Point[] = Array.from({ length: points.length * 2 }) + const ncoeffs: BigNumber[] = Array.from({ length: points.length * 2 }) let i: number for (i = 0; i < points.length; i++) { const split = this.curve._endoSplit(coeffs[i]) @@ -1247,7 +1241,7 @@ export default class Point extends BasePoint { return res } - private _hasDoubles (k: BigNumber): boolean { + private _hasDoubles(k: BigNumber): boolean { if (this.precomputed == null) { return false } @@ -1257,15 +1251,10 @@ export default class Point extends BasePoint { return false } - return ( - doubles.points.length >= Math.ceil((k.bitLength() + 1) / doubles.step) - ) + return doubles.points.length >= Math.ceil((k.bitLength() + 1) / doubles.step) } - private _getDoubles ( - step?: number, - power?: number - ): { step: number, points: any[] } { + private _getDoubles(step?: number, power?: number): { step: number; points: any[] } { if ( typeof this.precomputed === 'object' && this.precomputed !== null && @@ -1278,7 +1267,7 @@ export default class Point extends BasePoint { const doubles = [this] /* eslint-disable @typescript-eslint/no-this-alias */ let acc: Point = this - for (let i = 0; i < (power ?? 0); i += (step ?? 1)) { + for (let i = 0; i < (power ?? 0); i += step ?? 1) { for (let j = 0; j < (step ?? 1); j++) { acc = acc.dbl() } @@ -1290,7 +1279,7 @@ export default class Point extends BasePoint { } } - private _getNAFPoints (wnd: number): { wnd: number, points: any[] } { + private _getNAFPoints(wnd: number): { wnd: number; points: any[] } { if ( typeof this.precomputed === 'object' && this.precomputed !== null && diff --git a/packages/sdk/src/primitives/Secp256r1.ts b/packages/sdk/src/primitives/Secp256r1.ts index bbede5a79..a1fa25e55 100644 --- a/packages/sdk/src/primitives/Secp256r1.ts +++ b/packages/sdk/src/primitives/Secp256r1.ts @@ -2,7 +2,7 @@ import Random from './Random.js' import { sha256, sha256hmac } from './Hash.js' import { toArray, toHex } from './utils.js' -export type P256Point = { x: bigint, y: bigint } | null +export type P256Point = { x: bigint; y: bigint } | null type ByteSource = string | Uint8Array | ArrayBufferView @@ -34,12 +34,12 @@ export default class Secp256r1 { readonly b = B readonly g = G - private mod (x: bigint, m: bigint = this.p): bigint { + private mod(x: bigint, m: bigint = this.p): bigint { const v = x % m return v >= 0n ? v : v + m } - private modInv (x: bigint, m: bigint): bigint { + private modInv(x: bigint, m: bigint): bigint { if (x === 0n || m <= 0n) throw new Error('Invalid mod inverse input') let [a, b] = [this.mod(x, m), m] let [u, v] = [1n, 0n] @@ -52,7 +52,7 @@ export default class Secp256r1 { return this.mod(u, m) } - private modPow (base: bigint, exponent: bigint, modulus: bigint): bigint { + private modPow(base: bigint, exponent: bigint, modulus: bigint): bigint { if (modulus === 1n) return 0n let result = 1n let b = this.mod(base, modulus) @@ -65,11 +65,11 @@ export default class Secp256r1 { return result } - private isInfinity (p: P256Point): p is null { + private isInfinity(p: P256Point): p is null { return p === null } - private assertOnCurve (p: P256Point): void { + private assertOnCurve(p: P256Point): void { if (this.isInfinity(p)) return const { x, y } = p const left = this.mod(y * y) @@ -79,7 +79,7 @@ export default class Secp256r1 { } } - pointFromAffine (x: bigint, y: bigint): P256Point { + pointFromAffine(x: bigint, y: bigint): P256Point { const point: P256Point = { x: this.mod(x), y: this.mod(y) } this.assertOnCurve(point) return point @@ -88,7 +88,7 @@ export default class Secp256r1 { /** * Decode a point from compressed or uncompressed hex. */ - pointFromHex (hex: string): P256Point { + pointFromHex(hex: string): P256Point { if (hex.startsWith(UNCOMPRESSED)) { const x = BigInt('0x' + hex.slice(2, 66)) const y = BigInt('0x' + hex.slice(66)) @@ -100,7 +100,7 @@ export default class Secp256r1 { const y = this.modPow(ySq, (this.p + 1n) >> 2n, this.p) const isOdd = (y & 1n) === 1n const shouldBeOdd = hex.startsWith(COMPRESSED_ODD) - const yFinal = (isOdd === shouldBeOdd) ? y : this.p - y + const yFinal = isOdd === shouldBeOdd ? y : this.p - y return this.pointFromAffine(x, yFinal) } throw new Error('Invalid point encoding') @@ -109,7 +109,7 @@ export default class Secp256r1 { /** * Encode a point to compressed or uncompressed hex. Infinity is encoded as `00`. */ - pointToHex (p: P256Point, compressed = false): string { + pointToHex(p: P256Point, compressed = false): string { if (this.isInfinity(p)) return '00' const xHex = this.to32BytesHex(p.x) const yHex = this.to32BytesHex(p.y) @@ -121,7 +121,7 @@ export default class Secp256r1 { /** * Add two affine points (handles infinity). */ - private addPoints (p1: P256Point, p2: P256Point): P256Point { + private addPoints(p1: P256Point, p2: P256Point): P256Point { if (this.isInfinity(p1)) return p2 if (this.isInfinity(p2)) return p1 @@ -141,7 +141,7 @@ export default class Secp256r1 { return { x: x3, y: y3 } } - private doublePoint (p: P256Point): P256Point { + private doublePoint(p: P256Point): P256Point { if (this.isInfinity(p)) return p if (p.y === 0n) return null const m = this.mod((3n * p.x * p.x + this.a) * this.modInv(2n * p.y, this.p)) @@ -153,14 +153,14 @@ export default class Secp256r1 { /** * Add two points (handles infinity). */ - add (p1: P256Point, p2: P256Point): P256Point { + add(p1: P256Point, p2: P256Point): P256Point { return this.addPoints(p1, p2) } /** * Scalar multiply an arbitrary point using double-and-add. */ - multiply (point: P256Point, scalar: bigint): P256Point { + multiply(point: P256Point, scalar: bigint): P256Point { if (scalar === 0n || this.isInfinity(point)) return null let k = this.mod(scalar, this.n) let result: P256Point = null @@ -178,18 +178,18 @@ export default class Secp256r1 { /** * Scalar multiply the base point. */ - multiplyBase (scalar: bigint): P256Point { + multiplyBase(scalar: bigint): P256Point { return this.multiply(this.g, scalar) } /** * Check if a point lies on the curve (including infinity). */ - isOnCurve (p: P256Point): boolean { + isOnCurve(p: P256Point): boolean { try { this.assertOnCurve(p) return true - } catch (_notOnCurve) { + } catch { // assertOnCurve throws when the point is not on the curve; return false return false } @@ -198,11 +198,11 @@ export default class Secp256r1 { /** * Generate a new random private key as 32-byte hex. */ - generatePrivateKeyHex (): string { + generatePrivateKeyHex(): string { return this.to32BytesHex(this.randomScalar()) } - private randomScalar (): bigint { + private randomScalar(): bigint { while (true) { const bytes = Random(32) const k = BigInt('0x' + toHex(bytes)) @@ -210,13 +210,13 @@ export default class Secp256r1 { } } - private normalizePrivateKey (d: bigint): bigint { + private normalizePrivateKey(d: bigint): bigint { const key = this.mod(d, this.n) if (key === 0n) throw new Error('Invalid private key') return key } - private toScalar (input: string | bigint): bigint { + private toScalar(input: string | bigint): bigint { if (typeof input === 'bigint') return this.normalizePrivateKey(input) const hex = input.startsWith('0x') ? input.slice(2) : input if (!HEX_REGEX.test(hex) || hex.length === 0 || hex.length > 64) { @@ -226,7 +226,7 @@ export default class Secp256r1 { return this.normalizePrivateKey(value) } - publicKeyFromPrivate (privateKey: string | bigint): P256Point { + publicKeyFromPrivate(privateKey: string | bigint): P256Point { const d = this.toScalar(privateKey) return this.multiplyBase(d) } @@ -235,7 +235,11 @@ export default class Secp256r1 { * Create an ECDSA signature over a message. Uses SHA-256 unless `prehashed` is true. * Returns low-s normalized signature hex parts. */ - sign (message: ByteSource, privateKey: string | bigint, opts: { prehashed?: boolean, nonce?: bigint } = {}): { r: string, s: string } { + sign( + message: ByteSource, + privateKey: string | bigint, + opts: { prehashed?: boolean; nonce?: bigint } = {} + ): { r: string; s: string } { const { prehashed = false, nonce } = opts const d = this.toScalar(privateKey) const digest = this.normalizeMessage(message, prehashed) @@ -267,7 +271,12 @@ export default class Secp256r1 { /** * Verify an ECDSA signature against a message and public key. */ - verify (message: ByteSource, signature: { r: string | bigint, s: string | bigint }, publicKey: P256Point | string, opts: { prehashed?: boolean } = {}): boolean { + verify( + message: ByteSource, + signature: { r: string | bigint; s: string | bigint }, + publicKey: P256Point | string, + opts: { prehashed?: boolean } = {} + ): boolean { const { prehashed = false } = opts let q: P256Point try { @@ -275,7 +284,7 @@ export default class Secp256r1 { } catch { return false } - if ((q == null) || !this.isOnCurve(q)) return false + if (q == null || !this.isOnCurve(q)) return false const r = typeof signature.r === 'bigint' ? signature.r : BigInt('0x' + signature.r) const s = typeof signature.s === 'bigint' ? signature.s : BigInt('0x' + signature.s) @@ -291,24 +300,24 @@ export default class Secp256r1 { return v === r } - private normalizeMessage (message: ByteSource, prehashed: boolean): Uint8Array { + private normalizeMessage(message: ByteSource, prehashed: boolean): Uint8Array { const bytes = this.toBytes(message) if (prehashed) return bytes return new Uint8Array(sha256(bytes)) } - private bytesToScalar (bytes: Uint8Array): bigint { + private bytesToScalar(bytes: Uint8Array): bigint { const hex = toHex(Array.from(bytes)) return BigInt('0x' + hex) % this.n } - private deterministicNonce (priv: bigint, msgDigest: Uint8Array): bigint { + private deterministicNonce(priv: bigint, msgDigest: Uint8Array): bigint { const keyBytes = toArray(this.to32BytesHex(priv), 'hex') let counter = 0 - while (counter < 1024) { // safety bound - const data = counter === 0 - ? Array.from(msgDigest) - : Array.from(msgDigest).concat([counter & 0xff]) + while (counter < 1024) { + // safety bound + const data = + counter === 0 ? Array.from(msgDigest) : Array.from(msgDigest).concat([counter & 0xff]) const hmac = sha256hmac(keyBytes, data) const k = BigInt('0x' + toHex(hmac)) % this.n if (k > 0n) return k @@ -317,7 +326,7 @@ export default class Secp256r1 { throw new Error('Failed to derive deterministic nonce') } - private toBytes (data: ByteSource): Uint8Array { + private toBytes(data: ByteSource): Uint8Array { if (typeof data === 'string') { const isHex = HEX_REGEX.test(data) && data.length % 2 === 0 return Uint8Array.from(toArray(data, isHex ? 'hex' : 'utf8')) @@ -329,7 +338,7 @@ export default class Secp256r1 { throw new Error('Unsupported message format') } - private to32BytesHex (num: bigint): string { + private to32BytesHex(num: bigint): string { return num.toString(16).padStart(64, '0') } } diff --git a/packages/sdk/src/primitives/TransactionSignature.ts b/packages/sdk/src/primitives/TransactionSignature.ts index a7a0a2237..7bd02a244 100644 --- a/packages/sdk/src/primitives/TransactionSignature.ts +++ b/packages/sdk/src/primitives/TransactionSignature.ts @@ -62,8 +62,10 @@ export default class TransactionSignature extends Signature { * @param params * @returns preimage as a byte array */ - static formatOTDA (params: TransactionSignatureFormatParams): Uint8Array { - const isAnyoneCanPay = (params.scope & TransactionSignature.SIGHASH_ANYONECANPAY) === TransactionSignature.SIGHASH_ANYONECANPAY + static formatOTDA(params: TransactionSignatureFormatParams): Uint8Array { + const isAnyoneCanPay = + (params.scope & TransactionSignature.SIGHASH_ANYONECANPAY) === + TransactionSignature.SIGHASH_ANYONECANPAY const isSingle = (params.scope & 31) === TransactionSignature.SIGHASH_SINGLE const isNone = (params.scope & 31) === TransactionSignature.SIGHASH_NONE const isAll = (params.scope & 31) === TransactionSignature.SIGHASH_ALL || (!isSingle && !isNone) @@ -80,7 +82,14 @@ export default class TransactionSignature extends Signature { const writer = new Writer() - function writeInputs (inputs: Array<{ sourceTXID: string, sourceOutputIndex: number, sequence: number, script: number[] }>): void { + function writeInputs( + inputs: Array<{ + sourceTXID: string + sourceOutputIndex: number + sequence: number + script: number[] + }> + ): void { writer.writeVarIntNum(inputs.length) for (const input of inputs) { writer.writeReverse(toArray(input.sourceTXID, 'hex')) @@ -91,7 +100,7 @@ export default class TransactionSignature extends Signature { } } - function writeOutputs (outputs: Array<{ satoshis: number, script: number[] }>): void { + function writeOutputs(outputs: Array<{ satoshis: number; script: number[] }>): void { writer.writeVarIntNum(outputs.length) for (const output of outputs) { writer.writeUInt64LE(output.satoshis) @@ -106,21 +115,24 @@ export default class TransactionSignature extends Signature { const emptyScript = new Script().toBinary() if (!isAnyoneCanPay) { - const inputs = params.allInputs == null - ? params.otherInputs.map(input => ({ - sourceTXID: input.sourceTXID ?? input.sourceTransaction?.id('hex') ?? '', - sourceOutputIndex: input.sourceOutputIndex, - sequence: (isSingle || isNone) ? 0 : (input.sequence ?? 0xffffffff), - script: emptyScript - })) - : params.allInputs.map((input, index) => index === params.inputIndex - ? currentInput - : { + const inputs = + params.allInputs == null + ? params.otherInputs.map(input => ({ sourceTXID: input.sourceTXID ?? input.sourceTransaction?.id('hex') ?? '', sourceOutputIndex: input.sourceOutputIndex, - sequence: (isSingle || isNone) ? 0 : (input.sequence ?? 0xffffffff), + sequence: isSingle || isNone ? 0 : (input.sequence ?? 0xffffffff), script: emptyScript - }) + })) + : params.allInputs.map((input, index) => + index === params.inputIndex + ? currentInput + : { + sourceTXID: input.sourceTXID ?? input.sourceTransaction?.id('hex') ?? '', + sourceOutputIndex: input.sourceOutputIndex, + sequence: isSingle || isNone ? 0 : (input.sequence ?? 0xffffffff), + script: emptyScript + } + ) if (params.allInputs == null) inputs.splice(params.inputIndex, 0, currentInput) writeInputs(inputs) } else if (isAnyoneCanPay) { @@ -134,10 +146,13 @@ export default class TransactionSignature extends Signature { })) writeOutputs(outputs) } else if (isSingle) { - const outputs: Array<{ satoshis: number, script: number[] }> = [] - for (let i = 0; i < params.inputIndex; i++) outputs.push({ satoshis: -1, script: emptyScript }) + const outputs: Array<{ satoshis: number; script: number[] }> = [] + for (let i = 0; i < params.inputIndex; i++) + outputs.push({ satoshis: -1, script: emptyScript }) const o = params.outputs[params.inputIndex] - if (o !== undefined) { outputs.push({ satoshis: o.satoshis ?? 0, script: o.lockingScript.toBinary() }) } + if (o !== undefined) { + outputs.push({ satoshis: o.satoshis ?? 0, script: o.lockingScript.toBinary() }) + } writeOutputs(outputs) } else if (isNone) { writeOutputs([]) @@ -161,18 +176,20 @@ export default class TransactionSignature extends Signature { * @param params.cache - Optional `SignatureHashCache` that may already contain hashed prefixes and is populated during formatting. * @returns Bytes for signing. */ - static formatBip143 (params: TransactionSignatureFormatParams): Uint8Array { + static formatBip143(params: TransactionSignatureFormatParams): Uint8Array { const cache = params.cache const currentInput: TransactionInput = { sourceTXID: params.sourceTXID, sourceOutputIndex: params.sourceOutputIndex, sequence: params.inputSequence } - const inputs = params.allInputs ?? (() => { - const reconstructed = [...params.otherInputs] - reconstructed.splice(params.inputIndex, 0, currentInput) - return reconstructed - })() + const inputs = + params.allInputs ?? + (() => { + const reconstructed = [...params.otherInputs] + reconstructed.splice(params.inputIndex, 0, currentInput) + return reconstructed + })() const getPrevoutHash = (): number[] => { const writer = new Writer() @@ -209,7 +226,7 @@ export default class TransactionSignature extends Signature { return ret } - function getOutputsHash (outputIndex?: number): number[] { + function getOutputsHash(outputIndex?: number): number[] { const writer = new Writer() if (outputIndex === undefined) { @@ -224,7 +241,8 @@ export default class TransactionSignature extends Signature { } else { const output = params.outputs[outputIndex] - if (output === undefined) { // ✅ Explicitly check for undefined + if (output === undefined) { + // ✅ Explicitly check for undefined throw new Error(`Output at index ${outputIndex} does not exist`) } @@ -241,9 +259,9 @@ export default class TransactionSignature extends Signature { return ret } - let hashPrevouts = new Array(32).fill(0) - let hashSequence = new Array(32).fill(0) - let hashOutputs = new Array(32).fill(0) + let hashPrevouts = Array.from({ length: 32 }, () => 0) + let hashSequence = Array.from({ length: 32 }, () => 0) + let hashOutputs = Array.from({ length: 32 }, () => 0) if ((params.scope & TransactionSignature.SIGHASH_ANYONECANPAY) === 0) { if (cache?.hashPrevouts == null) { @@ -339,13 +357,15 @@ export default class TransactionSignature extends Signature { * @param params - Context for the signing input plus transaction metadata. * @param params.cache - Optional cache storing previously computed `hashPrevouts`, `hashSequence`, or `hashOutputs*` values; it will be populated if present. */ - static format (params: TransactionSignatureFormatParams): number[] { + static format(params: TransactionSignatureFormatParams): number[] { return Array.from(this.formatBytes(params)) } - static formatBytes (params: TransactionSignatureFormatParams): Uint8Array { + static formatBytes(params: TransactionSignatureFormatParams): Uint8Array { const hasForkId = (params.scope & TransactionSignature.SIGHASH_FORKID) !== 0 - const hasChronicle = params.ignoreChronicle !== true && (params.scope & TransactionSignature.SIGHASH_CHRONICLE) !== 0 + const hasChronicle = + params.ignoreChronicle !== true && + (params.scope & TransactionSignature.SIGHASH_CHRONICLE) !== 0 if (hasForkId && !hasChronicle) { return TransactionSignature.formatBip143(params) @@ -358,17 +378,21 @@ export default class TransactionSignature extends Signature { return new Uint8Array(0) } - static usesOtdaSingleBug (params: TransactionSignatureFormatParams): boolean { + static usesOtdaSingleBug(params: TransactionSignatureFormatParams): boolean { const hasForkId = (params.scope & TransactionSignature.SIGHASH_FORKID) !== 0 - const hasChronicle = params.ignoreChronicle !== true && (params.scope & TransactionSignature.SIGHASH_CHRONICLE) !== 0 + const hasChronicle = + params.ignoreChronicle !== true && + (params.scope & TransactionSignature.SIGHASH_CHRONICLE) !== 0 const usesOtda = !hasForkId || (hasForkId && hasChronicle) - return usesOtda && + return ( + usesOtda && (params.scope & 31) === TransactionSignature.SIGHASH_SINGLE && params.inputIndex >= params.outputs.length + ) } // The format used in a tx - static fromChecksigFormat (buf: number[]): TransactionSignature { + static fromChecksigFormat(buf: number[]): TransactionSignature { if (buf.length === 0) { // allow setting a "blank" signature const r = new BigNumber(1) @@ -382,7 +406,7 @@ export default class TransactionSignature extends Signature { return new TransactionSignature(tempSig.r, tempSig.s, scope) } - constructor (r: BigNumber, s: BigNumber, scope: number) { + constructor(r: BigNumber, s: BigNumber, scope: number) { super(r, s) this.scope = scope } @@ -392,14 +416,11 @@ export default class TransactionSignature extends Signature { * See also Ecdsa signature algorithm which enforces this. * See also Bip 62, "low S values in signatures" */ - public hasLowS (): boolean { + public hasLowS(): boolean { if ( this.s.ltn(1) || this.s.gt( - new BigNumber( - '7FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF5D576E7357A4501DDFE92F46681B20A0', - 'hex' - ) + new BigNumber('7FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF5D576E7357A4501DDFE92F46681B20A0', 'hex') ) ) { return false @@ -407,7 +428,7 @@ export default class TransactionSignature extends Signature { return true } - toChecksigFormat (): number[] { + toChecksigFormat(): number[] { const derbuf = this.toDER() as number[] return [...derbuf, this.scope] } diff --git a/packages/sdk/src/primitives/utils.ts b/packages/sdk/src/primitives/utils.ts index e3de659e0..7216b9da7 100644 --- a/packages/sdk/src/primitives/utils.ts +++ b/packages/sdk/src/primitives/utils.ts @@ -5,10 +5,8 @@ import { assertValidHex } from './hex.js' export { WriterUint8Array } from './WriterUint8Array.js' export { ReaderUint8Array } from './ReaderUint8Array.js' -const BufferCtor = - typeof globalThis === 'undefined' ? undefined : (globalThis as any).Buffer -const CAN_USE_BUFFER = - BufferCtor != null && typeof BufferCtor.from === 'function' +const BufferCtor = typeof globalThis === 'undefined' ? undefined : (globalThis as any).Buffer +const CAN_USE_BUFFER = BufferCtor != null && typeof BufferCtor.from === 'function' /** * Prepends a '0' to an odd character length word to ensure it has an even number of characters. @@ -29,10 +27,9 @@ export const zero2 = (word: string): string => { * @returns {string} - The hexadecimal string representation of the input array. */ const HEX_DIGITS = '0123456789abcdef' -const HEX_BYTE_STRINGS: string[] = new Array(256) +const HEX_BYTE_STRINGS: string[] = Array.from({ length: 256 }, () => '') for (let i = 0; i < 256; i++) { - HEX_BYTE_STRINGS[i] = - HEX_DIGITS[(i >> 4) & 0xf] + HEX_DIGITS[i & 0xf] + HEX_BYTE_STRINGS[i] = HEX_DIGITS[(i >> 4) & 0xf] + HEX_DIGITS[i & 0xf] } export const toHex = (msg: number[] | Uint8Array): string => { @@ -117,7 +114,7 @@ export const hexToUint8Array = (msg: string): Uint8Array => { return out } -export function base64ToArray (msg: string): number[] { +export function base64ToArray(msg: string): number[] { if (typeof msg !== 'string') { throw new TypeError('msg must be a string') } @@ -184,7 +181,7 @@ export function base64ToArray (msg: string): number[] { * @param str - The string to encode. * @returns An array of numbers, each representing a byte in the UTF-8 encoded string. */ -function utf8ToArray (str: string): number[] { +function utf8ToArray(str: string): number[] { return Array.from(new TextEncoder().encode(str)) } @@ -194,9 +191,7 @@ function utf8ToArray (str: string): number[] { * @returns {string} - The UTF-8 encoded string. */ export const toUTF8 = (arr: number[] | Uint8Array): string => { - return new TextDecoder().decode( - arr instanceof Uint8Array ? arr : new Uint8Array(arr) - ) + return new TextDecoder().decode(arr instanceof Uint8Array ? arr : new Uint8Array(arr)) } /** @@ -205,10 +200,7 @@ export const toUTF8 = (arr: number[] | Uint8Array): string => { * @param {('hex' | 'utf8')} enc - The desired encoding. * @returns {string | number[]} - The encoded message as a string (for 'hex' and 'utf8') or the original array. */ -export const encode = ( - arr: number[], - enc?: 'hex' | 'utf8' -): string | number[] => { +export const encode = (arr: number[], enc?: 'hex' | 'utf8'): string | number[] => { switch (enc) { case 'hex': return toHex(arr) @@ -230,9 +222,8 @@ export const encode = ( * const bytes = [72, 101, 108, 108, 111]; // Represents the string "Hello" * console.log(toBase64(bytes)); // Outputs: SGVsbG8= */ -export function toBase64 (byteArray: number[] | Uint8Array): string { - const base64Chars = - 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/' +export function toBase64(byteArray: number[] | Uint8Array): string { + const base64Chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/' let result = '' let i: number @@ -254,8 +245,7 @@ export function toBase64 (byteArray: number[] | Uint8Array): string { return result } -const base58chars = - '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz' +const base58chars = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz' /** * Converts a string from base58 to a binary array @@ -277,12 +267,10 @@ export const fromBase58 = (str: string): number[] => { const uint8 = new Uint8Array([ ...new Uint8Array(psz), - ...( - str.match(/./gmu) ?? [] // ✅ Safe Fix: If null, use [] - ) - .map((i) => base58chars.indexOf(i)) + ...(str.match(/./gmu) ?? []) // ✅ Safe Fix: If null, use [] + .map(i => base58chars.indexOf(i)) .reduce((acc, i) => { - acc = acc.map((j) => { + acc = acc.map(j => { const x = j * 58 + i i = x >> 8 return x @@ -292,7 +280,7 @@ export const fromBase58 = (str: string): number[] => { .reverse() .filter( ( - (lastValue) => (value) => + lastValue => value => // @ts-expect-error (lastValue = lastValue || value) )(false) @@ -307,7 +295,7 @@ export const fromBase58 = (str: string): number[] => { * @returns The base58 string representation */ export const toBase58 = (bin: number[]): string => { - const base58Map = new Array(256).fill(-1) + const base58Map = Array.from({ length: 256 }, () => -1) for (let i = 0; i < base58chars.length; ++i) { base58Map[base58chars.codePointAt(i) as number] = i } @@ -363,7 +351,7 @@ export const fromBase58Check = ( str: string, enc?: 'hex', prefixLength: number = 1 -): { data: number[] | string, prefix: number[] | string } => { +): { data: number[] | string; prefix: number[] | string } => { const bin = fromBase58(str) let prefix: string | number[] = bin.slice(0, prefixLength) let data: string | number[] = bin.slice(prefixLength, -4) @@ -387,17 +375,17 @@ export class Writer { public bufs: WriterChunk[] private length: number - constructor (bufs?: WriterChunk[]) { + constructor(bufs?: WriterChunk[]) { this.bufs = bufs ?? [] this.length = 0 for (const b of this.bufs) this.length += b.length } - getLength (): number { + getLength(): number { return this.length } - toUint8Array (): Uint8Array { + toUint8Array(): Uint8Array { const out = new Uint8Array(this.length) let offset = 0 for (const buf of this.bufs) { @@ -407,9 +395,9 @@ export class Writer { return out } - toArray (): number[] { + toArray(): number[] { const totalLength = this.length - const ret = new Array(totalLength) + const ret = Array.from({ length: totalLength }, () => 0) let offset = 0 for (const buf of this.bufs) { if (buf instanceof Uint8Array) { @@ -426,36 +414,38 @@ export class Writer { return ret } - toHex (): string { - return this.toArray().map((n) => n.toString(16).padStart(2, '0')).join('') + toHex(): string { + return this.toArray() + .map(n => n.toString(16).padStart(2, '0')) + .join('') } - write (buf: WriterChunk): this { + write(buf: WriterChunk): this { this.bufs.push(buf) this.length += buf.length return this } - writeReverse (buf: number[]): this { - const buf2: number[] = new Array(buf.length) + writeReverse(buf: number[]): this { + const buf2: number[] = Array.from({ length: buf.length }, () => 0) for (let i = 0; i < buf2.length; i++) { buf2[i] = buf[buf.length - 1 - i] } return this.write(buf2) } - writeUInt8 (n: number): this { - const buf = new Array(1) + writeUInt8(n: number): this { + const buf = Array.from({ length: 1 }, () => 0) buf[0] = n & 0xff this.write(buf) return this } - writeInt8 (n: number): this { + writeInt8(n: number): this { return this.writeUInt8(n) } - writeUInt16BE (n: number): this { + writeUInt16BE(n: number): this { const buf = [ (n >> 8) & 0xff, // shift right 8 bits to get the high byte n & 0xff // low byte is just the last 8 bits @@ -463,11 +453,11 @@ export class Writer { return this.write(buf) } - writeInt16BE (n: number): this { + writeInt16BE(n: number): this { return this.writeUInt16BE(n & 0xffff) // Mask with 0xFFFF to get the lower 16 bits } - writeUInt16LE (n: number): this { + writeUInt16LE(n: number): this { const buf = [ n & 0xff, // low byte is just the last 8 bits (n >> 8) & 0xff // shift right 8 bits to get the high byte @@ -475,11 +465,11 @@ export class Writer { return this.write(buf) } - writeInt16LE (n: number): this { + writeInt16LE(n: number): this { return this.writeUInt16LE(n & 0xffff) // Mask with 0xFFFF to get the lower 16 bits } - writeUInt32BE (n: number): this { + writeUInt32BE(n: number): this { const buf = [ (n >> 24) & 0xff, // highest byte (n >> 16) & 0xff, @@ -489,11 +479,11 @@ export class Writer { return this.write(buf) } - writeInt32BE (n: number): this { + writeInt32BE(n: number): this { return this.writeUInt32BE(n >>> 0) // Using unsigned right shift to handle negative numbers } - writeUInt32LE (n: number): this { + writeUInt32LE(n: number): this { const buf = [ n & 0xff, // lowest byte (n >> 8) & 0xff, @@ -503,26 +493,26 @@ export class Writer { return this.write(buf) } - writeInt32LE (n: number): this { + writeInt32LE(n: number): this { return this.writeUInt32LE(n >>> 0) // Using unsigned right shift to handle negative numbers } - writeUInt64BEBn (bn: BigNumber): this { + writeUInt64BEBn(bn: BigNumber): this { const buf = bn.toArray('be', 8) this.write(buf) return this } - writeUInt64LEBn (bn: BigNumber): this { + writeUInt64LEBn(bn: BigNumber): this { const buf = bn.toArray('be', 8) this.writeReverse(buf) return this } - writeUInt64LE (n: number): this { + writeUInt64LE(n: number): this { if (n === -1) { // This value is used as a dummy satoshis value when serializing OTDA placeholder output for SIGHASH_SINGLE - this.write(new Array(8).fill(0xff)) + this.write(Array.from({ length: 8 }, () => 0xff)) } else { const buf = new BigNumber(n).toArray('be', 8) this.writeReverse(buf) @@ -530,19 +520,19 @@ export class Writer { return this } - writeVarIntNum (n: number): this { + writeVarIntNum(n: number): this { const buf = Writer.varIntNum(n) this.write(buf) return this } - writeVarIntBn (bn: BigNumber): this { + writeVarIntBn(bn: BigNumber): this { const buf = Writer.varIntBn(bn) this.write(buf) return this } - static varIntNum (n: number): number[] { + static varIntNum(n: number): number[] { let buf: number[] if (n < 0) { return this.varIntBn(new BigNumber(n)) @@ -585,7 +575,7 @@ export class Writer { return buf } - static varIntBn (bn: BigNumber): number[] { + static varIntBn(bn: BigNumber): number[] { let buf: number[] if (bn.isNeg()) { bn = bn.add(OverflowUint64) // Adjust for negative numbers @@ -601,13 +591,7 @@ export class Writer { } else if (bn.lt(new BigNumber(0x100000000))) { const n = bn.toNumber() // Value fits in a uint32 - buf = [ - 254, - n & 0xff, - (n >> 8) & 0xff, - (n >> 16) & 0xff, - (n >> 24) & 0xff - ] + buf = [254, n & 0xff, (n >> 8) & 0xff, (n >> 16) & 0xff, (n >> 24) & 0xff] } else { const bw = new Writer() bw.writeUInt8(255) @@ -623,25 +607,25 @@ export class Reader { public pos: number private readonly length: number - constructor (bin: number[] = [], pos: number = 0) { + constructor(bin: number[] = [], pos: number = 0) { this.bin = bin this.pos = pos this.length = bin.length } - public eof (): boolean { + public eof(): boolean { return this.pos >= this.length } - public read (len = this.length): number[] { + public read(len = this.length): number[] { const start = this.pos const end = this.pos + len this.pos = end return this.bin.slice(start, end) } - public readReverse (len = this.length): number[] { - const buf2 = new Array(len) + public readReverse(len = this.length): number[] { + const buf2 = Array.from({ length: len }, () => 0) for (let i = 0; i < len; i++) { buf2[i] = this.bin[this.pos + len - 1 - i] } @@ -649,45 +633,45 @@ export class Reader { return buf2 } - public readUInt8 (): number { + public readUInt8(): number { const val = this.bin[this.pos] this.pos += 1 return val } - public readInt8 (): number { + public readInt8(): number { const val = this.bin[this.pos] this.pos += 1 // If the sign bit is set, convert to negative value return (val & 0x80) === 0 ? val : val - 0x100 } - public readUInt16BE (): number { + public readUInt16BE(): number { const val = (this.bin[this.pos] << 8) | this.bin[this.pos + 1] this.pos += 2 return val } - public readInt16BE (): number { + public readInt16BE(): number { const val = this.readUInt16BE() // If the sign bit is set, convert to negative value return (val & 0x8000) === 0 ? val : val - 0x10000 } - public readUInt16LE (): number { + public readUInt16LE(): number { const val = this.bin[this.pos] | (this.bin[this.pos + 1] << 8) this.pos += 2 return val } - public readInt16LE (): number { + public readInt16LE(): number { const val = this.readUInt16LE() // If the sign bit is set, convert to negative value const x = (val & 0x8000) === 0 ? val : val - 0x10000 return x } - public readUInt32BE (): number { + public readUInt32BE(): number { const val = this.bin[this.pos] * 0x1000000 + // Shift the first byte by 24 bits ((this.bin[this.pos + 1] << 16) | // Shift the second byte by 16 bits @@ -697,13 +681,13 @@ export class Reader { return val } - public readInt32BE (): number { + public readInt32BE(): number { const val = this.readUInt32BE() // If the sign bit is set, convert to negative value return (val & 0x80000000) === 0 ? val : val - 0x100000000 } - public readUInt32LE (): number { + public readUInt32LE(): number { const val = (this.bin[this.pos] | (this.bin[this.pos + 1] << 8) | @@ -714,26 +698,26 @@ export class Reader { return val } - public readInt32LE (): number { + public readInt32LE(): number { const val = this.readUInt32LE() // Explicitly check if the sign bit is set and then convert to a negative value return (val & 0x80000000) === 0 ? val : val - 0x100000000 } - public readUInt64BEBn (): BigNumber { + public readUInt64BEBn(): BigNumber { const bin = this.bin.slice(this.pos, this.pos + 8) const bn = new BigNumber(bin) this.pos = this.pos + 8 return bn } - public readUInt64LEBn (): BigNumber { + public readUInt64LEBn(): BigNumber { const bin = this.readReverse(8) const bn = new BigNumber(bin) return bn } - public readInt64LEBn (): BigNumber { + public readInt64LEBn(): BigNumber { const bin = this.readReverse(8) let bn = new BigNumber(bin) if (bn.gte(OverflowInt64)) { @@ -742,7 +726,7 @@ export class Reader { return bn } - public readVarIntNum (signed: boolean = true): number { + public readVarIntNum(signed: boolean = true): number { const first = this.readUInt8() let bn: BigNumber switch (first) { @@ -755,16 +739,14 @@ export class Reader { if (bn.lte(new BigNumber(2).pow(new BigNumber(53)))) { return bn.toNumber() } else { - throw new Error( - 'number too large to retain precision - use readVarIntBn' - ) + throw new Error('number too large to retain precision - use readVarIntBn') } default: return first } } - public readVarInt (): number[] { + public readVarInt(): number[] { const first = this.bin[this.pos] switch (first) { case 0xfd: @@ -778,7 +760,7 @@ export class Reader { } } - public readVarIntBn (): BigNumber { + public readVarIntBn(): BigNumber { const first = this.readUInt8() switch (first) { case 0xfd: @@ -851,12 +833,15 @@ const OverflowUint64 = new BigNumber(2).pow(new BigNumber(64)) * @example * const myValue = verifyNotNull(someValue, 'someValue must be defined') */ -export function verifyNotNull (value: T | undefined | null, errorMessage: string = 'Expected a valid value, but got undefined or null.'): T { +export function verifyNotNull( + value: T | undefined | null, + errorMessage: string = 'Expected a valid value, but got undefined or null.' +): T { if (value == null) throw new Error(errorMessage) return value } -export function constantTimeEquals (a: Uint8Array | number[], b: Uint8Array | number[]): boolean { +export function constantTimeEquals(a: Uint8Array | number[], b: Uint8Array | number[]): boolean { if (a.length !== b.length) return false let diff = 0 diff --git a/packages/sdk/src/script/Script.ts b/packages/sdk/src/script/Script.ts index 1557d0f98..89010e9b6 100644 --- a/packages/sdk/src/script/Script.ts +++ b/packages/sdk/src/script/Script.ts @@ -10,8 +10,7 @@ import BigNumber from '../primitives/BigNumber.js' * * @property {ScriptChunk[]} chunks - An array of script chunks that make up the script. */ -const BufferCtor = - typeof globalThis === 'undefined' ? undefined : (globalThis as any).Buffer +const BufferCtor = typeof globalThis === 'undefined' ? undefined : (globalThis as any).Buffer export default class Script { private _chunks: ScriptChunk[] @@ -27,7 +26,7 @@ export default class Script { * @example * const script = Script.fromASM("OP_DUP OP_HASH160 abcd... OP_EQUALVERIFY OP_CHECKSIG") */ - static fromASM (asm: string): Script { + static fromASM(asm: string): Script { const chunks: ScriptChunk[] = [] const tokens = asm.split(' ') let i = 0 @@ -39,17 +38,17 @@ export default class Script { return new Script(chunks) } - private static pushdataOpCodeNum (len: number): number { + private static pushdataOpCodeNum(len: number): number { if (len >= 0 && len < OP.OP_PUSHDATA1) return len if (len < Math.pow(2, 8)) return OP.OP_PUSHDATA1 if (len < Math.pow(2, 16)) return OP.OP_PUSHDATA2 return OP.OP_PUSHDATA4 } - private static parseASMToken ( + private static parseASMToken( tokens: string[], i: number - ): { chunk: ScriptChunk, advance: number } { + ): { chunk: ScriptChunk; advance: number } { const token = tokens[i] // Special literal tokens @@ -90,7 +89,7 @@ export default class Script { * @example * const script = Script.fromHex("76a9..."); */ - static fromHex (hex: string): Script { + static fromHex(hex: string): Script { if (hex.length === 0) return Script.fromBinary([]) if (hex.length % 2 !== 0) { throw new Error( @@ -112,7 +111,7 @@ export default class Script { * @example * const script = Script.fromBinary([0x76, 0xa9, ...]) */ - static fromBinary (bin: number[] | Uint8Array): Script { + static fromBinary(bin: number[] | Uint8Array): Script { const rawBytes = Uint8Array.from(bin) return new Script([], rawBytes, undefined, false) } @@ -121,7 +120,7 @@ export default class Script { * Constructs a lazily parsed script over an existing byte view without a copy. * The caller must not mutate `bin` while the script is in use. */ - static fromBinaryView (bin: Uint8Array): Script { + static fromBinaryView(bin: Uint8Array): Script { return new Script([], bin, undefined, false) } @@ -133,7 +132,12 @@ export default class Script { * @param hexCache - Optional lowercase hex string that matches the serialized bytes, used to satisfy `toHex` quickly. * @param parsed - When false the script defers parsing `rawBytesCache` until `chunks` is accessed; defaults to true. */ - constructor (chunks: ScriptChunk[] = [], rawBytesCache?: Uint8Array, hexCache?: string, parsed: boolean = true) { + constructor( + chunks: ScriptChunk[] = [], + rawBytesCache?: Uint8Array, + hexCache?: string, + parsed: boolean = true + ) { this._chunks = chunks this.parsed = parsed this.rawBytesCache = rawBytesCache @@ -145,18 +149,18 @@ export default class Script { * array through this property; mutating returned chunk objects in place * bypasses serialization-cache invalidation. */ - get chunks (): ScriptChunk[] { + get chunks(): ScriptChunk[] { this.ensureParsed() return this._chunks } - set chunks (value: ScriptChunk[]) { + set chunks(value: ScriptChunk[]) { this._chunks = value this.parsed = true this.invalidateSerializationCaches() } - private ensureParsed (): void { + private ensureParsed(): void { if (this.parsed) return if (this.rawBytesCache != null) { this._chunks = Script.parseChunks(this.rawBytesCache) @@ -171,7 +175,7 @@ export default class Script { * Serializes the script to an ASM formatted string. * @returns The script in ASM string format. */ - toASM (): string { + toASM(): string { let str = '' for (const chunk of this.chunks) { str += this._chunkToString(chunk) @@ -185,7 +189,7 @@ export default class Script { * Serializes the script to a hexadecimal string. * @returns The script in hexadecimal format. */ - toHex (): string { + toHex(): string { if (this.hexCache != null) { return this.hexCache } @@ -203,11 +207,11 @@ export default class Script { * Serializes the script to a binary array. * @returns The script in binary array format. */ - toBinary (): number[] { + toBinary(): number[] { return Array.from(this.toUint8Array()) } - toUint8Array (): Uint8Array { + toUint8Array(): Uint8Array { this.rawBytesCache ??= this.serializeChunksToBytes() return this.rawBytesCache } @@ -218,7 +222,7 @@ export default class Script { * @param script - The script to append. * @returns This script instance for chaining. */ - writeScript (script: Script): this { + writeScript(script: Script): this { this.invalidateSerializationCaches() this.chunks = this.chunks.concat(script.chunks) return this @@ -230,7 +234,7 @@ export default class Script { * @param op - The opcode to append. * @returns This script instance for chaining. */ - writeOpCode (op: number): this { + writeOpCode(op: number): this { this.invalidateSerializationCaches() this.chunks.push({ op }) return this @@ -243,7 +247,7 @@ export default class Script { * @param op - The opcode to set. * @returns This script instance for chaining. */ - setChunkOpCode (i: number, op: number): this { + setChunkOpCode(i: number, op: number): this { this.invalidateSerializationCaches() this.chunks[i] = { op } return this @@ -255,7 +259,7 @@ export default class Script { * @param bn - The BigNumber to append. * @returns This script instance for chaining. */ - writeBn (bn: BigNumber): this { + writeBn(bn: BigNumber): this { this.invalidateSerializationCaches() if (bn.cmpn(0) === OP.OP_0) { this.chunks.push({ @@ -284,7 +288,7 @@ export default class Script { * @returns This script instance for chaining. * @throws {Error} Throws an error if the data is too large to be pushed. */ - writeBin (bin: number[]): this { + writeBin(bin: number[]): this { this.invalidateSerializationCaches() let op: number const data = bin.length > 0 ? bin : undefined @@ -314,7 +318,7 @@ export default class Script { * @param num - The number to append. * @returns This script instance for chaining. */ - writeNumber (num: number): this { + writeNumber(num: number): this { this.invalidateSerializationCaches() this.writeBn(new BigNumber(num)) return this @@ -325,7 +329,7 @@ export default class Script { * Removes all OP_CODESEPARATOR opcodes from the script. * @returns This script instance for chaining. */ - removeCodeseparators (): this { + removeCodeseparators(): this { const bytes = this.toUint8Array() this.rawBytesCache = Uint8Array.from(Script.removeOpcodeBytes(bytes, OP.OP_CODESEPARATOR)) this.hexCache = undefined @@ -341,7 +345,7 @@ export default class Script { * * @returns This script instance for chaining. */ - findAndDelete (script: Script): this { + findAndDelete(script: Script): this { this.invalidateSerializationCaches() const targetBytes = script.toUint8Array() const targetLen = targetBytes.length @@ -424,7 +428,7 @@ export default class Script { * Checks if the script contains only push data operations. * @returns True if the script is push-only, otherwise false. */ - isPushOnly (): boolean { + isPushOnly(): boolean { for (const chunk of this.chunks) { const opCodeNum = chunk.op if (opCodeNum > OP.OP_16) { @@ -439,7 +443,7 @@ export default class Script { * Determines if the script is a locking script. * @returns True if the script is a locking script, otherwise false. */ - isLockingScript (): boolean { + isLockingScript(): boolean { throw new Error('Not implemented') } @@ -448,7 +452,7 @@ export default class Script { * Determines if the script is an unlocking script. * @returns True if the script is an unlocking script, otherwise false. */ - isUnlockingScript (): boolean { + isUnlockingScript(): boolean { throw new Error('Not implemented') } @@ -459,7 +463,7 @@ export default class Script { * @param chunk - The script chunk. * @returns The string representation of the chunk. */ - private static computeSerializedLength (chunks: ScriptChunk[]): number { + private static computeSerializedLength(chunks: ScriptChunk[]): number { let total = 0 for (const chunk of chunks) { total += 1 @@ -482,7 +486,7 @@ export default class Script { return total } - private serializeChunksToBytes (): Uint8Array { + private serializeChunksToBytes(): Uint8Array { const chunks = this.chunks const totalLength = Script.computeSerializedLength(chunks) const bytes = new Uint8Array(totalLength) @@ -501,12 +505,12 @@ export default class Script { return bytes } - private invalidateSerializationCaches (): void { + private invalidateSerializationCaches(): void { this.rawBytesCache = undefined this.hexCache = undefined } - private static writeChunkData ( + private static writeChunkData( target: Uint8Array, offset: number, op: number, @@ -541,18 +545,18 @@ export default class Script { * Reads pushdata length bytes from `bytes` at `pos` and returns the resulting * `{ len, newPos, hasLength }` for a given opcode. Does not read the actual data. */ - private static readPushdataLength ( + private static readPushdataLength( op: number, bytes: ArrayLike, pos: number, length: number - ): { len: number, newPos: number, hasLength: boolean } { + ): { len: number; newPos: number; hasLength: boolean } { if (op > 0 && op < OP.OP_PUSHDATA1) { return { len: op, newPos: pos, hasLength: true } } if (op === OP.OP_PUSHDATA1) { const hasLength = pos < length - const len = hasLength ? bytes[pos++] ?? 0 : 0 + const len = hasLength ? (bytes[pos++] ?? 0) : 0 return { len, newPos: pos, hasLength } } if (op === OP.OP_PUSHDATA2) { @@ -562,16 +566,16 @@ export default class Script { } // OP_PUSHDATA4 const hasLength = pos + 3 < length - const len = ( - (bytes[pos] ?? 0) | - ((bytes[pos + 1] ?? 0) << 8) | - ((bytes[pos + 2] ?? 0) << 16) | - ((bytes[pos + 3] ?? 0) << 24) - ) >>> 0 + const len = + ((bytes[pos] ?? 0) | + ((bytes[pos + 1] ?? 0) << 8) | + ((bytes[pos + 2] ?? 0) << 16) | + ((bytes[pos + 3] ?? 0) << 24)) >>> + 0 return { len, newPos: Math.min(pos + 4, length), hasLength } } - private static parseChunks (bytes: ArrayLike): ScriptChunk[] { + private static parseChunks(bytes: ArrayLike): ScriptChunk[] { const chunks: ScriptChunk[] = [] const length = bytes.length let pos = 0 @@ -606,7 +610,7 @@ export default class Script { return chunks } - private static removeOpcodeBytes (bytes: ArrayLike, opcode: number): number[] { + private static removeOpcodeBytes(bytes: ArrayLike, opcode: number): number[] { const out: number[] = [] const length = bytes.length let pos = 0 @@ -631,20 +635,16 @@ export default class Script { return out } - private static copyRange ( - bytes: ArrayLike, - start: number, - end: number - ): number[] { + private static copyRange(bytes: ArrayLike, start: number, end: number): number[] { const size = Math.max(end - start, 0) - const data = new Array(size) + const data = Array.from({ length: size }, () => 0) for (let i = 0; i < size; i++) { data[i] = bytes[start + i] ?? 0 } return data } - private _chunkToString (chunk: ScriptChunk): string { + private _chunkToString(chunk: ScriptChunk): string { const op = chunk.op let str = '' if (chunk.data === undefined) { diff --git a/packages/sdk/src/script/Spend.ts b/packages/sdk/src/script/Spend.ts index af2b25c36..d5bd9ea45 100644 --- a/packages/sdk/src/script/Spend.ts +++ b/packages/sdk/src/script/Spend.ts @@ -7,7 +7,9 @@ import ScriptChunk from './ScriptChunk.js' import { minimallyEncode, toArray, WriterUint8Array } from '../primitives/utils.js' import ScriptEvaluationError from './ScriptEvaluationError.js' import * as Hash from '../primitives/Hash.js' -import TransactionSignature, { type SignatureHashCache } from '../primitives/TransactionSignature.js' +import TransactionSignature, { + type SignatureHashCache +} from '../primitives/TransactionSignature.js' import PublicKey from '../primitives/PublicKey.js' import { verify } from '../primitives/ECDSA.js' import TransactionInput from '../transaction/TransactionInput.js' @@ -36,7 +38,7 @@ const SCRIPTNUMS_0_TO_16: ReadonlyArray> = Object.freeze( // --- Helper functions --- -function compareNumberArrays (a: Readonly, b: Readonly): boolean { +function compareNumberArrays(a: Readonly, b: Readonly): boolean { if (a.length !== b.length) return false for (let i = 0; i < a.length; i++) { if (a[i] !== b[i]) return false @@ -44,7 +46,7 @@ function compareNumberArrays (a: Readonly, b: Readonly): boo return true } -function isMinimallyEncodedHelper ( +function isMinimallyEncodedHelper( buf: Readonly, maxNumSize: number = Number.MAX_SAFE_INTEGER ): boolean { @@ -61,7 +63,7 @@ function isMinimallyEncodedHelper ( return true } -function isChecksigFormatHelper (buf: Readonly): boolean { +function isChecksigFormatHelper(buf: Readonly): boolean { // This is a simplified check. The full DER check is more complex and typically // done by TransactionSignature.fromChecksigFormat which can throw. // This helper is mostly for early bailout or non-throwing checks if needed. @@ -95,7 +97,7 @@ function isChecksigFormatHelper (buf: Readonly): boolean { return true } -function isChunkMinimalPushHelper (chunk: ScriptChunk): boolean { +function isChunkMinimalPushHelper(chunk: ScriptChunk): boolean { const data = chunk.data const op = chunk.op if (!Array.isArray(data)) return true @@ -199,7 +201,7 @@ export default class Spend { * memoryLimit: 100000 // memoryLimit * }); */ - constructor (params: { + constructor(params: { sourceTXID: string sourceOutputIndex: number sourceSatoshis: number @@ -257,86 +259,89 @@ export default class Spend { this.reset() } - private isRelaxed (): boolean { - return this.isRelaxedOverride || - (this.transactionVersion > 1) + private isRelaxed(): boolean { + return this.isRelaxedOverride || this.transactionVersion > 1 } - private hasExplicitFlags (): boolean { + private hasExplicitFlags(): boolean { return this.verifyFlags !== undefined } - private hasFlag (flag: string): boolean { + private hasFlag(flag: string): boolean { return this.verifyFlags?.has(flag) === true } - private isAfterGenesis (): boolean { + private isAfterGenesis(): boolean { if (this.hasExplicitFlags()) { - return this.hasFlag('GENESIS') || + return ( + this.hasFlag('GENESIS') || this.hasFlag('UTXO_AFTER_GENESIS') || this.hasFlag('UTXO_AFTER_CHRONICLE') + ) } return this.isRelaxed() } - private isAfterChronicle (): boolean { + private isAfterChronicle(): boolean { if (this.hasExplicitFlags()) return this.hasFlag('UTXO_AFTER_CHRONICLE') return this.isRelaxed() } - private shouldEnforceMinimalData (): boolean { + private shouldEnforceMinimalData(): boolean { if (this.hasExplicitFlags()) return this.hasFlag('MINIMALDATA') return !this.isRelaxed() } - private shouldEnforceLowS (): boolean { + private shouldEnforceLowS(): boolean { if (this.hasExplicitFlags()) return this.hasFlag('LOW_S') return !this.isRelaxed() } - private shouldEnforceNullDummy (): boolean { + private shouldEnforceNullDummy(): boolean { if (this.hasExplicitFlags()) return this.hasFlag('NULLDUMMY') return !this.isRelaxed() } - private shouldEnforceSigPushOnly (): boolean { + private shouldEnforceSigPushOnly(): boolean { if (this.hasExplicitFlags()) return this.hasFlag('SIGPUSHONLY') return !this.isRelaxed() } - private shouldEnforceCleanStack (): boolean { + private shouldEnforceCleanStack(): boolean { if (this.hasExplicitFlags()) return this.hasFlag('CLEANSTACK') return !this.isRelaxed() } - private shouldEnforceDerSignatures (): boolean { + private shouldEnforceDerSignatures(): boolean { if (this.hasExplicitFlags()) { - return this.hasFlag('DERSIG') || + return ( + this.hasFlag('DERSIG') || this.hasFlag('STRICTENC') || this.hasFlag('LOW_S') || this.hasFlag('SIGHASH_FORKID') + ) } return true } - private shouldEnforceStrictEncoding (): boolean { + private shouldEnforceStrictEncoding(): boolean { if (this.hasExplicitFlags()) { return this.hasFlag('STRICTENC') || this.hasFlag('SIGHASH_FORKID') } return true } - private scriptNumMaxSize (): number | undefined { + private scriptNumMaxSize(): number | undefined { if (this.hasExplicitFlags() && !this.isAfterGenesis()) return 4 return undefined } - private maxPushSize (): number { + private maxPushSize(): number { if (this.hasExplicitFlags() && !this.isAfterGenesis()) return maxScriptElementSizeBeforeGenesis return Number.POSITIVE_INFINITY } - reset (): void { + reset(): void { if (this.ownsSigHashCache) { delete this.sigHashCache.hashPrevouts delete this.sigHashCache.hashSequence @@ -356,17 +361,13 @@ export default class Spend { this.returningFromConditional = false } - private ensureStackMem (additional: number): void { + private ensureStackMem(additional: number): void { if (this.stackMem + additional > this.memoryLimit) { - throw new ScriptResourceLimitError( - 'stack', - this.memoryLimit, - this.stackMem + additional - ) + throw new ScriptResourceLimitError('stack', this.memoryLimit, this.stackMem + additional) } } - private ensureAltStackMem (additional: number): void { + private ensureAltStackMem(additional: number): void { if (this.altStackMem + additional > this.memoryLimit) { throw new ScriptResourceLimitError( 'alt-stack', @@ -376,20 +377,20 @@ export default class Spend { } } - private pushStack (item: number[]): void { + private pushStack(item: number[]): void { this.ensureStackMem(item.length) this.stack.push(item) this.stackMem += item.length } - private pushStackCopy (item: Readonly): void { + private pushStackCopy(item: Readonly): void { this.ensureStackMem(item.length) const copy = item.slice() this.stack.push(copy) this.stackMem += copy.length } - private popStack (): number[] { + private popStack(): number[] { if (this.stack.length === 0) { this.scriptEvaluationError('Attempted to pop from an empty stack.') } @@ -402,32 +403,38 @@ export default class Spend { return item } - private stackTop (index: number = -1): number[] { + private stackTop(index: number = -1): number[] { // index = -1 for top, -2 for second top, etc. // stack.length + index provides 0-based index from start - if (this.stack.length === 0 || this.stack.length < Math.abs(index) || (index >= 0 && index >= this.stack.length)) { - this.scriptEvaluationError(`Stack underflow accessing element at index ${index}. Stack length is ${this.stack.length}.`) + if ( + this.stack.length === 0 || + this.stack.length < Math.abs(index) || + (index >= 0 && index >= this.stack.length) + ) { + this.scriptEvaluationError( + `Stack underflow accessing element at index ${index}. Stack length is ${this.stack.length}.` + ) } return this.stack[this.stack.length + index] } - private setStack (items: number[][]): void { + private setStack(items: number[][]): void { this.stack = items.map(item => item.slice()) this.stackMem = this.stack.reduce((total, item) => total + item.length, 0) } - private clearAltStack (): void { + private clearAltStack(): void { this.altStack = [] this.altStackMem = 0 } - private pushAltStack (item: number[]): void { + private pushAltStack(item: number[]): void { this.ensureAltStackMem(item.length) this.altStack.push(item) this.altStackMem += item.length } - private popAltStack (): number[] { + private popAltStack(): number[] { if (this.altStack.length === 0) { this.scriptEvaluationError('Attempted to pop from an empty alt stack.') } @@ -440,13 +447,9 @@ export default class Spend { return item } - private readScriptNumber (buf: number[]): BigNumber { + private readScriptNumber(buf: number[]): BigNumber { try { - return BigNumber.fromScriptNum( - buf, - this.shouldEnforceMinimalData(), - this.scriptNumMaxSize() - ) + return BigNumber.fromScriptNum(buf, this.shouldEnforceMinimalData(), this.scriptNumMaxSize()) } catch (e) { const message = e instanceof Error ? e.message : String(e) this.scriptEvaluationError(message) @@ -454,13 +457,15 @@ export default class Spend { return new BigNumber(0) } - private isDefinedHashType (scope: number): boolean { + private isDefinedHashType(scope: number): boolean { const baseType = scope & 0x1f - return baseType >= TransactionSignature.SIGHASH_ALL && + return ( + baseType >= TransactionSignature.SIGHASH_ALL && baseType <= TransactionSignature.SIGHASH_SINGLE + ) } - private checkSignatureEncoding (buf: Readonly): boolean { + private checkSignatureEncoding(buf: Readonly): boolean { if (buf.length === 0) return true const enforceDer = this.shouldEnforceDerSignatures() @@ -506,7 +511,7 @@ export default class Spend { return true } - private parseChecksigSignature (buf: number[]): TransactionSignature { + private parseChecksigSignature(buf: number[]): TransactionSignature { try { return TransactionSignature.fromChecksigFormat(buf) } catch (e) { @@ -515,7 +520,7 @@ export default class Spend { } } - private readLaxDERLength (buf: number[], position: { value: number }): number { + private readLaxDERLength(buf: number[], position: { value: number }): number { const first = buf[position.value++] if (first === undefined) throw new Error('Invalid DER length') if ((first & 0x80) === 0) return first @@ -532,7 +537,11 @@ export default class Spend { return length } - private parseLaxDERInteger (buf: number[], position: { value: number }, sequenceEnd: number): BigNumber { + private parseLaxDERInteger( + buf: number[], + position: { value: number }, + sequenceEnd: number + ): BigNumber { if (position.value >= sequenceEnd || buf[position.value++] !== 0x02) { throw new Error('Invalid DER integer') } @@ -548,7 +557,7 @@ export default class Spend { return new BigNumber(bytes) } - private parseLaxChecksigSignature (buf: number[]): TransactionSignature { + private parseLaxChecksigSignature(buf: number[]): TransactionSignature { if (buf.length === 0) return TransactionSignature.fromChecksigFormat(buf) const scope = buf.at(-1) @@ -562,7 +571,7 @@ export default class Spend { return new TransactionSignature(r, s, scope) } - private checkPublicKeyEncoding (buf: Readonly): boolean { + private checkPublicKeyEncoding(buf: Readonly): boolean { if (!this.shouldEnforceStrictEncoding()) return true if (buf.length === 0) { this.scriptEvaluationError('Public key is empty.') @@ -595,7 +604,7 @@ export default class Spend { return true } - private verifySignature ( + private verifySignature( sig: TransactionSignature, pubkey: PublicKey, subscript: Script @@ -616,12 +625,12 @@ export default class Spend { cache: this.sigHashCache } const hash = TransactionSignature.usesOtdaSingleBug(params) - ? new BigNumber([1, ...new Array(31).fill(0)]) + ? new BigNumber([1, ...Array.from({ length: 31 }, () => 0)]) : new BigNumber(Hash.hash256(TransactionSignature.formatBytes(params))) return verify(hash, sig, pubkey) } - step (): boolean { + step(): boolean { if (this.stackMem > this.memoryLimit) { throw new ScriptResourceLimitError('stack', this.memoryLimit, this.stackMem) } @@ -647,7 +656,8 @@ export default class Spend { this.programCounter = 0 } - const currentScript = this.context === 'UnlockingScript' ? this.unlockingScript : this.lockingScript + const currentScript = + this.context === 'UnlockingScript' ? this.unlockingScript : this.lockingScript if (this.programCounter >= currentScript.chunks.length) { return false } @@ -658,10 +668,14 @@ export default class Spend { this.scriptEvaluationError(`Missing opcode in ${this.context} at pc=${this.programCounter}.`) // Error thrown } if (operation.invalidLength === true) { - this.scriptEvaluationError(`Malformed push data in ${this.context} at pc=${this.programCounter}.`) + this.scriptEvaluationError( + `Malformed push data in ${this.context} at pc=${this.programCounter}.` + ) } if (Array.isArray(operation.data) && operation.data.length > this.maxPushSize()) { - this.scriptEvaluationError(`Data push > ${this.maxPushSize()} bytes (pc=${this.programCounter}).`) // Error thrown + this.scriptEvaluationError( + `Data push > ${this.maxPushSize()} bytes (pc=${this.programCounter}).` + ) // Error thrown } const isScriptExecuting = !this.returningFromConditional && !this.ifStack.includes(false) @@ -670,19 +684,19 @@ export default class Spend { this.hasExplicitFlags() && !this.isAfterGenesis() && !this.isAfterChronicle() && - ( - currentOpcode === OP.OP_2MUL || + (currentOpcode === OP.OP_2MUL || currentOpcode === OP.OP_2DIV || currentOpcode === OP.OP_VERIF || - currentOpcode === OP.OP_VERNOTIF - ) + currentOpcode === OP.OP_VERNOTIF) ) { this.scriptEvaluationError(`${OP[currentOpcode] as string} is disabled until Chronicle.`) } if (isScriptExecuting && currentOpcode >= 0 && currentOpcode <= OP.OP_PUSHDATA4) { if (this.shouldEnforceMinimalData() && !isChunkMinimalPushHelper(operation)) { - this.scriptEvaluationError(`This data is not minimally-encoded. (PC: ${this.programCounter})`) // Error thrown + this.scriptEvaluationError( + `This data is not minimally-encoded. (PC: ${this.programCounter})` + ) // Error thrown } this.pushStack(Array.isArray(operation.data) ? operation.data : []) } else if (isScriptExecuting || (currentOpcode >= OP.OP_IF && currentOpcode <= OP.OP_ENDIF)) { @@ -692,11 +706,20 @@ export default class Spend { let n: number, size: number, fValue: boolean, fSuccess: boolean, subscript: Script let bufSig: number[], bufPubkey: number[] let sig: TransactionSignature, pubkey: PublicKey - let i: number, ikey: number, isig: number, nKeysCount: number, nSigsCount: number, fOk: boolean + let i: number, + ikey: number, + isig: number, + nKeysCount: number, + nSigsCount: number, + fOk: boolean if (isScriptExecuting && currentOpcode > OP.OP_16) { this.executedOpCount++ - if (this.hasExplicitFlags() && !this.isAfterGenesis() && this.executedOpCount > maxOpsBeforeGenesis) { + if ( + this.hasExplicitFlags() && + !this.isAfterGenesis() && + this.executedOpCount > maxOpsBeforeGenesis + ) { this.scriptEvaluationError(`Script executed more than ${maxOpsBeforeGenesis} opcodes.`) } } @@ -704,16 +727,16 @@ export default class Spend { if (this.hasExplicitFlags() && !this.isAfterChronicle()) { if ( isScriptExecuting && - ( - currentOpcode === OP.OP_SUBSTR || + (currentOpcode === OP.OP_SUBSTR || currentOpcode === OP.OP_LEFT || currentOpcode === OP.OP_RIGHT || currentOpcode === OP.OP_LSHIFTNUM || - currentOpcode === OP.OP_RSHIFTNUM - ) + currentOpcode === OP.OP_RSHIFTNUM) ) { if (this.hasFlag('DISCOURAGE_UPGRADABLE_NOPS')) { - this.scriptEvaluationError(`${OP[currentOpcode] as string} is discouraged by verification flags.`) + this.scriptEvaluationError( + `${OP[currentOpcode] as string} is discouraged by verification flags.` + ) } this.programCounter++ return true @@ -726,11 +749,9 @@ export default class Spend { } if ( (isScriptExecuting || !this.isAfterGenesis()) && - ( - currentOpcode === OP.OP_VER || + (currentOpcode === OP.OP_VER || currentOpcode === OP.OP_VERIF || - currentOpcode === OP.OP_VERNOTIF - ) + currentOpcode === OP.OP_VERNOTIF) ) { this.scriptEvaluationError(`${OP[currentOpcode] as string} is disabled until Chronicle.`) } @@ -747,15 +768,15 @@ export default class Spend { if ( isScriptExecuting && this.hasFlag('DISCOURAGE_UPGRADABLE_NOPS') && - ( - currentOpcode === OP.OP_NOP1 || + (currentOpcode === OP.OP_NOP1 || currentOpcode === OP.OP_CHECKLOCKTIMEVERIFY || currentOpcode === OP.OP_CHECKSEQUENCEVERIFY || currentOpcode === OP.OP_NOP9 || - currentOpcode === OP.OP_NOP10 - ) + currentOpcode === OP.OP_NOP10) ) { - this.scriptEvaluationError(`${OP[currentOpcode] as string} is discouraged by verification flags.`) + this.scriptEvaluationError( + `${OP[currentOpcode] as string} is discouraged by verification flags.` + ) } switch (currentOpcode) { @@ -766,21 +787,27 @@ export default class Spend { break } case OP.OP_SUBSTR: { - if (this.stack.length < 3) this.scriptEvaluationError('OP_SUBSTR requires at least three items to be on the stack.') + if (this.stack.length < 3) + this.scriptEvaluationError( + 'OP_SUBSTR requires at least three items to be on the stack.' + ) const len = this.readScriptNumber(this.popStack()).toNumber() const offset = this.readScriptNumber(this.popStack()).toNumber() buf = this.popStack() const size = buf.length if (offset < 0 || offset >= size || len < 0 || len > size - offset) { - this.scriptEvaluationError(`OP_SUBSTR offset (${offset}) must be in range [0, ${size}) and length (${len}) must be in range [0, ${size - offset}]`) + this.scriptEvaluationError( + `OP_SUBSTR offset (${offset}) must be in range [0, ${size}) and length (${len}) must be in range [0, ${size - offset}]` + ) } this.pushStack(buf.slice(offset, offset + len)) break } case OP.OP_LEFT: { - if (this.stack.length < 2) this.scriptEvaluationError('OP_LEFT requires at least two items to be on the stack.') + if (this.stack.length < 2) + this.scriptEvaluationError('OP_LEFT requires at least two items to be on the stack.') const len = this.readScriptNumber(this.popStack()).toNumber() buf = this.popStack() const size = buf.length @@ -793,7 +820,8 @@ export default class Spend { break } case OP.OP_RIGHT: { - if (this.stack.length < 2) this.scriptEvaluationError('OP_RIGHT requires at least two items to be on the stack.') + if (this.stack.length < 2) + this.scriptEvaluationError('OP_RIGHT requires at least two items to be on the stack.') const len = this.readScriptNumber(this.popStack()).toNumber() buf = this.popStack() const size = buf.length @@ -806,7 +834,10 @@ export default class Spend { break } case OP.OP_LSHIFTNUM: { - if (this.stack.length < 2) this.scriptEvaluationError('OP_LSHIFTNUM requires at least two items to be on the stack.') + if (this.stack.length < 2) + this.scriptEvaluationError( + 'OP_LSHIFTNUM requires at least two items to be on the stack.' + ) const bits = this.readScriptNumber(this.popStack()).toBigInt() if (bits < 0) { this.scriptEvaluationError('OP_LSHIFTNUM bits to shift must not be negative.') @@ -817,7 +848,10 @@ export default class Spend { break } case OP.OP_RSHIFTNUM: { - if (this.stack.length < 2) this.scriptEvaluationError('OP_RSHIFTNUM requires at least two items to be on the stack.') + if (this.stack.length < 2) + this.scriptEvaluationError( + 'OP_RSHIFTNUM requires at least two items to be on the stack.' + ) const bits = this.readScriptNumber(this.popStack()).toBigInt() if (bits < 0) { this.scriptEvaluationError('OP_RSHIFTNUM bits to shift must not be negative.') @@ -833,12 +867,28 @@ export default class Spend { break } - case OP.OP_1NEGATE: this.pushStackCopy(SCRIPTNUM_NEG_1); break - case OP.OP_0: this.pushStackCopy(SCRIPTNUMS_0_TO_16[0]); break - case OP.OP_1: case OP.OP_2: case OP.OP_3: case OP.OP_4: - case OP.OP_5: case OP.OP_6: case OP.OP_7: case OP.OP_8: - case OP.OP_9: case OP.OP_10: case OP.OP_11: case OP.OP_12: - case OP.OP_13: case OP.OP_14: case OP.OP_15: case OP.OP_16: + case OP.OP_1NEGATE: + this.pushStackCopy(SCRIPTNUM_NEG_1) + break + case OP.OP_0: + this.pushStackCopy(SCRIPTNUMS_0_TO_16[0]) + break + case OP.OP_1: + case OP.OP_2: + case OP.OP_3: + case OP.OP_4: + case OP.OP_5: + case OP.OP_6: + case OP.OP_7: + case OP.OP_8: + case OP.OP_9: + case OP.OP_10: + case OP.OP_11: + case OP.OP_12: + case OP.OP_13: + case OP.OP_14: + case OP.OP_15: + case OP.OP_16: n = currentOpcode - (OP.OP_1 - 1) this.pushStackCopy(SCRIPTNUMS_0_TO_16[n]) break @@ -855,16 +905,31 @@ export default class Spend { // OP_NOP3 (0xb2) = OP_CHECKSEQUENCEVERIFY: on BSV post-genesis treated as NOP case OP.OP_CHECKSEQUENCEVERIFY: if (this.hasFlag('CHECKSEQUENCEVERIFY')) { - if (this.stack.length < 1) this.scriptEvaluationError('OP_CHECKSEQUENCEVERIFY requires at least one item to be on the stack.') + if (this.stack.length < 1) + this.scriptEvaluationError( + 'OP_CHECKSEQUENCEVERIFY requires at least one item to be on the stack.' + ) let sequenceLock = 0n try { // BIP112 explicitly permits 5-byte script numbers so the disable flag can be represented. - sequenceLock = BigNumber.fromScriptNum(this.stackTop(), this.shouldEnforceMinimalData(), 5).toBigInt() + sequenceLock = BigNumber.fromScriptNum( + this.stackTop(), + this.shouldEnforceMinimalData(), + 5 + ).toBigInt() } catch { - this.scriptEvaluationError('OP_CHECKSEQUENCEVERIFY requires a minimally-encoded numeric lock time.') + this.scriptEvaluationError( + 'OP_CHECKSEQUENCEVERIFY requires a minimally-encoded numeric lock time.' + ) } - if (sequenceLock < 0n) this.scriptEvaluationError('OP_CHECKSEQUENCEVERIFY requires a non-negative lock time.') - if ((Number(sequenceLock & BigInt(sequenceLocktimeDisableFlag)) === 0) && this.transactionVersion < 2) { + if (sequenceLock < 0n) + this.scriptEvaluationError( + 'OP_CHECKSEQUENCEVERIFY requires a non-negative lock time.' + ) + if ( + Number(sequenceLock & BigInt(sequenceLocktimeDisableFlag)) === 0 && + this.transactionVersion < 2 + ) { this.scriptEvaluationError('OP_CHECKSEQUENCEVERIFY lock time is unsatisfied.') } } @@ -877,7 +942,10 @@ export default class Spend { case OP.OP_VERNOTIF: fValue = false if (isScriptExecuting) { - if (this.stack.length < 1) this.scriptEvaluationError('OP_VERIF and OP_VERNOTIF require at least one item on the stack when they are used!') + if (this.stack.length < 1) + this.scriptEvaluationError( + 'OP_VERIF and OP_VERNOTIF require at least one item on the stack when they are used!' + ) buf1 = this.popStack() // Node v1.2.0: compares against 4-byte little-endian tx_version (only matches when item is exactly 4 bytes) if (buf1.length === 4) { @@ -894,9 +962,16 @@ export default class Spend { case OP.OP_NOTIF: fValue = false if (isScriptExecuting) { - if (this.stack.length < 1) this.scriptEvaluationError('OP_IF and OP_NOTIF require at least one item on the stack when they are used!') + if (this.stack.length < 1) + this.scriptEvaluationError( + 'OP_IF and OP_NOTIF require at least one item on the stack when they are used!' + ) buf = this.popStack() - if (this.hasFlag('MINIMALIF') && buf.length > 0 && !(buf.length === 1 && buf[0] === 1)) { + if ( + this.hasFlag('MINIMALIF') && + buf.length > 0 && + !(buf.length === 1 && buf[0] === 1) + ) { this.scriptEvaluationError('OP_IF and OP_NOTIF require minimal truth values.') } fValue = this.castToBool(buf) @@ -906,23 +981,29 @@ export default class Spend { this.elseStack.push(false) break case OP.OP_ELSE: - if (this.ifStack.length === 0) this.scriptEvaluationError('OP_ELSE requires a preceeding OP_IF.') + if (this.ifStack.length === 0) + this.scriptEvaluationError('OP_ELSE requires a preceeding OP_IF.') if (this.hasExplicitFlags() && this.isAfterGenesis() && this.elseStack.at(-1) === true) { - this.scriptEvaluationError('OP_ELSE may only be used once for each OP_IF or OP_NOTIF after Genesis.') + this.scriptEvaluationError( + 'OP_ELSE may only be used once for each OP_IF or OP_NOTIF after Genesis.' + ) } this.elseStack[this.elseStack.length - 1] = true this.ifStack[this.ifStack.length - 1] = this.ifStack.at(-1) !== true break case OP.OP_ENDIF: - if (this.ifStack.length === 0) this.scriptEvaluationError('OP_ENDIF requires a preceeding OP_IF.') + if (this.ifStack.length === 0) + this.scriptEvaluationError('OP_ENDIF requires a preceeding OP_IF.') this.ifStack.pop() this.elseStack.pop() break case OP.OP_VERIFY: - if (this.stack.length < 1) this.scriptEvaluationError('OP_VERIFY requires at least one item to be on the stack.') + if (this.stack.length < 1) + this.scriptEvaluationError('OP_VERIFY requires at least one item to be on the stack.') buf1 = this.stackTop() fValue = this.castToBool(buf1) - if (!fValue) this.scriptEvaluationError('OP_VERIFY requires the top stack value to be truthy.') + if (!fValue) + this.scriptEvaluationError('OP_VERIFY requires the top stack value to be truthy.') this.popStack() break case OP.OP_RETURN: @@ -932,63 +1013,92 @@ export default class Spend { if (this.ifStack.length > 0) { this.returningFromConditional = true } else { - if (this.context === 'UnlockingScript') this.programCounter = this.unlockingScript.chunks.length + if (this.context === 'UnlockingScript') + this.programCounter = this.unlockingScript.chunks.length else this.programCounter = this.lockingScript.chunks.length this.programCounter-- // To counteract the final increment and ensure loop termination } break case OP.OP_TOALTSTACK: - if (this.stack.length < 1) this.scriptEvaluationError('OP_TOALTSTACK requires at oeast one item to be on the stack.') + if (this.stack.length < 1) + this.scriptEvaluationError( + 'OP_TOALTSTACK requires at oeast one item to be on the stack.' + ) this.pushAltStack(this.popStack()) break case OP.OP_FROMALTSTACK: - if (this.altStack.length < 1) this.scriptEvaluationError('OP_FROMALTSTACK requires at least one item to be on the stack.') // "stack" here means altstack + if (this.altStack.length < 1) + this.scriptEvaluationError( + 'OP_FROMALTSTACK requires at least one item to be on the stack.' + ) // "stack" here means altstack this.pushStack(this.popAltStack()) break case OP.OP_2DROP: - if (this.stack.length < 2) this.scriptEvaluationError('OP_2DROP requires at least two items to be on the stack.') - this.popStack(); this.popStack() + if (this.stack.length < 2) + this.scriptEvaluationError('OP_2DROP requires at least two items to be on the stack.') + this.popStack() + this.popStack() break case OP.OP_2DUP: - if (this.stack.length < 2) this.scriptEvaluationError('OP_2DUP requires at least two items to be on the stack.') + if (this.stack.length < 2) + this.scriptEvaluationError('OP_2DUP requires at least two items to be on the stack.') buf1 = this.stackTop(-2) buf2 = this.stackTop(-1) - this.pushStackCopy(buf1); this.pushStackCopy(buf2) + this.pushStackCopy(buf1) + this.pushStackCopy(buf2) break case OP.OP_3DUP: - if (this.stack.length < 3) this.scriptEvaluationError('OP_3DUP requires at least three items to be on the stack.') + if (this.stack.length < 3) + this.scriptEvaluationError('OP_3DUP requires at least three items to be on the stack.') buf1 = this.stackTop(-3) buf2 = this.stackTop(-2) buf3 = this.stackTop(-1) - this.pushStackCopy(buf1); this.pushStackCopy(buf2); this.pushStackCopy(buf3) + this.pushStackCopy(buf1) + this.pushStackCopy(buf2) + this.pushStackCopy(buf3) break case OP.OP_2OVER: - if (this.stack.length < 4) this.scriptEvaluationError('OP_2OVER requires at least four items to be on the stack.') + if (this.stack.length < 4) + this.scriptEvaluationError('OP_2OVER requires at least four items to be on the stack.') buf1 = this.stackTop(-4) buf2 = this.stackTop(-3) - this.pushStackCopy(buf1); this.pushStackCopy(buf2) + this.pushStackCopy(buf1) + this.pushStackCopy(buf2) break case OP.OP_2ROT: { - if (this.stack.length < 6) this.scriptEvaluationError('OP_2ROT requires at least six items to be on the stack.') - const rot6 = this.popStack(); const rot5 = this.popStack() - const rot4 = this.popStack(); const rot3 = this.popStack() - const rot2 = this.popStack(); const rot1 = this.popStack() - this.pushStack(rot3); this.pushStack(rot4) - this.pushStack(rot5); this.pushStack(rot6) - this.pushStack(rot1); this.pushStack(rot2) + if (this.stack.length < 6) + this.scriptEvaluationError('OP_2ROT requires at least six items to be on the stack.') + const rot6 = this.popStack() + const rot5 = this.popStack() + const rot4 = this.popStack() + const rot3 = this.popStack() + const rot2 = this.popStack() + const rot1 = this.popStack() + this.pushStack(rot3) + this.pushStack(rot4) + this.pushStack(rot5) + this.pushStack(rot6) + this.pushStack(rot1) + this.pushStack(rot2) break } case OP.OP_2SWAP: { - if (this.stack.length < 4) this.scriptEvaluationError('OP_2SWAP requires at least four items to be on the stack.') - const swap4 = this.popStack(); const swap3 = this.popStack() - const swap2 = this.popStack(); const swap1 = this.popStack() - this.pushStack(swap3); this.pushStack(swap4) - this.pushStack(swap1); this.pushStack(swap2) + if (this.stack.length < 4) + this.scriptEvaluationError('OP_2SWAP requires at least four items to be on the stack.') + const swap4 = this.popStack() + const swap3 = this.popStack() + const swap2 = this.popStack() + const swap1 = this.popStack() + this.pushStack(swap3) + this.pushStack(swap4) + this.pushStack(swap1) + this.pushStack(swap2) break } case OP.OP_IFDUP: - if (this.stack.length < 1) this.scriptEvaluationError('OP_IFDUP requires at least one item to be on the stack.') + if (this.stack.length < 1) + this.scriptEvaluationError('OP_IFDUP requires at least one item to be on the stack.') buf1 = this.stackTop() if (this.castToBool(buf1)) { this.pushStackCopy(buf1) @@ -998,30 +1108,39 @@ export default class Spend { this.pushStack(new BigNumber(this.stack.length).toScriptNum()) break case OP.OP_DROP: - if (this.stack.length < 1) this.scriptEvaluationError('OP_DROP requires at least one item to be on the stack.') + if (this.stack.length < 1) + this.scriptEvaluationError('OP_DROP requires at least one item to be on the stack.') this.popStack() break case OP.OP_DUP: - if (this.stack.length < 1) this.scriptEvaluationError('OP_DUP requires at least one item to be on the stack.') + if (this.stack.length < 1) + this.scriptEvaluationError('OP_DUP requires at least one item to be on the stack.') this.pushStackCopy(this.stackTop()) break case OP.OP_NIP: - if (this.stack.length < 2) this.scriptEvaluationError('OP_NIP requires at least two items to be on the stack.') + if (this.stack.length < 2) + this.scriptEvaluationError('OP_NIP requires at least two items to be on the stack.') buf2 = this.popStack() this.popStack() this.pushStack(buf2) break case OP.OP_OVER: - if (this.stack.length < 2) this.scriptEvaluationError('OP_OVER requires at least two items to be on the stack.') + if (this.stack.length < 2) + this.scriptEvaluationError('OP_OVER requires at least two items to be on the stack.') this.pushStackCopy(this.stackTop(-2)) break case OP.OP_PICK: case OP.OP_ROLL: { - if (this.stack.length < 2) this.scriptEvaluationError(`${OP[currentOpcode] as string} requires at least two items to be on the stack.`) + if (this.stack.length < 2) + this.scriptEvaluationError( + `${OP[currentOpcode] as string} requires at least two items to be on the stack.` + ) bn = this.readScriptNumber(this.popStack()) const nBigInt = bn.toBigInt() if (nBigInt < 0n || nBigInt >= BigInt(this.stack.length)) { - this.scriptEvaluationError(`${OP[currentOpcode] as string} requires the top stack element to be 0 or a positive number less than the current size of the stack.`) + this.scriptEvaluationError( + `${OP[currentOpcode] as string} requires the top stack element to be 0 or a positive number less than the current size of the stack.` + ) } const nIndex = Number(nBigInt) const itemToMoveOrCopy = this.stack[this.stack.length - 1 - nIndex] @@ -1029,26 +1148,33 @@ export default class Spend { this.stack.splice(this.stack.length - 1 - nIndex, 1) this.stackMem -= itemToMoveOrCopy.length this.pushStack(itemToMoveOrCopy) - } else { // OP_PICK + } else { + // OP_PICK this.pushStackCopy(itemToMoveOrCopy) } break } case OP.OP_ROT: - if (this.stack.length < 3) this.scriptEvaluationError('OP_ROT requires at least three items to be on the stack.') + if (this.stack.length < 3) + this.scriptEvaluationError('OP_ROT requires at least three items to be on the stack.') x3 = this.popStack() x2 = this.popStack() x1 = this.popStack() - this.pushStack(x2); this.pushStack(x3); this.pushStack(x1) + this.pushStack(x2) + this.pushStack(x3) + this.pushStack(x1) break case OP.OP_SWAP: - if (this.stack.length < 2) this.scriptEvaluationError('OP_SWAP requires at least two items to be on the stack.') + if (this.stack.length < 2) + this.scriptEvaluationError('OP_SWAP requires at least two items to be on the stack.') x2 = this.popStack() x1 = this.popStack() - this.pushStack(x2); this.pushStack(x1) + this.pushStack(x2) + this.pushStack(x1) break case OP.OP_TUCK: - if (this.stack.length < 2) this.scriptEvaluationError('OP_TUCK requires at least two items to be on the stack.') + if (this.stack.length < 2) + this.scriptEvaluationError('OP_TUCK requires at least two items to be on the stack.') buf1 = this.stackTop(-1) // Top element (x2) // stack is [... rest, x1, x2] // We want [... rest, x2_copy, x1, x2] @@ -1057,19 +1183,26 @@ export default class Spend { this.stackMem += buf1.length // Account for the new copy break case OP.OP_SIZE: - if (this.stack.length < 1) this.scriptEvaluationError('OP_SIZE requires at least one item to be on the stack.') + if (this.stack.length < 1) + this.scriptEvaluationError('OP_SIZE requires at least one item to be on the stack.') this.pushStack(new BigNumber(this.stackTop().length).toScriptNum()) break case OP.OP_AND: case OP.OP_OR: case OP.OP_XOR: { - if (this.stack.length < 2) this.scriptEvaluationError(`${OP[currentOpcode] as string} requires at least two items on the stack.`) + if (this.stack.length < 2) + this.scriptEvaluationError( + `${OP[currentOpcode] as string} requires at least two items on the stack.` + ) buf2 = this.popStack() buf1 = this.popStack() - if (buf1.length !== buf2.length) this.scriptEvaluationError(`${OP[currentOpcode] as string} requires the top two stack items to be the same size.`) + if (buf1.length !== buf2.length) + this.scriptEvaluationError( + `${OP[currentOpcode] as string} requires the top two stack items to be the same size.` + ) - const resultBufBitwiseOp = new Array(buf1.length) + const resultBufBitwiseOp = Array.from({ length: buf1.length }, () => 0) for (let k = 0; k < buf1.length; k++) { if (currentOpcode === OP.OP_AND) resultBufBitwiseOp[k] = buf1[k] & buf2[k] else if (currentOpcode === OP.OP_OR) resultBufBitwiseOp[k] = buf1[k] | buf2[k] @@ -1079,22 +1212,29 @@ export default class Spend { break } case OP.OP_INVERT: { - if (this.stack.length < 1) this.scriptEvaluationError('OP_INVERT requires at least one item to be on the stack.') + if (this.stack.length < 1) + this.scriptEvaluationError('OP_INVERT requires at least one item to be on the stack.') buf = this.popStack() - const invertedBufOp = new Array(buf.length) + const invertedBufOp = Array.from({ length: buf.length }, () => 0) for (let k = 0; k < buf.length; k++) { - invertedBufOp[k] = (~buf[k]) & 0xff + invertedBufOp[k] = ~buf[k] & 0xff } this.pushStack(invertedBufOp) break } case OP.OP_LSHIFT: case OP.OP_RSHIFT: { - if (this.stack.length < 2) this.scriptEvaluationError(`${OP[currentOpcode] as string} requires at least two items to be on the stack.`) + if (this.stack.length < 2) + this.scriptEvaluationError( + `${OP[currentOpcode] as string} requires at least two items to be on the stack.` + ) bn2 = this.readScriptNumber(this.popStack()) // n (shift amount) buf1 = this.popStack() // value to shift const shiftBits = bn2.toBigInt() - if (shiftBits < 0n) this.scriptEvaluationError(`${OP[currentOpcode] as string} requires the top item on the stack not to be negative.`) + if (shiftBits < 0n) + this.scriptEvaluationError( + `${OP[currentOpcode] as string} requires the top item on the stack not to be negative.` + ) if (buf1.length === 0) { this.pushStack([]) break @@ -1116,41 +1256,84 @@ export default class Spend { } case OP.OP_EQUAL: case OP.OP_EQUALVERIFY: - if (this.stack.length < 2) this.scriptEvaluationError(`${OP[currentOpcode] as string} requires at least two items to be on the stack.`) + if (this.stack.length < 2) + this.scriptEvaluationError( + `${OP[currentOpcode] as string} requires at least two items to be on the stack.` + ) buf2 = this.popStack() buf1 = this.popStack() fValue = compareNumberArrays(buf1, buf2) this.pushStack(fValue ? [1] : []) if (currentOpcode === OP.OP_EQUALVERIFY) { - if (!fValue) this.scriptEvaluationError('OP_EQUALVERIFY requires the top two stack items to be equal.') + if (!fValue) + this.scriptEvaluationError( + 'OP_EQUALVERIFY requires the top two stack items to be equal.' + ) this.popStack() } break - case OP.OP_1ADD: case OP.OP_1SUB: case OP.OP_2MUL: case OP.OP_2DIV: - case OP.OP_NEGATE: case OP.OP_ABS: - case OP.OP_NOT: case OP.OP_0NOTEQUAL: - if (this.stack.length < 1) this.scriptEvaluationError(`${OP[currentOpcode] as string} requires at least one item to be on the stack.`) + case OP.OP_1ADD: + case OP.OP_1SUB: + case OP.OP_2MUL: + case OP.OP_2DIV: + case OP.OP_NEGATE: + case OP.OP_ABS: + case OP.OP_NOT: + case OP.OP_0NOTEQUAL: + if (this.stack.length < 1) + this.scriptEvaluationError( + `${OP[currentOpcode] as string} requires at least one item to be on the stack.` + ) bn = this.readScriptNumber(this.popStack()) switch (currentOpcode) { - case OP.OP_1ADD: bn = bn.add(new BigNumber(1)); break - case OP.OP_1SUB: bn = bn.sub(new BigNumber(1)); break - case OP.OP_2MUL: bn = bn.mul(new BigNumber(2)); break - case OP.OP_2DIV: bn = bn.div(new BigNumber(2)); break - case OP.OP_NEGATE: bn = bn.neg(); break - case OP.OP_ABS: if (bn.isNeg()) bn = bn.neg(); break - case OP.OP_NOT: bn = new BigNumber(bn.cmpn(0) === 0 ? 1 : 0); break - case OP.OP_0NOTEQUAL: bn = new BigNumber(bn.cmpn(0) === 0 ? 0 : 1); break + case OP.OP_1ADD: + bn = bn.add(new BigNumber(1)) + break + case OP.OP_1SUB: + bn = bn.sub(new BigNumber(1)) + break + case OP.OP_2MUL: + bn = bn.mul(new BigNumber(2)) + break + case OP.OP_2DIV: + bn = bn.div(new BigNumber(2)) + break + case OP.OP_NEGATE: + bn = bn.neg() + break + case OP.OP_ABS: + if (bn.isNeg()) bn = bn.neg() + break + case OP.OP_NOT: + bn = new BigNumber(bn.cmpn(0) === 0 ? 1 : 0) + break + case OP.OP_0NOTEQUAL: + bn = new BigNumber(bn.cmpn(0) === 0 ? 0 : 1) + break } this.pushStack(bn.toScriptNum()) break - case OP.OP_ADD: case OP.OP_SUB: case OP.OP_MUL: case OP.OP_DIV: case OP.OP_MOD: - case OP.OP_BOOLAND: case OP.OP_BOOLOR: - case OP.OP_NUMEQUAL: case OP.OP_NUMEQUALVERIFY: case OP.OP_NUMNOTEQUAL: - case OP.OP_LESSTHAN: case OP.OP_GREATERTHAN: - case OP.OP_LESSTHANOREQUAL: case OP.OP_GREATERTHANOREQUAL: - case OP.OP_MIN: case OP.OP_MAX: { - if (this.stack.length < 2) this.scriptEvaluationError(`${OP[currentOpcode] as string} requires at least two items to be on the stack.`) + case OP.OP_ADD: + case OP.OP_SUB: + case OP.OP_MUL: + case OP.OP_DIV: + case OP.OP_MOD: + case OP.OP_BOOLAND: + case OP.OP_BOOLOR: + case OP.OP_NUMEQUAL: + case OP.OP_NUMEQUALVERIFY: + case OP.OP_NUMNOTEQUAL: + case OP.OP_LESSTHAN: + case OP.OP_GREATERTHAN: + case OP.OP_LESSTHANOREQUAL: + case OP.OP_GREATERTHANOREQUAL: + case OP.OP_MIN: + case OP.OP_MAX: { + if (this.stack.length < 2) + this.scriptEvaluationError( + `${OP[currentOpcode] as string} requires at least two items to be on the stack.` + ) buf2 = this.popStack() buf1 = this.popStack() bn2 = this.readScriptNumber(buf2) @@ -1170,36 +1353,72 @@ export default class Spend { this.ensureStackMem(predictedLen) let resultBnArithmetic: BigNumber = new BigNumber(0) switch (currentOpcode) { - case OP.OP_ADD: resultBnArithmetic = bn1.add(bn2); break - case OP.OP_SUB: resultBnArithmetic = bn1.sub(bn2); break - case OP.OP_MUL: resultBnArithmetic = bn1.mul(bn2); break + case OP.OP_ADD: + resultBnArithmetic = bn1.add(bn2) + break + case OP.OP_SUB: + resultBnArithmetic = bn1.sub(bn2) + break + case OP.OP_MUL: + resultBnArithmetic = bn1.mul(bn2) + break case OP.OP_DIV: if (bn2.cmpn(0) === 0) this.scriptEvaluationError('OP_DIV cannot divide by zero!') - resultBnArithmetic = bn1.div(bn2); break + resultBnArithmetic = bn1.div(bn2) + break case OP.OP_MOD: if (bn2.cmpn(0) === 0) this.scriptEvaluationError('OP_MOD cannot divide by zero!') - resultBnArithmetic = bn1.mod(bn2); break - case OP.OP_BOOLAND: resultBnArithmetic = new BigNumber((bn1.cmpn(0) !== 0 && bn2.cmpn(0) !== 0) ? 1 : 0); break - case OP.OP_BOOLOR: resultBnArithmetic = new BigNumber((bn1.cmpn(0) !== 0 || bn2.cmpn(0) !== 0) ? 1 : 0); break - case OP.OP_NUMEQUAL: resultBnArithmetic = new BigNumber(bn1.cmp(bn2) === 0 ? 1 : 0); break - case OP.OP_NUMEQUALVERIFY: resultBnArithmetic = new BigNumber(bn1.cmp(bn2) === 0 ? 1 : 0); break - case OP.OP_NUMNOTEQUAL: resultBnArithmetic = new BigNumber(bn1.cmp(bn2) === 0 ? 0 : 1); break - case OP.OP_LESSTHAN: resultBnArithmetic = new BigNumber(bn1.cmp(bn2) < 0 ? 1 : 0); break - case OP.OP_GREATERTHAN: resultBnArithmetic = new BigNumber(bn1.cmp(bn2) > 0 ? 1 : 0); break - case OP.OP_LESSTHANOREQUAL: resultBnArithmetic = new BigNumber(bn1.cmp(bn2) <= 0 ? 1 : 0); break - case OP.OP_GREATERTHANOREQUAL: resultBnArithmetic = new BigNumber(bn1.cmp(bn2) >= 0 ? 1 : 0); break - case OP.OP_MIN: resultBnArithmetic = bn1.cmp(bn2) < 0 ? bn1 : bn2; break - case OP.OP_MAX: resultBnArithmetic = bn1.cmp(bn2) > 0 ? bn1 : bn2; break + resultBnArithmetic = bn1.mod(bn2) + break + case OP.OP_BOOLAND: + resultBnArithmetic = new BigNumber(bn1.cmpn(0) !== 0 && bn2.cmpn(0) !== 0 ? 1 : 0) + break + case OP.OP_BOOLOR: + resultBnArithmetic = new BigNumber(bn1.cmpn(0) !== 0 || bn2.cmpn(0) !== 0 ? 1 : 0) + break + case OP.OP_NUMEQUAL: + resultBnArithmetic = new BigNumber(bn1.cmp(bn2) === 0 ? 1 : 0) + break + case OP.OP_NUMEQUALVERIFY: + resultBnArithmetic = new BigNumber(bn1.cmp(bn2) === 0 ? 1 : 0) + break + case OP.OP_NUMNOTEQUAL: + resultBnArithmetic = new BigNumber(bn1.cmp(bn2) === 0 ? 0 : 1) + break + case OP.OP_LESSTHAN: + resultBnArithmetic = new BigNumber(bn1.cmp(bn2) < 0 ? 1 : 0) + break + case OP.OP_GREATERTHAN: + resultBnArithmetic = new BigNumber(bn1.cmp(bn2) > 0 ? 1 : 0) + break + case OP.OP_LESSTHANOREQUAL: + resultBnArithmetic = new BigNumber(bn1.cmp(bn2) <= 0 ? 1 : 0) + break + case OP.OP_GREATERTHANOREQUAL: + resultBnArithmetic = new BigNumber(bn1.cmp(bn2) >= 0 ? 1 : 0) + break + case OP.OP_MIN: + resultBnArithmetic = bn1.cmp(bn2) < 0 ? bn1 : bn2 + break + case OP.OP_MAX: + resultBnArithmetic = bn1.cmp(bn2) > 0 ? bn1 : bn2 + break } this.pushStack(resultBnArithmetic.toScriptNum()) if (currentOpcode === OP.OP_NUMEQUALVERIFY) { - if (!this.castToBool(this.stackTop())) this.scriptEvaluationError('OP_NUMEQUALVERIFY requires the top stack item to be truthy.') + if (!this.castToBool(this.stackTop())) + this.scriptEvaluationError( + 'OP_NUMEQUALVERIFY requires the top stack item to be truthy.' + ) this.popStack() } break } case OP.OP_WITHIN: - if (this.stack.length < 3) this.scriptEvaluationError('OP_WITHIN requires at least three items to be on the stack.') + if (this.stack.length < 3) + this.scriptEvaluationError( + 'OP_WITHIN requires at least three items to be on the stack.' + ) bn3 = this.readScriptNumber(this.popStack()) // max bn2 = this.readScriptNumber(this.popStack()) // min bn1 = this.readScriptNumber(this.popStack()) // x @@ -1207,9 +1426,15 @@ export default class Spend { this.pushStack(fValue ? [1] : []) break - case OP.OP_RIPEMD160: case OP.OP_SHA1: case OP.OP_SHA256: - case OP.OP_HASH160: case OP.OP_HASH256: { - if (this.stack.length < 1) this.scriptEvaluationError(`${OP[currentOpcode] as string} requires at least one item to be on the stack.`) + case OP.OP_RIPEMD160: + case OP.OP_SHA1: + case OP.OP_SHA256: + case OP.OP_HASH160: + case OP.OP_HASH256: { + if (this.stack.length < 1) + this.scriptEvaluationError( + `${OP[currentOpcode] as string} requires at least one item to be on the stack.` + ) buf = this.popStack() let hashResult: number[] = [] // Initialize to empty, to satisfy TS compiler if (currentOpcode === OP.OP_RIPEMD160) hashResult = Hash.ripemd160(buf) @@ -1225,13 +1450,18 @@ export default class Spend { break case OP.OP_CHECKSIG: case OP.OP_CHECKSIGVERIFY: { - if (this.stack.length < 2) this.scriptEvaluationError(`${OP[currentOpcode] as string} requires at least two items to be on the stack.`) + if (this.stack.length < 2) + this.scriptEvaluationError( + `${OP[currentOpcode] as string} requires at least two items to be on the stack.` + ) bufPubkey = this.popStack() bufSig = this.popStack() if (!this.checkSignatureEncoding(bufSig) || !this.checkPublicKeyEncoding(bufPubkey)) { // Error already thrown by helpers - this.scriptEvaluationError(`${OP[currentOpcode] as string} requires correct encoding for the public key and signature.`) // Fallback, should be unreachable + this.scriptEvaluationError( + `${OP[currentOpcode] as string} requires correct encoding for the public key and signature.` + ) // Fallback, should be unreachable } fSuccess = false @@ -1239,8 +1469,11 @@ export default class Spend { try { sig = this.parseChecksigSignature(bufSig) - const scriptForChecksig: Script = this.context === 'UnlockingScript' ? this.unlockingScript : this.lockingScript - let scriptCodeChunks = scriptForChecksig.chunks.slice(this.lastCodeSeparator === null ? 0 : this.lastCodeSeparator + 1) + const scriptForChecksig: Script = + this.context === 'UnlockingScript' ? this.unlockingScript : this.lockingScript + let scriptCodeChunks = scriptForChecksig.chunks.slice( + this.lastCodeSeparator === null ? 0 : this.lastCodeSeparator + 1 + ) // When an OP_CODESEPARATOR appears in the unlocking script, the CHECKSIG subscript // continues across the unlock/lock boundary into the full locking script (legacy // combined-script semantics; matches BSV node consensus). Without this, signatures @@ -1259,11 +1492,16 @@ export default class Spend { } if (!fSuccess && this.hasFlag('NULLFAIL') && bufSig.length > 0) { - this.scriptEvaluationError(`${OP[currentOpcode] as string} requires failing signatures to be empty.`) + this.scriptEvaluationError( + `${OP[currentOpcode] as string} requires failing signatures to be empty.` + ) } this.pushStack(fSuccess ? [1] : []) if (currentOpcode === OP.OP_CHECKSIGVERIFY) { - if (!fSuccess) this.scriptEvaluationError('OP_CHECKSIGVERIFY requires that a valid signature is provided.') + if (!fSuccess) + this.scriptEvaluationError( + 'OP_CHECKSIGVERIFY requires that a valid signature is provided.' + ) this.popStack() } break @@ -1272,16 +1510,21 @@ export default class Spend { case OP.OP_CHECKMULTISIGVERIFY: { i = 1 if (this.stack.length < i) { - this.scriptEvaluationError(`${OP[currentOpcode] as string} requires at least 1 item for nKeys.`) + this.scriptEvaluationError( + `${OP[currentOpcode] as string} requires at least 1 item for nKeys.` + ) } const nKeysCountBN = this.readScriptNumber(this.stackTop(-i)) const nKeysCountBigInt = nKeysCountBN.toBigInt() - const multisigKeyLimitBigInt = this.hasExplicitFlags() && !this.isAfterGenesis() - ? BigInt(maxMultisigKeyCountBeforeGenesis) - : maxMultisigKeyCountBigInt + const multisigKeyLimitBigInt = + this.hasExplicitFlags() && !this.isAfterGenesis() + ? BigInt(maxMultisigKeyCountBeforeGenesis) + : maxMultisigKeyCountBigInt if (nKeysCountBigInt < 0n || nKeysCountBigInt > multisigKeyLimitBigInt) { - this.scriptEvaluationError(`${OP[currentOpcode] as string} requires a key count between 0 and ${multisigKeyLimitBigInt.toString()}.`) + this.scriptEvaluationError( + `${OP[currentOpcode] as string} requires a key count between 0 and ${multisigKeyLimitBigInt.toString()}.` + ) } nKeysCount = Number(nKeysCountBigInt) const declaredKeyCount = nKeysCount @@ -1289,24 +1532,33 @@ export default class Spend { i += nKeysCount if (this.stack.length < i) { - this.scriptEvaluationError(`${OP[currentOpcode] as string} stack too small for nKeys and keys. Need ${i}, have ${this.stack.length}.`) + this.scriptEvaluationError( + `${OP[currentOpcode] as string} stack too small for nKeys and keys. Need ${i}, have ${this.stack.length}.` + ) } const nSigsCountBN = this.readScriptNumber(this.stackTop(-i)) const nSigsCountBigInt = nSigsCountBN.toBigInt() if (nSigsCountBigInt < 0n || nSigsCountBigInt > BigInt(nKeysCount)) { - this.scriptEvaluationError(`${OP[currentOpcode] as string} requires the number of signatures to be no greater than the number of keys.`) + this.scriptEvaluationError( + `${OP[currentOpcode] as string} requires the number of signatures to be no greater than the number of keys.` + ) } nSigsCount = Number(nSigsCountBigInt) const declaredSigCount = nSigsCount isig = ++i i += nSigsCount if (this.stack.length < i) { - this.scriptEvaluationError(`${OP[currentOpcode] as string} stack too small for N, keys, M, sigs, and dummy. Need ${i}, have ${this.stack.length}.`) + this.scriptEvaluationError( + `${OP[currentOpcode] as string} stack too small for N, keys, M, sigs, and dummy. Need ${i}, have ${this.stack.length}.` + ) } - const baseScriptCMS = this.context === 'UnlockingScript' ? this.unlockingScript : this.lockingScript - const subscriptChunksCMS = baseScriptCMS.chunks.slice(this.lastCodeSeparator === null ? 0 : this.lastCodeSeparator + 1) + const baseScriptCMS = + this.context === 'UnlockingScript' ? this.unlockingScript : this.lockingScript + const subscriptChunksCMS = baseScriptCMS.chunks.slice( + this.lastCodeSeparator === null ? 0 : this.lastCodeSeparator + 1 + ) subscript = new Script(subscriptChunksCMS) let hasNonEmptySignature = false @@ -1318,7 +1570,8 @@ export default class Spend { fSuccess = true while (fSuccess && nSigsCount > 0) { - if (nKeysCount === 0) { // No more keys to check against but still sigs left + if (nKeysCount === 0) { + // No more keys to check against but still sigs left fSuccess = false break } @@ -1326,7 +1579,9 @@ export default class Spend { bufPubkey = this.stackTop(-ikey) if (!this.checkSignatureEncoding(bufSig) || !this.checkPublicKeyEncoding(bufPubkey)) { - this.scriptEvaluationError(`${OP[currentOpcode] as string} requires correct encoding for the public key and signature.`) + this.scriptEvaluationError( + `${OP[currentOpcode] as string} requires correct encoding for the public key and signature.` + ) } fOk = false @@ -1341,9 +1596,11 @@ export default class Spend { } if (fOk) { - isig++; nSigsCount-- + isig++ + nSigsCount-- } - ikey++; nKeysCount-- + ikey++ + nKeysCount-- if (nSigsCount > nKeysCount) { fSuccess = false @@ -1351,15 +1608,18 @@ export default class Spend { } if (!fSuccess && this.hasFlag('NULLFAIL') && hasNonEmptySignature) { - this.scriptEvaluationError(`${OP[currentOpcode] as string} requires failing signatures to be empty.`) + this.scriptEvaluationError( + `${OP[currentOpcode] as string} requires failing signatures to be empty.` + ) } // Correct total items consumed by op (N_val, keys, M_val, sigs, dummy) - const itemsConsumedByOp = 1 + // N_val - declaredKeyCount + // keys - 1 + // M_val - declaredSigCount + // sigs - 1 // dummy + const itemsConsumedByOp = + 1 + // N_val + declaredKeyCount + // keys + 1 + // M_val + declaredSigCount + // sigs + 1 // dummy let popCount = itemsConsumedByOp - 1 // Pop all except dummy while (popCount > 0) { @@ -1369,38 +1629,53 @@ export default class Spend { // Check and pop dummy if (this.stack.length < 1) { - this.scriptEvaluationError(`${OP[currentOpcode] as string} requires an extra item (dummy) to be on the stack.`) + this.scriptEvaluationError( + `${OP[currentOpcode] as string} requires an extra item (dummy) to be on the stack.` + ) } const dummyBuf = this.popStack() - if (this.shouldEnforceNullDummy() && dummyBuf.length > 0) { // SCRIPT_VERIFY_NULLDUMMY - this.scriptEvaluationError(`${OP[currentOpcode] as string} requires the extra stack item (dummy) to be empty.`) + if (this.shouldEnforceNullDummy() && dummyBuf.length > 0) { + // SCRIPT_VERIFY_NULLDUMMY + this.scriptEvaluationError( + `${OP[currentOpcode] as string} requires the extra stack item (dummy) to be empty.` + ) } this.pushStack(fSuccess ? [1] : []) if (currentOpcode === OP.OP_CHECKMULTISIGVERIFY) { - if (!fSuccess) this.scriptEvaluationError('OP_CHECKMULTISIGVERIFY requires that a sufficient number of valid signatures are provided.') + if (!fSuccess) + this.scriptEvaluationError( + 'OP_CHECKMULTISIGVERIFY requires that a sufficient number of valid signatures are provided.' + ) this.popStack() } break } case OP.OP_CAT: { - if (this.stack.length < 2) this.scriptEvaluationError('OP_CAT requires at least two items to be on the stack.') + if (this.stack.length < 2) + this.scriptEvaluationError('OP_CAT requires at least two items to be on the stack.') buf2 = this.popStack() buf1 = this.popStack() - const catResult = (buf1).concat(buf2) - if (catResult.length > this.maxPushSize()) this.scriptEvaluationError(`It's not currently possible to push data larger than ${this.maxPushSize()} bytes.`) + const catResult = buf1.concat(buf2) + if (catResult.length > this.maxPushSize()) + this.scriptEvaluationError( + `It's not currently possible to push data larger than ${this.maxPushSize()} bytes.` + ) this.pushStack(catResult) break } case OP.OP_SPLIT: { - if (this.stack.length < 2) this.scriptEvaluationError('OP_SPLIT requires at least two items to be on the stack.') + if (this.stack.length < 2) + this.scriptEvaluationError('OP_SPLIT requires at least two items to be on the stack.') const posBuf = this.popStack() const dataToSplit = this.popStack() const splitIndexBigInt = this.readScriptNumber(posBuf).toBigInt() if (splitIndexBigInt < 0n || splitIndexBigInt > BigInt(dataToSplit.length)) { - this.scriptEvaluationError('OP_SPLIT requires the first stack item to be a non-negative number less than or equal to the size of the second-from-top stack item.') + this.scriptEvaluationError( + 'OP_SPLIT requires the first stack item to be a non-negative number less than or equal to the size of the second-from-top stack item.' + ) } const splitIndex = Number(splitIndexBigInt) @@ -1409,22 +1684,22 @@ export default class Spend { break } case OP.OP_NUM2BIN: { - if (this.stack.length < 2) this.scriptEvaluationError('OP_NUM2BIN requires at least two items to be on the stack.') + if (this.stack.length < 2) + this.scriptEvaluationError('OP_NUM2BIN requires at least two items to be on the stack.') const sizeBigInt = this.readScriptNumber(this.popStack()).toBigInt() const maxPushSize = this.maxPushSize() if ( (Number.isFinite(maxPushSize) && sizeBigInt > BigInt(maxPushSize)) || sizeBigInt < 0n - ) { // size can be 0 - this.scriptEvaluationError(`It's not currently possible to push data larger than ${maxPushSize} bytes or negative size.`) + ) { + // size can be 0 + this.scriptEvaluationError( + `It's not currently possible to push data larger than ${maxPushSize} bytes or negative size.` + ) } if (sizeBigInt > maxJavaScriptArrayLength) { - throw new ScriptResourceLimitError( - 'element-size', - maxJavaScriptArrayLength, - sizeBigInt - ) + throw new ScriptResourceLimitError('element-size', maxJavaScriptArrayLength, sizeBigInt) } size = Number(sizeBigInt) @@ -1432,7 +1707,9 @@ export default class Spend { rawnum = minimallyEncode(rawnum) // Get its minimal scriptnum form if (rawnum.length > size) { - this.scriptEvaluationError('OP_NUM2BIN requires that the size expressed in the top stack item is large enough to hold the value expressed in the second-from-top stack item.') + this.scriptEvaluationError( + 'OP_NUM2BIN requires that the size expressed in the top stack item is large enough to hold the value expressed in the second-from-top stack item.' + ) } if (rawnum.length === size) { @@ -1440,7 +1717,7 @@ export default class Spend { break } - const resultN2B = new Array(size).fill(0x00) + const resultN2B = Array.from({ length: size }, () => 0x00) let signbit = 0x00 if (rawnum.length > 0) { @@ -1461,7 +1738,8 @@ export default class Spend { break } case OP.OP_BIN2NUM: { - if (this.stack.length < 1) this.scriptEvaluationError('OP_BIN2NUM requires at least one item to be on the stack.') + if (this.stack.length < 1) + this.scriptEvaluationError('OP_BIN2NUM requires at least one item to be on the stack.') buf1 = this.popStack() const b2nResult = minimallyEncode(buf1) if (!isMinimallyEncodedHelper(b2nResult)) { @@ -1504,24 +1782,21 @@ export default class Spend { * spend.validate() * console.log("Spend is valid!") */ - validate (context?: SpendVerificationContext): boolean { + validate(context?: SpendVerificationContext): boolean { const verifier = scriptVerificationBackend() if ( verifier?.verifySpendSync !== undefined && (verifier.isReady?.() ?? true) && - ( - context === undefined - ? verifier.shouldVerifySpend?.(this) - : verifier.shouldVerifySpend?.(this, context) - ) !== false + (context === undefined + ? verifier.shouldVerifySpend?.(this) + : verifier.shouldVerifySpend?.(this, context)) !== false ) { - const valid = context === undefined - ? verifier.verifySpendSync(this) - : verifier.verifySpendSync(this, context) + const valid = + context === undefined + ? verifier.verifySpendSync(this) + : verifier.verifySpendSync(this, context) if (!valid) { - this.scriptEvaluationError( - 'The selected script-verification backend rejected the spend.' - ) + this.scriptEvaluationError('The selected script-verification backend rejected the spend.') } return true } @@ -1532,7 +1807,7 @@ export default class Spend { * Runs the original TypeScript interpreter explicitly, bypassing any * registered optional backend. */ - validateJavaScript (): boolean { + validateJavaScript(): boolean { this.reset() if (this.shouldEnforceSigPushOnly() && !this.unlockingScript.isPushOnly()) { this.scriptEvaluationError( @@ -1542,9 +1817,7 @@ export default class Spend { const originalLockingScript = this.lockingScript const shouldEvaluateP2SH = - this.hasFlag('P2SH') && - !this.isAfterGenesis() && - this.isP2SHLockingScript(this.lockingScript) + this.hasFlag('P2SH') && !this.isAfterGenesis() && this.isP2SHLockingScript(this.lockingScript) if (shouldEvaluateP2SH && !this.unlockingScript.isPushOnly()) { this.scriptEvaluationError('P2SH unlocking scripts can only contain push operations.') @@ -1594,13 +1867,14 @@ export default class Spend { * @param context - Optional explicit consensus or policy context. Transaction * version is never used as a substitute for this context. */ - async validateWith ( + async validateWith( verifier: SpendVerifierInterface, context?: SpendVerificationContext ): Promise { - const shouldVerify = context === undefined - ? verifier.shouldVerifySpend?.(this) - : verifier.shouldVerifySpend?.(this, context) + const shouldVerify = + context === undefined + ? verifier.shouldVerifySpend?.(this) + : verifier.shouldVerifySpend?.(this, context) if (shouldVerify === false) { return this.validateJavaScript() } @@ -1614,7 +1888,7 @@ export default class Spend { * output is intentionally excluded and is supplied separately to a Spend * verifier, avoiding an EF construction and parse for one-input validation. */ - toTransactionUint8Array (): Uint8Array { + toTransactionUint8Array(): Uint8Array { const currentInput: TransactionInput = { sourceTXID: this.sourceTXID, sourceOutputIndex: this.sourceOutputIndex, @@ -1636,8 +1910,10 @@ export default class Spend { for (let index = 0; index < inputs.length; index++) { const input = index === this.inputIndex ? currentInput : inputs[index] const sourceTXID = input.sourceTXID ?? input.sourceTransaction?.id('hex') - if (sourceTXID === undefined) throw new Error(`Input ${index} is missing its source transaction ID`) - if (input.unlockingScript === undefined) throw new Error(`Input ${index} is missing its unlocking script`) + if (sourceTXID === undefined) + throw new Error(`Input ${index} is missing its source transaction ID`) + if (input.unlockingScript === undefined) + throw new Error(`Input ${index} is missing its unlocking script`) writer.writeReverse(toArray(sourceTXID, 'hex')) writer.writeUInt32LE(input.sourceOutputIndex) const unlockingScript = input.unlockingScript.toUint8Array() @@ -1656,7 +1932,7 @@ export default class Spend { return writer.toUint8Array() } - private runScript (context: 'UnlockingScript' | 'LockingScript'): void { + private runScript(context: 'UnlockingScript' | 'LockingScript'): void { this.context = context this.programCounter = 0 this.ifStack = [] @@ -1686,29 +1962,29 @@ export default class Spend { this.lastCodeSeparator = null } - private isP2SHLockingScript (script: LockingScript): boolean { + private isP2SHLockingScript(script: LockingScript): boolean { const chunks = script.chunks - return chunks.length === 3 && + return ( + chunks.length === 3 && chunks[0].op === OP.OP_HASH160 && chunks[1].op === 20 && Array.isArray(chunks[1].data) && chunks[1].data.length === 20 && chunks[2].op === OP.OP_EQUAL + ) } - private requireTruthyTopStack (): void { + private requireTruthyTopStack(): void { if (this.stack.length === 0) { this.scriptEvaluationError( 'The top stack element must be truthy after script evaluation (stack is empty).' ) } else if (!this.castToBool(this.stackTop())) { - this.scriptEvaluationError( - 'The top stack element must be truthy after script evaluation.' - ) + this.scriptEvaluationError('The top stack element must be truthy after script evaluation.') } } - private castToBool (val: Readonly): boolean { + private castToBool(val: Readonly): boolean { if (val.length === 0) return false for (let i = 0; i < val.length; i++) { if (val[i] !== 0) { @@ -1718,7 +1994,7 @@ export default class Spend { return false } - private scriptEvaluationError (str: string): void { + private scriptEvaluationError(str: string): void { throw new ScriptEvaluationError({ message: str, txid: this.sourceTXID, diff --git a/packages/sdk/src/transaction/MerklePath.ts b/packages/sdk/src/transaction/MerklePath.ts index c1adcae00..b22479971 100644 --- a/packages/sdk/src/transaction/MerklePath.ts +++ b/packages/sdk/src/transaction/MerklePath.ts @@ -1,4 +1,3 @@ - import { Reader, Writer, toHex, toArray, WriterUint8Array } from '../primitives/utils.js' import { hash256 } from '../primitives/Hash.js' import ChainTracker from './ChainTracker.js' @@ -35,12 +34,12 @@ export interface MerklePathLeaf { export default class MerklePath { blockHeight: number path: Array< - Array<{ - offset: number - hash?: string - txid?: boolean - duplicate?: boolean - }> + Array<{ + offset: number + hash?: string + txid?: boolean + duplicate?: boolean + }> > /** @@ -50,18 +49,20 @@ export default class MerklePath { * @param {string} hex - The hexadecimal string representation of the Merkle Path. * @returns {MerklePath} - A new MerklePath instance. */ - static fromHex (hex: string): MerklePath { + static fromHex(hex: string): MerklePath { return MerklePath.fromBinary(toArray(hex, 'hex')) } - static fromReader ( + static fromReader( reader: Reader | ReaderUint8Array, legalOffsetsOnly: boolean = true ): MerklePath { const blockHeight = reader.readVarIntNum() const treeHeight = reader.readUInt8() // Explicitly define the type of path as an array of arrays of leaf objects - const path: Array> = new Array(treeHeight) + const path: Array< + Array<{ offset: number; hash?: string; txid?: boolean; duplicate?: boolean }> + > = Array.from({ length: treeHeight }) .fill(null) .map(() => []) let flags: number, offset: number, nLeavesAtThisHeight: number @@ -104,7 +105,7 @@ export default class MerklePath { * @param {number[]} bump - The binary array representation of the Merkle Path. * @returns {MerklePath} - A new MerklePath instance. */ - static fromBinary (bump: number[] | Uint8Array): MerklePath { + static fromBinary(bump: number[] | Uint8Array): MerklePath { const reader = new ReaderUint8Array(bump) return MerklePath.fromReader(reader) } @@ -120,19 +121,19 @@ export default class MerklePath { * @param {number} height - The height of the block. * @returns {MerklePath} - A new MerklePath instance which assumes the tx is in a block with no other transactions. */ - static fromCoinbaseTxidAndHeight (txid: string, height: number): MerklePath { + static fromCoinbaseTxidAndHeight(txid: string, height: number): MerklePath { return new MerklePath(height, [[{ offset: 0, hash: txid, txid: true }]]) } - constructor ( + constructor( blockHeight: number, path: Array< - Array<{ - offset: number - hash?: string - txid?: boolean - duplicate?: boolean - }> + Array<{ + offset: number + hash?: string + txid?: boolean + duplicate?: boolean + }> >, legalOffsetsOnly: boolean = true ) { @@ -140,7 +141,7 @@ export default class MerklePath { this.path = path // store all of the legal offsets which we expect given the txid indices. - const legalOffsets = new Array(this.path.length) + const legalOffsets = Array.from({ length: this.path.length }) .fill(0) .map(() => new Set()) this.path.forEach((leaves, height) => { @@ -148,11 +149,9 @@ export default class MerklePath { throw new Error(`Empty level at height: ${height}`) } const offsetsAtThisHeight = new Set() - leaves.forEach((leaf) => { + leaves.forEach(leaf => { if (offsetsAtThisHeight.has(leaf.offset)) { - throw new Error( - `Duplicate offset: ${leaf.offset}, at height: ${height}` - ) + throw new Error(`Duplicate offset: ${leaf.offset}, at height: ${height}`) } offsetsAtThisHeight.add(leaf.offset) if (height === 0) { @@ -184,7 +183,7 @@ export default class MerklePath { * * @param writer - The writer to which the Merkle Path will be serialized. */ - toWriter (writer: Writer | WriterUint8Array): void { + toWriter(writer: Writer | WriterUint8Array): void { writer.writeVarIntNum(this.blockHeight) const treeHeight = this.path.length writer.writeUInt8(treeHeight) @@ -213,7 +212,7 @@ export default class MerklePath { * * @returns {number[]} - The binary array representation of the Merkle Path. */ - toBinary (): number[] { + toBinary(): number[] { const writer = new Writer() this.toWriter(writer) return writer.toArray() @@ -224,7 +223,7 @@ export default class MerklePath { * * @returns {Uint8Array} - The binary array representation of the Merkle Path. */ - toBinaryUint8Array (): Uint8Array { + toBinaryUint8Array(): Uint8Array { const writer = new WriterUint8Array() this.toWriter(writer) return writer.toUint8Array() @@ -235,13 +234,13 @@ export default class MerklePath { * * @returns {string} - The hexadecimal string representation of the Merkle Path. */ - toHex (): string { + toHex(): string { return toHex(this.toBinaryUint8Array()) } // - private indexOf (txid: string): number { - const leaf = this.path[0].find((l) => l.hash === txid) + private indexOf(txid: string): number { + const leaf = this.path[0].find(l => l.hash === txid) if (leaf === null || leaf === undefined) { throw new Error(`Transaction ID ${txid} not found in the Merkle Path`) } @@ -255,9 +254,9 @@ export default class MerklePath { * @returns {string} - The computed Merkle root as a hexadecimal string. * @throws {Error} - If the transaction ID is not part of the Merkle Path. */ - computeRoot (txid?: string): string { + computeRoot(txid?: string): string { if (typeof txid !== 'string') { - const foundLeaf = this.path[0].find((leaf) => Boolean(leaf?.hash)) + const foundLeaf = this.path[0].find(leaf => Boolean(leaf?.hash)) if (foundLeaf == null) { throw new Error('No valid leaf found in the Merkle Path') } @@ -269,8 +268,7 @@ export default class MerklePath { } const index = this.indexOf(txid) // Calculate the root using the index as a way to determine which direction to concatenate. - const hash = (m: string): string => - toHex(hash256(toArray(m, 'hex').reverse()).reverse()) + const hash = (m: string): string => toHex(hash256(toArray(m, 'hex').reverse()).reverse()) let workingHash = txid // special case for blocks with only one transaction @@ -288,7 +286,7 @@ export default class MerklePath { if (typeof leaf !== 'object') { // For single-level paths (all txids at level 0), the sibling may be beyond the tree // because this is the last odd node at this height. Bitcoin Merkle duplicates it. - if (this.path.length === 1 && (index >> height) === (maxOffset >> height)) { + if (this.path.length === 1 && index >> height === maxOffset >> height) { workingHash = hash((workingHash ?? '') + (workingHash ?? '')) continue } @@ -312,16 +310,11 @@ export default class MerklePath { * @param height * @param offset */ - findOrComputeLeaf ( - height: number, - offset: number - ): MerklePathLeaf | undefined { - const hash = (m: string): string => - toHex(hash256(toArray(m, 'hex').reverse()).reverse()) + findOrComputeLeaf(height: number, offset: number): MerklePathLeaf | undefined { + const hash = (m: string): string => toHex(hash256(toArray(m, 'hex').reverse()).reverse()) - let leaf: MerklePathLeaf | undefined = height < this.path.length - ? this.path[height].find((l) => l.offset === offset) - : undefined + let leaf: MerklePathLeaf | undefined = + height < this.path.length ? this.path[height].find(l => l.offset === offset) : undefined if (leaf != null) return leaf @@ -342,7 +335,7 @@ export default class MerklePath { // For single-level paths, leaf0 may be the last odd node at height h — duplicate it. if (this.path.length === 1) { const maxOffset0 = this.path[0].reduce((max, lf) => Math.max(max, lf.offset), 0) - if (l === (maxOffset0 >> h)) { + if (l === maxOffset0 >> h) { return { offset, hash: hash(leaf0.hash + leaf0.hash) } } } @@ -370,7 +363,7 @@ export default class MerklePath { * @param {ChainTracker} chainTracker - The ChainTracker instance used to verify the Merkle root. * @returns {boolean} - True if the transaction ID is valid within the Merkle Path at the specified block height. */ - async verify (txid: string, chainTracker: ChainTracker): Promise { + async verify(txid: string, chainTracker: ChainTracker): Promise { const root = this.computeRoot(txid) if (this.indexOf(txid) === 0) { // Coinbase transaction outputs can only be spent once they're 100 blocks deep. @@ -389,29 +382,25 @@ export default class MerklePath { * @param {MerklePath} other - Another MerklePath to combine with this path. * @throws {Error} - If the paths have different block heights or roots. */ - combine (other: MerklePath): void { + combine(other: MerklePath): void { if (this.blockHeight !== other.blockHeight) { - throw new Error( - 'You cannot combine paths which do not have the same block height.' - ) + throw new Error('You cannot combine paths which do not have the same block height.') } const root1 = this.computeRoot() const root2 = other.computeRoot() if (root1 !== root2) { - throw new Error( - 'You cannot combine paths which do not have the same root.' - ) + throw new Error('You cannot combine paths which do not have the same root.') } - const combinedPath: Array> = [] + const combinedPath: Array< + Array<{ offset: number; hash?: string; txid?: boolean; duplicate?: boolean }> + > = [] for (let h = 0; h < this.path.length; h++) { combinedPath.push([]) for (const leaf of this.path[h]) { combinedPath[h].push(leaf) } for (const otherLeaf of other.path[h]) { - const existingLeaf = combinedPath[h].find( - (leaf) => leaf.offset === otherLeaf.offset - ) + const existingLeaf = combinedPath[h].find(leaf => leaf.offset === otherLeaf.offset) if (existingLeaf === undefined) { combinedPath[h].push(otherLeaf) } else if (otherLeaf?.txid !== undefined && otherLeaf?.txid !== null) { @@ -429,7 +418,7 @@ export default class MerklePath { * Assumes that at least all required nodes are present. * Leaves all levels sorted by increasing offset. */ - trim (): void { + trim(): void { const pushIfNew = (v: number, a: number[]): void => { if (a.length === 0 || a.at(-1) !== v) { a.push(v) @@ -438,9 +427,7 @@ export default class MerklePath { const dropOffsetsFromLevel = (dropOffsets: number[], level: number): void => { for (let i = dropOffsets.length; i >= 0; i--) { - const l = this.path[level].findIndex( - (n) => n.offset === dropOffsets[i] - ) + const l = this.path[level].findIndex(n => n.offset === dropOffsets[i]) if (l >= 0) { this.path[level].splice(l, 1) } @@ -487,7 +474,7 @@ export default class MerklePath { * Cached leaf finder for extract(). Uses Map-based indexes for O(1) lookups * and caches computed intermediate hashes to avoid redundant work. */ - private cachedFindLeaf ( + private cachedFindLeaf( height: number, offset: number, sourceIndex: Array>, @@ -497,12 +484,10 @@ export default class MerklePath { const key = `${height}:${offset}` if (hashCache.has(key)) return hashCache.get(key) - const doHash = (m: string): string => - toHex(hash256(toArray(m, 'hex').reverse()).reverse()) + const doHash = (m: string): string => toHex(hash256(toArray(m, 'hex').reverse()).reverse()) - let leaf: MerklePathLeaf | undefined = height < sourceIndex.length - ? sourceIndex[height].get(offset) - : undefined + let leaf: MerklePathLeaf | undefined = + height < sourceIndex.length ? sourceIndex[height].get(offset) : undefined if (leaf != null) { hashCache.set(key, leaf) @@ -524,7 +509,7 @@ export default class MerklePath { const leaf1 = this.cachedFindLeaf(h, l + 1, sourceIndex, hashCache, maxOffset) if (leaf1?.hash == null) { - if (leaf1?.duplicate === true || (this.path.length === 1 && l === (maxOffset >> h))) { + if (leaf1?.duplicate === true || (this.path.length === 1 && l === maxOffset >> h)) { leaf = { offset, hash: doHash(leaf0.hash + leaf0.hash) } hashCache.set(key, leaf) return leaf @@ -533,9 +518,10 @@ export default class MerklePath { return undefined } - const workinghash = leaf1.duplicate === true - ? doHash(leaf0.hash + leaf0.hash) - : doHash((leaf1.hash ?? '') + (leaf0.hash ?? '')) + const workinghash = + leaf1.duplicate === true + ? doHash(leaf0.hash + leaf0.hash) + : doHash((leaf1.hash ?? '') + (leaf0.hash ?? '')) leaf = { offset, hash: workinghash } hashCache.set(key, leaf) return leaf @@ -562,7 +548,7 @@ export default class MerklePath { * const twoTxProof = fullBlock.extract([txid1, txid2]) * twoTxProof.computeRoot(txid1) // === fullBlock.computeRoot() */ - extract (txids: string[]): MerklePath { + extract(txids: string[]): MerklePath { if (txids.length === 0) { throw new Error('At least one txid must be provided to extract') } @@ -572,7 +558,7 @@ export default class MerklePath { const treeHeight = Math.max(this.path.length, 32 - Math.clz32(maxOffset)) // Build O(1) lookup indexes for the source path - const sourceIndex: Array> = new Array(this.path.length) + const sourceIndex: Array> = Array.from({ length: this.path.length }) for (let h = 0; h < this.path.length; h++) { const map = new Map() for (const leaf of this.path[h]) map.set(leaf.offset, leaf) @@ -588,7 +574,7 @@ export default class MerklePath { } // Collect all needed leaves per level - const neededPerLevel: Array> = new Array(treeHeight) + const neededPerLevel: Array> = Array.from({ length: treeHeight }) for (let h = 0; h < treeHeight; h++) neededPerLevel[h] = new Map() for (const txid of txids) { @@ -612,17 +598,18 @@ export default class MerklePath { const sib = this.cachedFindLeaf(h, sibOffset, sourceIndex, hashCache, maxOffset) if (sib != null) { neededPerLevel[h].set(sibOffset, sib) - } else if ((txOffset >> h) === (maxOffset >> h)) { + } else if (txOffset >> h === maxOffset >> h) { neededPerLevel[h].set(sibOffset, { offset: sibOffset, duplicate: true }) } } } // Build sorted compound path - const compoundPath: Array> = new Array(treeHeight) + const compoundPath: Array< + Array<{ offset: number; hash?: string; txid?: boolean; duplicate?: boolean }> + > = Array.from({ length: treeHeight }) for (let h = 0; h < treeHeight; h++) { - compoundPath[h] = Array.from(neededPerLevel[h].values()) - .sort((a, b) => a.offset - b.offset) + compoundPath[h] = Array.from(neededPerLevel[h].values()).sort((a, b) => a.offset - b.offset) } const compound = new MerklePath(this.blockHeight, compoundPath) diff --git a/packages/sdk/src/transaction/Transaction.ts b/packages/sdk/src/transaction/Transaction.ts index 4a7848194..1ac5f06e3 100644 --- a/packages/sdk/src/transaction/Transaction.ts +++ b/packages/sdk/src/transaction/Transaction.ts @@ -3,7 +3,15 @@ import TransactionInput from './TransactionInput.js' import TransactionOutput from './TransactionOutput.js' import UnlockingScript from '../script/UnlockingScript.js' import LockingScript from '../script/LockingScript.js' -import { Reader, Writer, toHex, toArray, ReaderUint8Array, toUint8Array, WriterUint8Array } from '../primitives/utils.js' +import { + Reader, + Writer, + toHex, + toArray, + ReaderUint8Array, + toUint8Array, + WriterUint8Array +} from '../primitives/utils.js' import { hash256 } from '../primitives/Hash.js' import FeeModel from './FeeModel.js' import LivePolicy from './fee-models/LivePolicy.js' @@ -15,8 +23,14 @@ import { defaultBroadcaster } from './broadcasters/DefaultBroadcaster.js' import { defaultChainTracker } from './chaintrackers/DefaultChainTracker.js' import { Beef, BEEF_V1 } from './Beef.js' import P2PKH from '../script/templates/P2PKH.js' -import type { WalletInterface, DescriptionString5to50Bytes, CreateActionOptions } from '../wallet/Wallet.interfaces.js' -import TransactionSignature, { type SignatureHashCache } from '../primitives/TransactionSignature.js' +import type { + WalletInterface, + DescriptionString5to50Bytes, + CreateActionOptions +} from '../wallet/Wallet.interfaces.js' +import TransactionSignature, { + type SignatureHashCache +} from '../primitives/TransactionSignature.js' import Random from '../primitives/Random.js' import type BdkVerifierInterface from './BdkVerifierInterface.js' import { scriptVerificationBackend } from './ScriptVerificationBackend.js' @@ -102,7 +116,7 @@ export default class Transaction { * * @internal */ - getSignatureHashCache (): SignatureHashCache { + getSignatureHashCache(): SignatureHashCache { return this.activeSignatureHashCache ?? { hashOutputsSingle: new Map() } } @@ -110,10 +124,10 @@ export default class Transaction { * Iteratively materializes source transaction IDs so deep spend chains do not * recurse through `hash()` while serializing their parents. */ - materializeSourceTXIDs (): void { + materializeSourceTXIDs(): void { const complete = new Set() const visiting = new Set() - const stack: Array<{ tx: Transaction, expanded: boolean }> = [{ tx: this, expanded: false }] + const stack: Array<{ tx: Transaction; expanded: boolean }> = [{ tx: this, expanded: false }] while (stack.length > 0) { const frame = stack.pop() @@ -138,7 +152,11 @@ export default class Transaction { stack.push({ tx: frame.tx, expanded: true }) for (let i = frame.tx.inputs.length - 1; i >= 0; i--) { const input = frame.tx.inputs[i] - if (input.sourceTXID == null && input.sourceTransaction != null && !complete.has(input.sourceTransaction)) { + if ( + input.sourceTXID == null && + input.sourceTransaction != null && + !complete.has(input.sourceTransaction) + ) { stack.push({ tx: input.sourceTransaction, expanded: false }) } } @@ -154,7 +172,7 @@ export default class Transaction { * @param txid Optional TXID of the transaction to retrieve from the BEEF data. * @returns An anchored transaction, linked to its associated inputs populated with merkle paths. */ - static fromBEEF (beef: number[] | Uint8Array, txid?: string): Transaction { + static fromBEEF(beef: number[] | Uint8Array, txid?: string): Transaction { const { tx } = Transaction.fromAnyBeef(beef, txid) return tx } @@ -162,7 +180,7 @@ export default class Transaction { /** * Zero-copy variant of {@link fromBEEF}. The caller must not mutate `beef`. */ - static fromBEEFView (beef: Uint8Array, txid?: string): Transaction { + static fromBEEFView(beef: Uint8Array, txid?: string): Transaction { const { tx } = Transaction.fromAnyBeef(beef, txid, true) return tx } @@ -174,7 +192,7 @@ export default class Transaction { * @param beef A binary representation of an Atomic BEEF structure. * @returns The subject transaction, linked to its associated inputs populated with merkle paths. */ - static fromAtomicBEEF (beef: number[] | Uint8Array): Transaction { + static fromAtomicBEEF(beef: number[] | Uint8Array): Transaction { const { tx, txid, beef: b } = Transaction.fromAnyBeef(beef) if (txid !== b.atomicTxid) { if (b.atomicTxid == null) { @@ -191,18 +209,24 @@ export default class Transaction { * Zero-copy variant of {@link fromAtomicBEEF}. The caller must not mutate * `beef` while any linked transaction remains in use. */ - static fromAtomicBEEFView (beef: Uint8Array): Transaction { + static fromAtomicBEEFView(beef: Uint8Array): Transaction { const { tx, txid, beef: b } = Transaction.fromAnyBeef(beef, undefined, true) if (txid !== b.atomicTxid) { - if (b.atomicTxid == null) throw new Error('beef must conform to BRC-95 and must contain the subject txid.') + if (b.atomicTxid == null) + throw new Error('beef must conform to BRC-95 and must contain the subject txid.') throw new Error(`Transaction with TXID ${b.atomicTxid} not found in BEEF data.`) } if (!b.isAtomic(txid)) throw new Error('Atomic BEEF contains unrelated transaction data.') return tx } - private static fromAnyBeef (beef: number[] | Uint8Array, txid?: string, zeroCopy: boolean = false): { tx: Transaction, beef: Beef, txid: string } { - const b = zeroCopy && beef instanceof Uint8Array ? Beef.fromBinaryView(beef) : Beef.fromBinary(beef) + private static fromAnyBeef( + beef: number[] | Uint8Array, + txid?: string, + zeroCopy: boolean = false + ): { tx: Transaction; beef: Beef; txid: string } { + const b = + zeroCopy && beef instanceof Uint8Array ? Beef.fromBinaryView(beef) : Beef.fromBinary(beef) if (b.txs.length < 1) { throw new Error('beef must include at least one transaction.') } @@ -227,10 +251,12 @@ export default class Transaction { * @param ef A binary representation of a transaction in EF format. * @returns An extended transaction, linked to its associated inputs by locking script and satoshis amounts only. */ - static fromEF (ef: number[] | Uint8Array): Transaction { + static fromEF(ef: number[] | Uint8Array): Transaction { const br = ReaderUint8Array.makeReader(ef) const version = br.readUInt32LE() - if (toHex(br.read(6)) !== '0000000000ef') { throw new Error('Invalid EF marker') } + if (toHex(br.read(6)) !== '0000000000ef') { + throw new Error('Invalid EF marker') + } const inputsLength = br.readVarIntNum() const inputs: TransactionInput[] = [] for (let i = 0; i < inputsLength; i++) { @@ -245,7 +271,7 @@ export default class Transaction { const lockingScriptBin = br.read(lockingScriptLength) const lockingScript = LockingScript.fromBinary(lockingScriptBin) const sourceTransaction = new Transaction(undefined, [], [], undefined) - sourceTransaction.outputs = new Array(sourceOutputIndex + 1).fill(null) + sourceTransaction.outputs = Array.from({ length: sourceOutputIndex + 1 }).fill(null) sourceTransaction.outputs[sourceOutputIndex] = { satoshis, lockingScript @@ -289,13 +315,13 @@ export default class Transaction { * outputs: { vout: number, offset: number, length: number }[] * } */ - static parseScriptOffsets (bin: number[] | Uint8Array): { - inputs: Array<{ vin: number, offset: number, length: number }> - outputs: Array<{ vout: number, offset: number, length: number }> + static parseScriptOffsets(bin: number[] | Uint8Array): { + inputs: Array<{ vin: number; offset: number; length: number }> + outputs: Array<{ vout: number; offset: number; length: number }> } { const br = ReaderUint8Array.makeReader(bin) - const inputs: Array<{ vin: number, offset: number, length: number }> = [] - const outputs: Array<{ vout: number, offset: number, length: number }> = [] + const inputs: Array<{ vin: number; offset: number; length: number }> = [] + const outputs: Array<{ vout: number; offset: number; length: number }> = [] br.pos += 4 // version const inputsLength = br.readVarIntNum() @@ -315,11 +341,14 @@ export default class Transaction { return { inputs, outputs } } - static fromReader (br: Reader | ReaderUint8Array): Transaction { + static fromReader(br: Reader | ReaderUint8Array): Transaction { return Transaction.fromReaderInternal(br, false) } - private static fromReaderInternal (br: Reader | ReaderUint8Array, zeroCopyScripts: boolean): Transaction { + private static fromReaderInternal( + br: Reader | ReaderUint8Array, + zeroCopyScripts: boolean + ): Transaction { const version = br.readUInt32LE() const inputsLength = br.readVarIntNum() const inputs: TransactionInput[] = [] @@ -327,12 +356,14 @@ export default class Transaction { const sourceTXID = toHex(br.readReverse(32)) const sourceOutputIndex = br.readUInt32LE() const scriptLength = br.readVarIntNum() - const scriptBin = zeroCopyScripts && br instanceof ReaderUint8Array - ? br.readView(scriptLength) - : br.read(scriptLength) - const unlockingScript = zeroCopyScripts && scriptBin instanceof Uint8Array - ? UnlockingScript.fromBinaryView(scriptBin) - : UnlockingScript.fromBinary(scriptBin) + const scriptBin = + zeroCopyScripts && br instanceof ReaderUint8Array + ? br.readView(scriptLength) + : br.read(scriptLength) + const unlockingScript = + zeroCopyScripts && scriptBin instanceof Uint8Array + ? UnlockingScript.fromBinaryView(scriptBin) + : UnlockingScript.fromBinary(scriptBin) const sequence = br.readUInt32LE() inputs.push({ sourceTXID, @@ -346,12 +377,14 @@ export default class Transaction { for (let i = 0; i < outputsLength; i++) { const satoshis = br.readUInt64LEBn().toNumber() const scriptLength = br.readVarIntNum() - const scriptBin = zeroCopyScripts && br instanceof ReaderUint8Array - ? br.readView(scriptLength) - : br.read(scriptLength) - const lockingScript = zeroCopyScripts && scriptBin instanceof Uint8Array - ? LockingScript.fromBinaryView(scriptBin) - : LockingScript.fromBinary(scriptBin) + const scriptBin = + zeroCopyScripts && br instanceof ReaderUint8Array + ? br.readView(scriptLength) + : br.read(scriptLength) + const lockingScript = + zeroCopyScripts && scriptBin instanceof Uint8Array + ? LockingScript.fromBinaryView(scriptBin) + : LockingScript.fromBinary(scriptBin) outputs.push({ satoshis, lockingScript @@ -368,7 +401,7 @@ export default class Transaction { * @param {number[]} bin - The binary array representation of the transaction. * @returns {Transaction} - A new Transaction instance. */ - static fromBinary (bin: number[] | Uint8Array): Transaction { + static fromBinary(bin: number[] | Uint8Array): Transaction { const rawBytes = Uint8Array.from(bin) const br = new ReaderUint8Array(rawBytes) const tx = Transaction.fromReaderInternal(br, true) @@ -381,7 +414,7 @@ export default class Transaction { * Parses a transaction while retaining zero-copy views over `bin` for the raw * transaction and its scripts. The caller must not mutate `bin`. */ - static fromBinaryView (bin: Uint8Array): Transaction { + static fromBinaryView(bin: Uint8Array): Transaction { const br = new ReaderUint8Array(bin) const tx = Transaction.fromReaderInternal(br, true) if (!br.eof()) throw new Error('Serialized transaction contains trailing data') @@ -397,7 +430,7 @@ export default class Transaction { * @param {string} hex - The hexadecimal string representation of the transaction. * @returns {Transaction} - A new Transaction instance. */ - static fromHex (hex: string): Transaction { + static fromHex(hex: string): Transaction { const rawBytes = toUint8Array(hex, 'hex') const br = new ReaderUint8Array(rawBytes) const tx = Transaction.fromReaderInternal(br, true) @@ -414,7 +447,7 @@ export default class Transaction { * @param {string} hex - The hexadecimal string representation of the transaction EF. * @returns {Transaction} - A new Transaction instance. */ - static fromHexEF (hex: string): Transaction { + static fromHexEF(hex: string): Transaction { return Transaction.fromEF(toUint8Array(hex, 'hex')) } @@ -429,11 +462,11 @@ export default class Transaction { * @param {string} [txid] - Optional TXID of the transaction to retrieve from the BEEF data. * @returns {Transaction} - A new Transaction instance. */ - static fromHexBEEF (hex: string, txid?: string): Transaction { + static fromHexBEEF(hex: string, txid?: string): Transaction { return Transaction.fromBEEF(toArray(hex, 'hex'), txid) } - constructor ( + constructor( version: number = 1, inputs: TransactionInput[] = [], outputs: TransactionOutput[] = [], @@ -449,7 +482,7 @@ export default class Transaction { this.merklePath = merklePath } - private invalidateSerializationCaches (): void { + private invalidateSerializationCaches(): void { this.cachedHash = undefined this.cachedIdHex = undefined this.rawBytesCache = undefined @@ -458,11 +491,11 @@ export default class Transaction { this.rawCacheState = undefined } - private sourceTransactionId (input: TransactionInput): string | undefined { + private sourceTransactionId(input: TransactionInput): string | undefined { return input.sourceTXID == null ? input.sourceTransaction?.id('hex') : undefined } - private captureSerializationState (): void { + private captureSerializationState(): void { this.rawCacheState = { version: this.version, lockTime: this.lockTime, @@ -491,7 +524,7 @@ export default class Transaction { } } - private serializationCacheMatchesState (): boolean { + private serializationCacheMatchesState(): boolean { const cached = this.rawCacheState if ( cached == null || @@ -499,7 +532,8 @@ export default class Transaction { cached.lockTime !== this.lockTime || cached.inputs.length !== this.inputs.length || cached.outputs.length !== this.outputs.length - ) return false + ) + return false for (let i = 0; i < this.inputs.length; i++) { const input = this.inputs[i] @@ -517,7 +551,8 @@ export default class Transaction { state.sourceSatoshis !== sourceOutput?.satoshis || state.sourceLockingScript !== sourceOutput?.lockingScript || state.sourceLockingScriptBytes !== sourceOutput?.lockingScript.toUint8Array() - ) return false + ) + return false } for (let i = 0; i < this.outputs.length; i++) { @@ -528,7 +563,8 @@ export default class Transaction { state.satoshis !== output.satoshis || state.lockingScript !== output.lockingScript || state.lockingScriptBytes !== output.lockingScript.toUint8Array() - ) return false + ) + return false } return true } @@ -539,11 +575,8 @@ export default class Transaction { * @param {TransactionInput} input - The TransactionInput object to add to the transaction. * @throws {Error} - If the input does not have a sourceTXID or sourceTransaction defined. */ - addInput (input: TransactionInput): void { - if ( - input.sourceTXID === undefined && - input.sourceTransaction === undefined - ) { + addInput(input: TransactionInput): void { + if (input.sourceTXID === undefined && input.sourceTransaction === undefined) { throw new TypeError( 'A reference to an an input transaction is required. If the input transaction itself cannot be referenced, its TXID must still be provided.' ) @@ -559,15 +592,15 @@ export default class Transaction { * * @param {TransactionOutput} output - The TransactionOutput object to add to the transaction. */ - addOutput (output: TransactionOutput): void { + addOutput(output: TransactionOutput): void { this.invalidateSerializationCaches() if (output.change !== true) { if (output.satoshis === undefined) { - throw new TypeError( - 'either satoshis must be defined or change must be set to true' - ) + throw new TypeError('either satoshis must be defined or change must be set to true') + } + if (output.satoshis < 0) { + throw new Error('satoshis must be a positive integer or zero') } - if (output.satoshis < 0) { throw new Error('satoshis must be a positive integer or zero') } } if (output.lockingScript == null) throw new Error('lockingScript must be defined') this.outputs.push(output) @@ -580,7 +613,7 @@ export default class Transaction { * @param {number} [satoshis] - The number of satoshis to send to the address - if not provided, the output is considered a change output. * */ - addP2PKHOutput (address: number[] | string, satoshis?: number): void { + addP2PKHOutput(address: number[] | string, satoshis?: number): void { const lockingScript = new P2PKH().lock(address) if (satoshis === undefined) { return this.addOutput({ lockingScript, change: true }) @@ -596,7 +629,7 @@ export default class Transaction { * * @param {Record} metadata - The metadata object to merge into the existing metadata. */ - updateMetadata (metadata: Record): void { + updateMetadata(metadata: Record): void { this.metadata = { ...this.metadata, ...metadata @@ -613,7 +646,7 @@ export default class Transaction { * amongst the change outputs * */ - async fee ( + async fee( modelOrFee: FeeModel | number = LivePolicy.getInstance(), changeDistribution: 'equal' | 'random' = 'equal' ): Promise { @@ -627,13 +660,13 @@ export default class Transaction { const fee = await modelOrFee.computeFee(this) const change = this.calculateChange(fee) if (change <= 0) { - this.outputs = this.outputs.filter((output) => output.change !== true) + this.outputs = this.outputs.filter(output => output.change !== true) return } this.distributeChange(change, changeDistribution) } - private calculateChange (fee: number): number { + private calculateChange(fee: number): number { let change = 0 for (const input of this.inputs) { if (typeof input.sourceTransaction !== 'object') { @@ -641,8 +674,7 @@ export default class Transaction { 'Source transactions are required for all inputs during fee computation' ) } - change += - input.sourceTransaction.outputs[input.sourceOutputIndex].satoshis ?? 0 + change += input.sourceTransaction.outputs[input.sourceOutputIndex].satoshis ?? 0 } change -= fee for (const out of this.outputs) { @@ -655,12 +687,9 @@ export default class Transaction { return change } - private distributeChange ( - change: number, - changeDistribution: 'equal' | 'random' - ): void { + private distributeChange(change: number, changeDistribution: 'equal' | 'random'): void { let distributedChange = 0 - const changeOutputs = this.outputs.filter((out) => out.change) + const changeOutputs = this.outputs.filter(out => out.change) if (changeDistribution === 'random') { distributedChange = this.distributeRandomChange(change, changeOutputs) } else if (changeDistribution === 'equal') { @@ -676,13 +705,10 @@ export default class Transaction { } } - private distributeRandomChange ( - change: number, - changeOutputs: TransactionOutput[] - ): number { + private distributeRandomChange(change: number, changeOutputs: TransactionOutput[]): number { let distributedChange = 0 let changeToUse = change - const benfordNumbers = new Array(changeOutputs.length).fill(1) + const benfordNumbers = Array.from({ length: changeOutputs.length }).fill(1) changeToUse -= changeOutputs.length distributedChange += changeOutputs.length for (let i = 0; i < changeOutputs.length - 1; i++) { @@ -697,10 +723,7 @@ export default class Transaction { return distributedChange } - private distributeEqualChange ( - change: number, - changeOutputs: TransactionOutput[] - ): number { + private distributeEqualChange(change: number, changeOutputs: TransactionOutput[]): number { let distributedChange = 0 const perOutput = Math.floor(change / changeOutputs.length) for (const out of changeOutputs) { @@ -710,11 +733,9 @@ export default class Transaction { return distributedChange } - private benfordNumber (min: number, max: number): number { - const d = Random(1)[0] % 9 + 1 - return Math.floor( - min + ((max - min) * Math.log10(1 + 1 / d)) / Math.log10(10) - ) + private benfordNumber(min: number, max: number): number { + const d = (Random(1)[0] % 9) + 1 + return Math.floor(min + ((max - min) * Math.log10(1 + 1 / d)) / Math.log10(10)) } /** @@ -722,7 +743,7 @@ export default class Transaction { * * @returns The current transaction fee */ - getFee (): number { + getFee(): number { let totalIn = 0 for (const input of this.inputs) { if (typeof input.sourceTransaction !== 'object') { @@ -730,8 +751,7 @@ export default class Transaction { 'Source transactions or sourceSatoshis are required for all inputs to calculate fee' ) } - totalIn += - input.sourceTransaction.outputs[input.sourceOutputIndex].satoshis ?? 0 + totalIn += input.sourceTransaction.outputs[input.sourceOutputIndex].satoshis ?? 0 } let totalOut = 0 for (const output of this.outputs) { @@ -744,7 +764,7 @@ export default class Transaction { * Signs a transaction, hydrating all its unlocking scripts based on the provided script templates where they are available. * @param options - Signing behavior. Set `skipExistingSignatures` to preserve inputs that already have an unlocking script. */ - async sign (options: { skipExistingSignatures?: boolean } = {}): Promise { + async sign(options: { skipExistingSignatures?: boolean } = {}): Promise { this.invalidateSerializationCaches() for (const out of this.outputs) { if (out.satoshis === undefined) { @@ -795,13 +815,13 @@ export default class Transaction { * @param broadcaster The Broadcaster instance wwhere the transaction will be sent * @returns A BroadcastResponse or BroadcastFailure from the Broadcaster */ - async broadcast ( + async broadcast( broadcaster: Broadcaster = defaultBroadcaster() ): Promise { return await broadcaster.broadcast(this) } - private writeTransactionBody (writer: Writer | WriterUint8Array): void { + private writeTransactionBody(writer: Writer | WriterUint8Array): void { writer.writeUInt32LE(this.version) writer.writeVarIntNum(this.inputs.length) for (const i of this.inputs) { @@ -833,13 +853,13 @@ export default class Transaction { writer.writeUInt32LE(this.lockTime) } - private buildSerializedBytes (): Uint8Array { + private buildSerializedBytes(): Uint8Array { const writer = new WriterUint8Array() this.writeTransactionBody(writer) return writer.toUint8Array() } - private getSerializedBytes (): Uint8Array { + private getSerializedBytes(): Uint8Array { if (this.rawBytesCache == null || !this.serializationCacheMatchesState()) { this.invalidateSerializationCaches() this.rawBytesCache = this.buildSerializedBytes() @@ -853,15 +873,15 @@ export default class Transaction { * * @returns {number[]} - The binary array representation of the transaction. */ - toBinary (): number[] { + toBinary(): number[] { return Array.from(this.getSerializedBytes()) } - toUint8Array (): Uint8Array { + toUint8Array(): Uint8Array { return this.getSerializedBytes() } - private writeEF (writer: Writer | WriterUint8Array): void { + private writeEF(writer: Writer | WriterUint8Array): void { writer.writeUInt32LE(this.version) writer.write([0, 0, 0, 0, 0, 0xef]) writer.writeVarIntNum(this.inputs.length) @@ -884,13 +904,9 @@ export default class Transaction { writer.writeVarIntNum(scriptBin.length) writer.write(scriptBin) writer.writeUInt32LE(i.sequence ?? 0xffffffff) // default to max sequence - writer.writeUInt64LE( - i.sourceTransaction.outputs[i.sourceOutputIndex].satoshis ?? 0 - ) + writer.writeUInt64LE(i.sourceTransaction.outputs[i.sourceOutputIndex].satoshis ?? 0) const lockingScriptBin = - i.sourceTransaction.outputs[ - i.sourceOutputIndex - ].lockingScript.toUint8Array() + i.sourceTransaction.outputs[i.sourceOutputIndex].lockingScript.toUint8Array() writer.writeVarIntNum(lockingScriptBin.length) writer.write(lockingScriptBin) } @@ -909,7 +925,7 @@ export default class Transaction { * * @returns {number[]} - The BRC-30 EF representation of the transaction. */ - toEF (): number[] { + toEF(): number[] { return Array.from(this.getEFBytes()) } @@ -921,11 +937,11 @@ export default class Transaction { * * @returns {Uint8Array} - The BRC-30 EF representation of the transaction. */ - toEFUint8Array (): Uint8Array { + toEFUint8Array(): Uint8Array { return this.toEFBinary() } - private getEFBytes (): Uint8Array { + private getEFBytes(): Uint8Array { if (this.efBytesCache == null || !this.serializationCacheMatchesState()) { this.invalidateSerializationCaches() const writer = new WriterUint8Array() @@ -945,7 +961,7 @@ export default class Transaction { * * @returns {Uint8Array} The cached BRC-30 EF representation. */ - toEFBinary (): Uint8Array { + toEFBinary(): Uint8Array { return this.getEFBytes() } @@ -954,7 +970,7 @@ export default class Transaction { * * @returns {string} - The hexadecimal string representation of the transaction EF. */ - toHexEF (): string { + toHexEF(): string { return toHex(this.toEFBinary()) } @@ -963,7 +979,7 @@ export default class Transaction { * * @returns {string} - The hexadecimal string representation of the transaction. */ - toHex (): string { + toHex(): string { const bytes = this.getSerializedBytes() if (this.hexCache != null) return this.hexCache const hex = toHex(bytes) @@ -976,7 +992,7 @@ export default class Transaction { * * @returns {string} - The hexadecimal string representation of the transaction BEEF. */ - toHexBEEF (): string { + toHexBEEF(): string { return toHex(this.toBEEF()) } @@ -985,7 +1001,7 @@ export default class Transaction { * * @returns {string} - The hexadecimal string representation of the transaction Atomic BEEF. */ - toHexAtomicBEEF (): string { + toHexAtomicBEEF(): string { return toHex(this.toAtomicBEEF()) } @@ -995,7 +1011,7 @@ export default class Transaction { * @param {'hex' | undefined} enc - The encoding to use for the hash. If 'hex', returns a hexadecimal string; otherwise returns a binary array. * @returns {string | number[]} - The hash of the transaction in the specified format. */ - hash (enc?: 'hex'): number[] | string { + hash(enc?: 'hex'): number[] | string { const bytes = this.getSerializedBytes() this.cachedHash ??= hash256(bytes) if (enc === 'hex') { @@ -1009,21 +1025,21 @@ export default class Transaction { * * @returns {number[]} - The ID of the transaction in the binary array format. */ - id (): number[] + id(): number[] /** * Calculates the transaction's ID in hexadecimal format. * * @param {'hex'} enc - The encoding to use for the ID. If 'hex', returns a hexadecimal string. * @returns {string} - The ID of the transaction in the hex format. */ - id (enc: 'hex'): string + id(enc: 'hex'): string /** * Calculates the transaction's ID. * * @param {'hex' | undefined} enc - The encoding to use for the ID. If 'hex', returns a hexadecimal string; otherwise returns a binary array. * @returns {string | number[]} - The ID of the transaction in the specified format. */ - id (enc?: 'hex'): number[] | string { + id(enc?: 'hex'): number[] | string { // Validate public mutable transaction state before consulting either ID // cache. getSerializedBytes() clears both when any signed field changed. this.getSerializedBytes() @@ -1051,7 +1067,7 @@ export default class Transaction { * * @example tx.verify(new WhatsOnChain(), LivePolicy.getInstance()) */ - async verify ( + async verify( chainTracker: ChainTracker | 'scripts only' = defaultChainTracker(), feeModel?: FeeModel, memoryLimit?: number, @@ -1126,7 +1142,8 @@ export default class Transaction { consensus: true, ...(memoryLimit === undefined ? {} : { memoryLimit }) } as const - const useVerifier = selectedVerifier !== undefined && + const useVerifier = + selectedVerifier !== undefined && (memoryLimit === undefined || selectedVerifier.supportsMemoryLimit === true) && (selectedVerifier.shouldVerifyScripts?.(verifierParams) ?? true) @@ -1146,16 +1163,19 @@ export default class Transaction { `Verification failed because the input at index ${i} of transaction ${getTxid()} is missing an associated unlocking script. This script is required for transaction verification because there is no merkle proof for the transaction spending the UTXO.` ) } - const sourceOutput = - input.sourceTransaction.outputs[input.sourceOutputIndex] + const sourceOutput = input.sourceTransaction.outputs[input.sourceOutputIndex] inputTotal += sourceOutput.satoshis ?? 0 const sourceTransaction = input.sourceTransaction - const sourceTxid = scriptsOnly && input.sourceTXID !== undefined - ? input.sourceTXID - : sourceTransaction.id('hex') + const sourceTxid = + scriptsOnly && input.sourceTXID !== undefined + ? input.sourceTXID + : sourceTransaction.id('hex') if (scriptsOnly) { - if (!verifiedTransactions.has(sourceTransaction) && !queuedTransactions.has(sourceTransaction)) { + if ( + !verifiedTransactions.has(sourceTransaction) && + !queuedTransactions.has(sourceTransaction) + ) { txQueue.push(sourceTransaction) queuedTransactions.add(sourceTransaction) } @@ -1220,11 +1240,12 @@ export default class Transaction { } if (verifierQueue.length > 0 && selectedVerifier !== undefined) { - const scriptVerdicts = selectedVerifier.verifyScriptsBatch === undefined - ? await Promise.all(verifierQueue.map( - async params => await selectedVerifier.verifyScripts(params) - )) - : await selectedVerifier.verifyScriptsBatch(verifierQueue) + const scriptVerdicts = + selectedVerifier.verifyScriptsBatch === undefined + ? await Promise.all( + verifierQueue.map(async params => await selectedVerifier.verifyScripts(params)) + ) + : await selectedVerifier.verifyScriptsBatch(verifierQueue) if (scriptVerdicts.length !== verifierQueue.length) { throw new Error('Script verifier returned an invalid batch result count') } @@ -1248,13 +1269,13 @@ export default class Transaction { * @returns The serialized BEEF structure * @throws Error if there are any missing sourceTransactions unless `allowPartial` is true. */ - writeSerializedBEEF (writer: Writer | WriterUint8Array, allowPartial?: boolean): void { + writeSerializedBEEF(writer: Writer | WriterUint8Array, allowPartial?: boolean): void { this.materializeSourceTXIDs() writer.writeUInt32LE(BEEF_V1) const BUMPs: MerklePath[] = [] const bumpIndexByInstance = new Map() const bumpIndexByRoot = new Map() - const txs: Array<{ tx: Transaction, pathIndex?: number }> = [] + const txs: Array<{ tx: Transaction; pathIndex?: number }> = [] const seenTxids = new Set() const getBumpIndex = (merklePath: MerklePath): number => { @@ -1279,14 +1300,14 @@ export default class Transaction { } const scheduledTxids = new Set() - const stack: Array<{ tx: Transaction, expanded: boolean }> = [{ tx: this, expanded: false }] + const stack: Array<{ tx: Transaction; expanded: boolean }> = [{ tx: this, expanded: false }] while (stack.length > 0) { const frame = stack.pop() if (frame == null) continue const txid = frame.tx.id('hex') if (frame.expanded) { if (seenTxids.has(txid)) continue - const obj: { tx: Transaction, pathIndex?: number } = { tx: frame.tx } + const obj: { tx: Transaction; pathIndex?: number } = { tx: frame.tx } if (frame.tx.merklePath != null) obj.pathIndex = getBumpIndex(frame.tx.merklePath) seenTxids.add(txid) txs.push(obj) @@ -1299,7 +1320,8 @@ export default class Transaction { for (let i = 0; i < frame.tx.inputs.length; i++) { const source = frame.tx.inputs[i].sourceTransaction if (source != null) stack.push({ tx: source, expanded: false }) - else if (allowPartial === false) throw new Error('A required source transaction is missing!') + else if (allowPartial === false) + throw new Error('A required source transaction is missing!') } } } @@ -1336,7 +1358,7 @@ export default class Transaction { * @returns {number[]} The serialized BEEF structure * @throws Error if there are any missing sourceTransactions unless `allowPartial` is true. */ - toBEEF (allowPartial?: boolean): number[] { + toBEEF(allowPartial?: boolean): number[] { const writer = new Writer() this.writeSerializedBEEF(writer, allowPartial) return writer.toArray() @@ -1352,7 +1374,7 @@ export default class Transaction { * @deprecated This historical method returns a legacy `number[]` at runtime * despite its declared type. Use {@link toBEEFBytes} for a real Uint8Array. */ - toBEEFUint8Array (allowPartial?: boolean): Uint8Array { + toBEEFUint8Array(allowPartial?: boolean): Uint8Array { const writer = new WriterUint8Array() this.writeSerializedBEEF(writer, allowPartial) return writer.toArray() @@ -1364,7 +1386,7 @@ export default class Transaction { * @remarks This replaces the historical `toBEEFUint8Array` method, whose * runtime value is a legacy `number[]` despite its declared return type. */ - toBEEFBytes (allowPartial?: boolean): Uint8Array { + toBEEFBytes(allowPartial?: boolean): Uint8Array { const writer = new WriterUint8Array() this.writeSerializedBEEF(writer, allowPartial) return writer.toUint8Array() @@ -1381,7 +1403,7 @@ export default class Transaction { * @returns {number[]} - The serialized Atomic BEEF structure. * @throws Error if there are any missing sourceTransactions unless `allowPartial` is true. */ - toAtomicBEEF (allowPartial?: boolean): number[] { + toAtomicBEEF(allowPartial?: boolean): number[] { this.materializeSourceTXIDs() const prefix = [1, 1, 1, 1] const txHash = this.hash() as number[] @@ -1400,7 +1422,7 @@ export default class Transaction { * @returns {number[]} - The serialized Atomic BEEF structure. * @throws Error if there are any missing sourceTransactions unless `allowPartial` is true. */ - toAtomicBEEFUint8Array (allowPartial?: boolean): Uint8Array { + toAtomicBEEFUint8Array(allowPartial?: boolean): Uint8Array { this.materializeSourceTXIDs() const writer = new WriterUint8Array() const prefix = [1, 1, 1, 1] @@ -1423,10 +1445,16 @@ export default class Transaction { * @param {CreateActionOptions} [options] - Optional settings for transaction creation (e.g., acceptDelayedBroadcast, trustSelf, noSend, etc.) * @returns {Promise} */ - async completeWithWallet (wallet: WalletInterface, actionDescription?: DescriptionString5to50Bytes, originator?: string, options?: CreateActionOptions): Promise { + async completeWithWallet( + wallet: WalletInterface, + actionDescription?: DescriptionString5to50Bytes, + originator?: string, + options?: CreateActionOptions + ): Promise { const inputCount = this.inputs.length const outputCount = this.outputs.length - const description = actionDescription ?? `Transaction with ${inputCount} input(s) and ${outputCount} output(s)` + const description = + actionDescription ?? `Transaction with ${inputCount} input(s) and ${outputCount} output(s)` const actionArgs: CreateActionArgs = { description, @@ -1469,7 +1497,9 @@ export default class Transaction { // Still provide the script if it exists inputArg.unlockingScript = input.unlockingScript.toHex() } else { - throw new Error(`Input ${i} must have either an unlockingScript or unlockingScriptTemplate`) + throw new Error( + `Input ${i} must have either an unlockingScript or unlockingScriptTemplate` + ) } } else { // Original flow: all inputs must have unlocking scripts @@ -1541,21 +1571,25 @@ export default class Transaction { } // Extract options that apply to signAction (subset of CreateActionOptions) - const signActionOptions: SignActionOptions | undefined = options == null - ? undefined - : { - acceptDelayedBroadcast: options.acceptDelayedBroadcast, - returnTXIDOnly: options.returnTXIDOnly, - noSend: options.noSend, - sendWith: options.sendWith - } + const signActionOptions: SignActionOptions | undefined = + options == null + ? undefined + : { + acceptDelayedBroadcast: options.acceptDelayedBroadcast, + returnTXIDOnly: options.returnTXIDOnly, + noSend: options.noSend, + sendWith: options.sendWith + } // Call signAction with the generated unlocking scripts - const signResult = await wallet.signAction({ - reference: signableTransaction.reference, - spends, - options: signActionOptions - }, originator) + const signResult = await wallet.signAction( + { + reference: signableTransaction.reference, + spends, + options: signActionOptions + }, + originator + ) if (signResult.tx == null) { throw new Error('Wallet signAction did not return transaction data') @@ -1602,7 +1636,7 @@ export default class Transaction { * @param subscript - The subscript to use for the preimage (optional) * @returns The formatted preimage */ - preimage (inputIndex?: number, signatureScope?: number, subscript?: LockingScript): number[] { + preimage(inputIndex?: number, signatureScope?: number, subscript?: LockingScript): number[] { inputIndex ??= 0 signatureScope ??= TransactionSignature.SIGHASH_FORKID | TransactionSignature.SIGHASH_ALL if (inputIndex < 0 || inputIndex >= this.inputs.length) { diff --git a/packages/sdk/src/transaction/http/BinaryFetchClient.ts b/packages/sdk/src/transaction/http/BinaryFetchClient.ts index b9f76bb6c..755dd4b72 100644 --- a/packages/sdk/src/transaction/http/BinaryFetchClient.ts +++ b/packages/sdk/src/transaction/http/BinaryFetchClient.ts @@ -1,8 +1,4 @@ -import { - HttpClient, - HttpClientRequestOptions, - HttpClientResponse -} from './HttpClient.js' +import { HttpClient, HttpClientRequestOptions, HttpClientResponse } from './HttpClient.js' import { HttpsModuleLike, executeNodejsRequest } from './NodejsHttpRequestUtils.js' /** Node Https module interface limited to options needed by ts-sdk */ @@ -27,7 +23,7 @@ export interface BinaryNodejsHttpClientRequest { * Adapter for Node Https module to be used as HttpClient */ export class BinaryNodejsHttpClient implements HttpClient { - constructor(private readonly https: BinaryHttpsNodejs) { } + constructor(private readonly https: BinaryHttpsNodejs) {} async request( url: string, @@ -37,7 +33,7 @@ export class BinaryNodejsHttpClient implements HttpClient { this.https as unknown as HttpsModuleLike, url, requestOptions, - (data) => Buffer.from(data) + data => Buffer.from(data) ) } } @@ -67,12 +63,9 @@ export interface FetchOptions { * Adapter for Node Https module to be used as HttpClient */ export class BinaryFetchClient implements HttpClient { - constructor(private readonly fetch: Fetch) { } + constructor(private readonly fetch: Fetch) {} - async request( - url: string, - options: HttpClientRequestOptions - ): Promise> { + async request(url: string, options: HttpClientRequestOptions): Promise> { const fetchOptions: FetchOptions = { method: options.method, headers: options.headers, @@ -104,17 +97,20 @@ export function binaryHttpClient(): HttpClient { } else if (typeof globalThis.fetch === 'function') { // Service workers, Deno, Node 18+ (any environment with global fetch) return new BinaryFetchClient(globalThis.fetch.bind(globalThis)) - } else if (typeof require === 'undefined') { + } + + const nodeRequire = typeof require === 'function' ? require : undefined + if (nodeRequire === undefined) { + return noHttpClient + } + + // Older Node.js — use https without exposing a static server-only import to + // browser bundlers. + try { + const https = nodeRequire(['node', 'https'].join(':')) + return new BinaryNodejsHttpClient(https) + } catch { + // node:https not available in this runtime; fall through to noHttpClient return noHttpClient - } else { - // Older Node.js — use https module - try { - // eslint-disable-next-line @typescript-eslint/no-var-requires - const https = require('node:https') - return new BinaryNodejsHttpClient(https) - } catch (_httpsModuleUnavailable) { - // node:https not available in this runtime; fall through to noHttpClient - return noHttpClient - } } } diff --git a/packages/sdk/src/transaction/http/DefaultHttpClient.ts b/packages/sdk/src/transaction/http/DefaultHttpClient.ts index a7a70f63b..cc06fd14a 100644 --- a/packages/sdk/src/transaction/http/DefaultHttpClient.ts +++ b/packages/sdk/src/transaction/http/DefaultHttpClient.ts @@ -20,17 +20,20 @@ export function defaultHttpClient(): HttpClient { } else if (typeof globalThis.fetch === 'function') { // Service workers, Deno, Node 18+ (any environment with global fetch) return new FetchHttpClient(globalThis.fetch.bind(globalThis)) - } else if (typeof require === 'undefined') { + } + + const nodeRequire = typeof require === 'function' ? require : undefined + if (nodeRequire === undefined) { + return noHttpClient + } + + // Older Node.js — use https without exposing a static server-only import to + // browser bundlers. + try { + const https = nodeRequire(['node', 'https'].join(':')) + return new NodejsHttpClient(https) + } catch { + // node:https not available in this runtime; fall through to noHttpClient return noHttpClient - } else { - // Older Node.js — use https module - try { - // eslint-disable-next-line @typescript-eslint/no-var-requires - const https = require('node:https') - return new NodejsHttpClient(https) - } catch (_httpsModuleUnavailable) { - // node:https not available in this runtime; fall through to noHttpClient - return noHttpClient - } } } diff --git a/packages/sdk/src/wallet/ProtoWallet.ts b/packages/sdk/src/wallet/ProtoWallet.ts index 64993fb02..15822a493 100644 --- a/packages/sdk/src/wallet/ProtoWallet.ts +++ b/packages/sdk/src/wallet/ProtoWallet.ts @@ -36,14 +36,16 @@ import { } from './Wallet.interfaces.js' import { constantTimeEquals, toArray } from '../primitives/utils.js' -function keyDeriverOrThrow (keyDeriver?: KeyDeriverApi): KeyDeriverApi { - return keyDeriver ?? +function keyDeriverOrThrow(keyDeriver?: KeyDeriverApi): KeyDeriverApi { + return ( + keyDeriver ?? (() => { throw new Error('keyDeriver is undefined') })() + ) } -async function derivePublicKey ( +async function derivePublicKey( keyDeriver: KeyDeriverApi, args: Pick ): Promise { @@ -54,15 +56,16 @@ async function derivePublicKey ( } if (keyDeriver.derivePublicKeyAsync !== undefined) { return await keyDeriver.derivePublicKeyAsync( - protocolID, keyID, args.counterparty ?? 'self', args.forSelf + protocolID, + keyID, + args.counterparty ?? 'self', + args.forSelf ) } - return keyDeriver.derivePublicKey( - protocolID, keyID, args.counterparty ?? 'self', args.forSelf - ) + return keyDeriver.derivePublicKey(protocolID, keyID, args.counterparty ?? 'self', args.forSelf) } -function derivePrivateKey ( +function derivePrivateKey( keyDeriver: KeyDeriverApi, protocolID: Parameters[0], keyID: string, @@ -73,7 +76,7 @@ function derivePrivateKey ( return keyDeriver.derivePrivateKey(protocolID, keyID, counterparty) } -async function deriveSymmetricKey ( +async function deriveSymmetricKey( keyDeriver: KeyDeriverApi, protocolID: Parameters[0], keyID: string, @@ -95,27 +98,21 @@ async function deriveSymmetricKey ( export class ProtoWallet { keyDeriver?: KeyDeriverApi - constructor (rootKeyOrKeyDeriver?: PrivateKey | 'anyone' | KeyDeriverApi) { + constructor(rootKeyOrKeyDeriver?: PrivateKey | 'anyone' | KeyDeriverApi) { if (typeof (rootKeyOrKeyDeriver as KeyDeriver).identityKey !== 'string') { - rootKeyOrKeyDeriver = new CachedKeyDeriver( - rootKeyOrKeyDeriver as PrivateKey | 'anyone' - ) + rootKeyOrKeyDeriver = new CachedKeyDeriver(rootKeyOrKeyDeriver as PrivateKey | 'anyone') } this.keyDeriver = rootKeyOrKeyDeriver as KeyDeriverApi } - async getPublicKey ( - args: GetPublicKeyArgs - ): Promise<{ publicKey: PubKeyHex }> { + async getPublicKey(args: GetPublicKeyArgs): Promise<{ publicKey: PubKeyHex }> { if (args.identityKey) { const rootKey = keyDeriverOrThrow(this.keyDeriver).rootKey const backend = readyAsyncCryptoBackend('publicKeyFromPrivate') if (backend !== undefined) { const publicKey = validateAsyncCryptoBytes( 'publicKeyFromPrivate', - await backend.publicKeyFromPrivate( - Uint8Array.from(rootKey.toArray('be', 32)) - ), + await backend.publicKeyFromPrivate(Uint8Array.from(rootKey.toArray('be', 32))), 33 ) return { @@ -125,9 +122,7 @@ export class ProtoWallet { return { publicKey: rootKey.toPublicKey().toString() } } else { if (args.protocolID == null || args.keyID == null || args.keyID === '') { - throw new Error( - 'protocolID and keyID are required if identityKey is false or undefined.' - ) + throw new Error('protocolID and keyID are required if identityKey is false or undefined.') } return { publicKey: (await derivePublicKey(keyDeriverOrThrow(this.keyDeriver), args)).toString() @@ -135,7 +130,7 @@ export class ProtoWallet { } } - async revealCounterpartyKeyLinkage ( + async revealCounterpartyKeyLinkage( args: RevealCounterpartyKeyLinkageArgs ): Promise { const { publicKey: identityKey } = await this.getPublicKey({ @@ -179,7 +174,7 @@ export class ProtoWallet { } } - async revealSpecificKeyLinkage ( + async revealSpecificKeyLinkage( args: RevealSpecificKeyLinkageArgs ): Promise { const { publicKey: identityKey } = await this.getPublicKey({ @@ -195,19 +190,13 @@ export class ProtoWallet { ) const { ciphertext: encryptedLinkage } = await this.encrypt({ plaintext: linkage, - protocolID: [ - 2, - `specific linkage revelation ${args.protocolID[0]} ${args.protocolID[1]}` - ], + protocolID: [2, `specific linkage revelation ${args.protocolID[0]} ${args.protocolID[1]}`], keyID: args.keyID, counterparty: args.verifier }) const { ciphertext: encryptedLinkageProof } = await this.encrypt({ plaintext: [0], // Proof type 0, no proof provided - protocolID: [ - 2, - `specific linkage revelation ${args.protocolID[0]} ${args.protocolID[1]}` - ], + protocolID: [2, `specific linkage revelation ${args.protocolID[0]} ${args.protocolID[1]}`], keyID: args.keyID, counterparty: args.verifier }) @@ -223,9 +212,7 @@ export class ProtoWallet { } } - async encrypt ( - args: WalletEncryptArgs - ): Promise { + async encrypt(args: WalletEncryptArgs): Promise { const key = await deriveSymmetricKey( keyDeriverOrThrow(this.keyDeriver), args.protocolID, @@ -235,8 +222,7 @@ export class ProtoWallet { return { ciphertext: key.encrypt(args.plaintext) as number[] } } - async decrypt ( - args: WalletDecryptArgs, originator?: string): Promise { + async decrypt(args: WalletDecryptArgs, _originator?: string): Promise { const key = await deriveSymmetricKey( keyDeriverOrThrow(this.keyDeriver), args.protocolID, @@ -246,9 +232,7 @@ export class ProtoWallet { return { plaintext: key.decrypt(args.ciphertext) as number[] } } - async createHmac ( - args: CreateHmacArgs - ): Promise { + async createHmac(args: CreateHmacArgs): Promise { const key = await deriveSymmetricKey( keyDeriverOrThrow(this.keyDeriver), args.protocolID, @@ -258,9 +242,7 @@ export class ProtoWallet { return { hmac: Hash.sha256hmac(key.toArray(), args.data) } } - async verifyHmac ( - args: VerifyHmacArgs - ): Promise { + async verifyHmac(args: VerifyHmacArgs): Promise { const key = await deriveSymmetricKey( keyDeriverOrThrow(this.keyDeriver), args.protocolID, @@ -270,10 +252,7 @@ export class ProtoWallet { const computed = Hash.sha256hmac(key.toArray(), args.data) const provided = args.hmac - const valid = constantTimeEquals( - toArray(computed), - toArray(provided) - ) + const valid = constantTimeEquals(toArray(computed), toArray(provided)) if (!valid) { const e = new Error('HMAC is not valid') as Error & { code: string } e.code = 'ERR_INVALID_HMAC' @@ -282,15 +261,12 @@ export class ProtoWallet { return { valid } } - async createSignature ( - args: CreateSignatureArgs - ): Promise { - if ((args.hashToDirectlySign == null) && (args.data == null)) { + async createSignature(args: CreateSignatureArgs): Promise { + if (args.hashToDirectlySign == null && args.data == null) { throw new Error('args.data or args.hashToDirectlySign must be valid') } - const hash: number[] = - args.hashToDirectlySign ?? Hash.sha256(args.data ?? []) + const hash: number[] = args.hashToDirectlySign ?? Hash.sha256(args.data ?? []) const key = derivePrivateKey( keyDeriverOrThrow(this.keyDeriver), args.protocolID, @@ -298,44 +274,43 @@ export class ProtoWallet { args.counterparty ?? 'anyone' ) - const backend = isAsyncCryptoDigest(hash) - ? readyAsyncCryptoBackend('signDigest') - : undefined - const signature = backend === undefined - ? ECDSA.sign(new BigNumber(hash), key, true) - : Signature.fromDER(Array.from(validateAsyncCryptoBytes( - 'signDigest', - await backend.signDigest( - Uint8Array.from(key.toArray('be', 32)), - Uint8Array.from(hash) - ) - ))) + const backend = isAsyncCryptoDigest(hash) ? readyAsyncCryptoBackend('signDigest') : undefined + const signature = + backend === undefined + ? ECDSA.sign(new BigNumber(hash), key, true) + : Signature.fromDER( + Array.from( + validateAsyncCryptoBytes( + 'signDigest', + await backend.signDigest( + Uint8Array.from(key.toArray('be', 32)), + Uint8Array.from(hash) + ) + ) + ) + ) return { signature: signature.toDER() as number[] } } - async verifySignature ( - args: VerifySignatureArgs - ): Promise { - if ((args.hashToDirectlyVerify == null) && (args.data == null)) { + async verifySignature(args: VerifySignatureArgs): Promise { + if (args.hashToDirectlyVerify == null && args.data == null) { throw new Error('args.data or args.hashToDirectlyVerify must be valid') } - const hash: number[] = - args.hashToDirectlyVerify ?? Hash.sha256(args.data ?? []) + const hash: number[] = args.hashToDirectlyVerify ?? Hash.sha256(args.data ?? []) const key = await derivePublicKey(keyDeriverOrThrow(this.keyDeriver), args) const parsedSignature = Signature.fromDER(args.signature) - const backend = isAsyncCryptoDigest(hash) - ? readyAsyncCryptoBackend('verifyDigest') - : undefined - const valid = backend === undefined - ? ECDSA.verify(new BigNumber(hash), parsedSignature, key) - : await backend.verifyDigest( - Uint8Array.from(key.encode(true) as number[]), - Uint8Array.from(hash), - Uint8Array.from(parsedSignature.toDER() as number[]) - ) + const backend = isAsyncCryptoDigest(hash) ? readyAsyncCryptoBackend('verifyDigest') : undefined + const valid = + backend === undefined + ? ECDSA.verify(new BigNumber(hash), parsedSignature, key) + : await backend.verifyDigest( + Uint8Array.from(key.encode(true) as number[]), + Uint8Array.from(hash), + Uint8Array.from(parsedSignature.toDER() as number[]) + ) if (!valid) { const e = new Error('Signature is not valid') as Error & { code: string } diff --git a/packages/sdk/tsconfig.base.json b/packages/sdk/tsconfig.base.json index 284480e2d..d1703cd04 100644 --- a/packages/sdk/tsconfig.base.json +++ b/packages/sdk/tsconfig.base.json @@ -1,12 +1,7 @@ { "compilerOptions": { - "lib": [ - "dom", - "ESNext" - ], - "types": [ - "node" - ], + "lib": ["dom", "ESNext"], + "types": ["node"], "module": "NodeNext", "target": "esnext", "moduleResolution": "NodeNext", @@ -19,20 +14,13 @@ "forceConsistentCasingInFileNames": true, "incremental": true, "sourceMap": true, + "inlineSources": true, "emitDecoratorMetadata": true, "experimentalDecorators": true, "resolveJsonModule": true, "esModuleInterop": true }, - "files": [ - "mod.ts" - ], - "include": [ - "src" - ], - "exclude": [ - "dist", - "**/__tests__/**", - "**/__tests/**" - ] + "files": ["mod.ts"], + "include": ["src"], + "exclude": ["dist", "**/__tests__/**", "**/__tests/**"] } diff --git a/packages/sdk/tsconfig.cjs.json b/packages/sdk/tsconfig.cjs.json index e86ad6c42..52f8dc4b3 100644 --- a/packages/sdk/tsconfig.cjs.json +++ b/packages/sdk/tsconfig.cjs.json @@ -5,6 +5,9 @@ "module": "commonjs", "moduleResolution": "bundler", "rootDir": "./", - "outDir": "./dist/cjs" + "outDir": "./dist/cjs", + "declaration": true, + "declarationMap": true, + "tsBuildInfoFile": "./node_modules/.cache/sdk-cjs.tsbuildinfo" } -} \ No newline at end of file +} diff --git a/packages/sdk/tsconfig.esm.json b/packages/sdk/tsconfig.esm.json index efa568e3e..ffd9d9913 100644 --- a/packages/sdk/tsconfig.esm.json +++ b/packages/sdk/tsconfig.esm.json @@ -3,6 +3,7 @@ "compilerOptions": { "rootDir": "./", "outDir": "./dist/esm", - "allowSyntheticDefaultImports": true + "allowSyntheticDefaultImports": true, + "tsBuildInfoFile": "./node_modules/.cache/sdk-esm.tsbuildinfo" } } diff --git a/packages/sdk/tsconfig.json b/packages/sdk/tsconfig.json index 291b5ab41..0a2227998 100644 --- a/packages/sdk/tsconfig.json +++ b/packages/sdk/tsconfig.json @@ -16,4 +16,4 @@ "path": "tsconfig.types.json" } ] -} \ No newline at end of file +} diff --git a/packages/sdk/tsconfig.types.json b/packages/sdk/tsconfig.types.json index 4a492387e..985b50544 100644 --- a/packages/sdk/tsconfig.types.json +++ b/packages/sdk/tsconfig.types.json @@ -5,6 +5,7 @@ "outDir": "./dist/types", "emitDeclarationOnly": true, "declaration": true, - "declarationMap": true + "declarationMap": true, + "tsBuildInfoFile": "./node_modules/.cache/sdk-types.tsbuildinfo" } } diff --git a/packages/verifast/README.md b/packages/verifast/README.md index f4a30ecb2..ab9edcf09 100644 --- a/packages/verifast/README.md +++ b/packages/verifast/README.md @@ -80,9 +80,7 @@ Packed batch methods cross the JS/WASM boundary once per bounded chunk: ```ts const txVerdicts = await verifier.verifyScriptsBatchFromEF(items) -const spendVerdicts = await verifier.verifySpendsBatch( - spends.map(spend => ({ spend })) -) +const spendVerdicts = await verifier.verifySpendsBatch(spends.map(spend => ({ spend }))) ``` On machines with enough logical cores, batches of at least 32 items can use a @@ -216,9 +214,12 @@ validates the vector, typed, transaction-batch, Spend, and Spend-batch ABIs through all three loaders. ```bash +pnpm --filter @bsv/verifast format:check +pnpm --filter @bsv/verifast lint pnpm --filter @bsv/verifast typecheck pnpm --filter @bsv/verifast build -pnpm --filter @bsv/verifast test +pnpm --filter @bsv/verifast test:coverage +pnpm --filter @bsv/verifast pack:check pnpm --filter @bsv/verifast test:consumers pnpm --filter @bsv/verifast bench pnpm --filter @bsv/verifast bench:batch @@ -226,7 +227,10 @@ pnpm --filter @bsv/verifast bench:crypto pnpm --filter @bsv/verifast bench:warmup ``` -The deterministic corpus compares positive and negative SDK-interpreter +The coverage gate ratchets statements, branches, functions, and lines. +`pack:check` installs the exact tarball in ESM and CommonJS projects and +validates conditional declarations and raw WASM assets. The deterministic +corpus compares positive and negative SDK-interpreter verdicts with real BDK WASM for whole transactions and individual Spend objects. Consumer tests execute the built package through Node ESM, CommonJS, browser ESM, and browser UMD rather than substituting mocks. diff --git a/packages/verifast/bench/batch-benchmark.ts b/packages/verifast/bench/batch-benchmark.ts index ca5585379..893b769c4 100644 --- a/packages/verifast/bench/batch-benchmark.ts +++ b/packages/verifast/bench/batch-benchmark.ts @@ -1,10 +1,4 @@ -import { - MerklePath, - P2PKH, - PrivateKey, - Script, - Transaction -} from '@bsv/sdk' +import { MerklePath, P2PKH, PrivateKey, Script, Transaction } from '@bsv/sdk' import BdkVerifier from '../src/BdkVerifier.js' import { buildCorpus, spendsForTransaction } from './corpus.js' @@ -12,12 +6,12 @@ const BATCH_SIZES = [1, 10, 50, 250] as const const SCRIPT_SIZES = [1024, 64 * 1024, 1024 * 1024, 4 * 1024 * 1024] as const const SAMPLES = 25 -function median (values: number[]): number { +function median(values: number[]): number { const sorted = [...values].sort((a, b) => a - b) return sorted[Math.floor(sorted.length / 2)] } -async function measure (operation: () => Promise, samples = SAMPLES): Promise { +async function measure(operation: () => Promise, samples = SAMPLES): Promise { await operation() const values: number[] = [] for (let sample = 0; sample < samples; sample++) { @@ -28,7 +22,7 @@ async function measure (operation: () => Promise, samples = SAMPLES): Prom return median(values) } -function measureSync (operation: () => void, iterations: number): number { +function measureSync(operation: () => void, iterations: number): number { const values: number[] = [] for (let sample = 0; sample < SAMPLES; sample++) { const start = performance.now() @@ -38,7 +32,7 @@ function measureSync (operation: () => void, iterations: number): number { return median(values) } -async function transactionWithOutputScript (scriptSize: number): Promise { +async function transactionWithOutputScript(scriptSize: number): Promise { const key = new PrivateKey(42) const source = new Transaction() source.addInput({ @@ -60,7 +54,7 @@ async function transactionWithOutputScript (scriptSize: number): Promise { +async function dependentP2pkhChain(length: number): Promise { const key = new PrivateKey(84) let previous = new Transaction() previous.addInput({ @@ -73,7 +67,10 @@ async function dependentP2pkhChain (length: number): Promise { lockingScript: new P2PKH().lock(key.toAddress()) }) previous.merklePath = new MerklePath(800000, [ - [{ offset: 0, hash: previous.id('hex'), txid: true }, { offset: 1, duplicate: true }] + [ + { offset: 0, hash: previous.id('hex'), txid: true }, + { offset: 1, duplicate: true } + ] ]) for (let index = 0; index < length; index++) { const transaction = new Transaction() @@ -92,7 +89,7 @@ async function dependentP2pkhChain (length: number): Promise { return previous } -function collectMemory (): { heapMiB: number, rssMiB: number } { +function collectMemory(): { heapMiB: number; rssMiB: number } { globalThis.gc?.() const usage = process.memoryUsage() return { @@ -101,7 +98,7 @@ function collectMemory (): { heapMiB: number, rssMiB: number } { } } -async function main (): Promise { +async function main(): Promise { const verifier = new BdkVerifier({ maxBatchItems: 250, batchWorkers: 1, @@ -129,7 +126,9 @@ async function main (): Promise { await parallelVerifier.preloadBatch() console.log(`Node ${process.version}; ${SAMPLES} median samples; real BDK WASM`) console.log('\nWarm batch scheduling (milliseconds per complete batch):') - console.log('items Spend 1 worker Spend 4 workers speedup EF 1 worker EF 4 workers speedup') + console.log( + 'items Spend 1 worker Spend 4 workers speedup EF 1 worker EF 4 workers speedup' + ) for (const count of BATCH_SIZES) { const spends = Array.from({ length: count }, () => ({ spend })) const transactions = Array.from({ length: count }, () => efParams) @@ -155,28 +154,28 @@ async function main (): Promise { }) console.log( `${String(count).padStart(5)} ${spendSingle.toFixed(3).padStart(14)} ` + - `${spendParallel.toFixed(3).padStart(15)} ${(spendSingle / spendParallel).toFixed(2).padStart(7)}x ` + - `${efSingle.toFixed(3).padStart(11)} ${efParallel.toFixed(3).padStart(12)} ` + - `${(efSingle / efParallel).toFixed(2).padStart(7)}x` + `${spendParallel.toFixed(3).padStart(15)} ${(spendSingle / spendParallel).toFixed(2).padStart(7)}x ` + + `${efSingle.toFixed(3).padStart(11)} ${efParallel.toFixed(3).padStart(12)} ` + + `${(efSingle / efParallel).toFixed(2).padStart(7)}x` ) } const chain = await dependentP2pkhChain(250) const chainSingle = await measure(async () => { - if (!await chain.verify('scripts only', undefined, undefined, verifier)) { + if (!(await chain.verify('scripts only', undefined, undefined, verifier))) { throw new Error('single-instance dependent graph was rejected') } }) const chainParallel = await measure(async () => { - if (!await chain.verify('scripts only', undefined, undefined, parallelVerifier)) { + if (!(await chain.verify('scripts only', undefined, undefined, parallelVerifier))) { throw new Error('parallel dependent graph was rejected') } }) console.log('\n250-transaction dependent graph through Transaction.verify:') console.log( `one instance ${chainSingle.toFixed(3)} ms; four workers ` + - `${chainParallel.toFixed(3)} ms; ` + - `${(chainSingle / chainParallel).toFixed(2)}x` + `${chainParallel.toFixed(3)} ms; ` + + `${(chainSingle / chainParallel).toFixed(2)}x` ) console.log('\nEF serialization (milliseconds per call):') @@ -184,21 +183,32 @@ async function main (): Promise { for (const scriptSize of SCRIPT_SIZES) { const tx = await transactionWithOutputScript(scriptSize) let sequence = 0 - const legacy = measureSync(() => { - tx.lockTime = sequence++ & 1 - tx.toEF() - }, scriptSize >= 1024 * 1024 ? 3 : 15) - const typed = measureSync(() => { - tx.lockTime = sequence++ & 1 - tx.toEFBinary() - }, scriptSize >= 1024 * 1024 ? 3 : 15) + const legacy = measureSync( + () => { + tx.lockTime = sequence++ & 1 + tx.toEF() + }, + scriptSize >= 1024 * 1024 ? 3 : 15 + ) + const typed = measureSync( + () => { + tx.lockTime = sequence++ & 1 + tx.toEFBinary() + }, + scriptSize >= 1024 * 1024 ? 3 : 15 + ) tx.toEFBinary() - const cached = measureSync(() => { tx.toEFBinary() }, scriptSize >= 1024 * 1024 ? 100 : 1000) + const cached = measureSync( + () => { + tx.toEFBinary() + }, + scriptSize >= 1024 * 1024 ? 100 : 1000 + ) const scriptLabel = `${Math.round(scriptSize / 1024)} KiB`.padStart(8) console.log( `${scriptLabel} ${String(tx.toEFBinary().byteLength).padStart(9)} ` + - `${legacy.toFixed(3).padStart(13)} ${typed.toFixed(3).padStart(10)} ` + - `${cached.toFixed(4).padStart(12)} ${(legacy / typed).toFixed(2).padStart(8)}x` + `${legacy.toFixed(3).padStart(13)} ${typed.toFixed(3).padStart(10)} ` + + `${cached.toFixed(4).padStart(12)} ${(legacy / typed).toFixed(2).padStart(8)}x` ) } @@ -206,7 +216,9 @@ async function main (): Promise { await verifier.verifySpendsBatch(Array.from({ length: 250 }, () => ({ spend }))) const after = collectMemory() console.log('\n250-Spend retained-memory delta after forced GC:') - console.log(`heap ${(after.heapMiB - before.heapMiB).toFixed(2)} MiB; RSS ${(after.rssMiB - before.rssMiB).toFixed(2)} MiB`) + console.log( + `heap ${(after.heapMiB - before.heapMiB).toFixed(2)} MiB; RSS ${(after.rssMiB - before.rssMiB).toFixed(2)} MiB` + ) } finally { verifier.dispose() parallelVerifier.dispose() diff --git a/packages/verifast/bench/benchmark.ts b/packages/verifast/bench/benchmark.ts index 20cbd8c56..535fc3ff9 100644 --- a/packages/verifast/bench/benchmark.ts +++ b/packages/verifast/bench/benchmark.ts @@ -19,24 +19,21 @@ interface Comparison { speedup: number } -function percentile (sorted: number[], fraction: number): number { +function percentile(sorted: number[], fraction: number): number { return sorted[Math.min(sorted.length - 1, Math.ceil(sorted.length * fraction) - 1)] } -function summarize (samples: number[], inputs: number): Stats { +function summarize(samples: number[], inputs: number): Stats { const sorted = [...samples].sort((a, b) => a - b) const medianMs = percentile(sorted, 0.5) return { medianMs, p95Ms: percentile(sorted, 0.95), - inputsPerSecond: inputs * ITERATIONS / (medianMs / 1000) + inputsPerSecond: (inputs * ITERATIONS) / (medianMs / 1000) } } -async function timeCase ( - tx: Transaction, - verifier: BdkVerifier | undefined -): Promise { +async function timeCase(tx: Transaction, verifier: BdkVerifier | undefined): Promise { const start = performance.now() for (let i = 0; i < ITERATIONS; i++) { const valid = await tx.verify('scripts only', undefined, undefined, verifier) @@ -45,13 +42,13 @@ async function timeCase ( return performance.now() - start } -async function timeOperation (operation: () => Promise): Promise { +async function timeOperation(operation: () => Promise): Promise { const start = performance.now() for (let i = 0; i < ITERATIONS; i++) await operation() return performance.now() - start } -async function main (): Promise { +async function main(): Promise { const corpus = (await buildCorpus()).filter(({ expected }) => expected) const verifier = new BdkVerifier({ mode: 'always' }) @@ -85,21 +82,27 @@ async function main (): Promise { }) } - console.log(`Node ${process.version}; ${ITERATIONS} iterations x ${SAMPLES} samples; median and p95 wall time`) - console.log('case in JS median BDK median JS inputs/s BDK inputs/s speedup') + console.log( + `Node ${process.version}; ${ITERATIONS} iterations x ${SAMPLES} samples; median and p95 wall time` + ) + console.log( + 'case in JS median BDK median JS inputs/s BDK inputs/s speedup' + ) for (const result of comparisons) { console.log( `${result.name.padEnd(29)} ${String(result.inputs).padStart(2)} ` + - `${result.pureJs.medianMs.toFixed(1).padStart(8)}ms ` + - `${result.bdkWasm.medianMs.toFixed(1).padStart(9)}ms ` + - `${result.pureJs.inputsPerSecond.toFixed(0).padStart(11)} ` + - `${result.bdkWasm.inputsPerSecond.toFixed(0).padStart(12)} ` + - `${result.speedup.toFixed(2).padStart(6)}x` + `${result.pureJs.medianMs.toFixed(1).padStart(8)}ms ` + + `${result.bdkWasm.medianMs.toFixed(1).padStart(9)}ms ` + + `${result.pureJs.inputsPerSecond.toFixed(0).padStart(11)} ` + + `${result.bdkWasm.inputsPerSecond.toFixed(0).padStart(12)} ` + + `${result.speedup.toFixed(2).padStart(6)}x` ) } console.log('\np95 milliseconds:') for (const result of comparisons) { - console.log(`${result.name}: JS ${result.pureJs.p95Ms.toFixed(1)}, BDK ${result.bdkWasm.p95Ms.toFixed(1)}`) + console.log( + `${result.name}: JS ${result.pureJs.p95Ms.toFixed(1)}, BDK ${result.bdkWasm.p95Ms.toFixed(1)}` + ) } const diagnosticTx = corpus[0].tx @@ -107,20 +110,31 @@ async function main (): Promise { const orchestrationSamples: number[] = [] const noOpVerifier = { verifyScripts: async (): Promise => true } for (let sample = 0; sample < SAMPLES; sample++) { - directSamples.push(await timeOperation(async () => await verifier.verifyScripts({ - tx: diagnosticTx, - blockHeight: 943816, - consensus: true - }))) - orchestrationSamples.push(await timeOperation(async () => await diagnosticTx.verify( - 'scripts only', undefined, undefined, noOpVerifier - ))) + directSamples.push( + await timeOperation( + async () => + await verifier.verifyScripts({ + tx: diagnosticTx, + blockHeight: 943816, + consensus: true + }) + ) + ) + orchestrationSamples.push( + await timeOperation( + async () => await diagnosticTx.verify('scripts only', undefined, undefined, noOpVerifier) + ) + ) } const direct = summarize(directSamples, 1) const orchestration = summarize(orchestrationSamples, 1) console.log('\n1-input P2PKH diagnostic lanes:') - console.log(`BDK adapter direct: ${direct.medianMs.toFixed(1)} ms (${direct.inputsPerSecond.toFixed(0)} inputs/s)`) - console.log(`SDK verify + no-op backend: ${orchestration.medianMs.toFixed(1)} ms (${orchestration.inputsPerSecond.toFixed(0)} inputs/s)`) + console.log( + `BDK adapter direct: ${direct.medianMs.toFixed(1)} ms (${direct.inputsPerSecond.toFixed(0)} inputs/s)` + ) + console.log( + `SDK verify + no-op backend: ${orchestration.medianMs.toFixed(1)} ms (${orchestration.inputsPerSecond.toFixed(0)} inputs/s)` + ) if (process.env.VERIFAST_JSON === '1') console.log(JSON.stringify(comparisons)) } diff --git a/packages/verifast/bench/corpus.ts b/packages/verifast/bench/corpus.ts index fc3d1764e..5dba16ce6 100644 --- a/packages/verifast/bench/corpus.ts +++ b/packages/verifast/bench/corpus.ts @@ -7,12 +7,15 @@ export interface CorpusEntry { } /** Reconstruct the same per-input Spend objects used by Transaction.verify. */ -export function spendsForTransaction (tx: Transaction): Spend[] { +export function spendsForTransaction(tx: Transaction): Spend[] { return tx.inputs.map((input, inputIndex) => { - if (input.sourceTransaction === undefined) throw new Error(`Input ${inputIndex} has no source transaction`) - if (input.unlockingScript === undefined) throw new Error(`Input ${inputIndex} has no unlocking script`) + if (input.sourceTransaction === undefined) + throw new Error(`Input ${inputIndex} has no source transaction`) + if (input.unlockingScript === undefined) + throw new Error(`Input ${inputIndex} has no unlocking script`) const sourceOutput = input.sourceTransaction.outputs[input.sourceOutputIndex] - if (sourceOutput === undefined) throw new Error(`Input ${inputIndex} references a missing source output`) + if (sourceOutput === undefined) + throw new Error(`Input ${inputIndex} references a missing source output`) return new Spend({ sourceTXID: input.sourceTXID ?? input.sourceTransaction.id('hex'), sourceOutputIndex: input.sourceOutputIndex, @@ -30,13 +33,16 @@ export function spendsForTransaction (tx: Transaction): Spend[] { }) } -function markMined (tx: Transaction, blockHeight = 800000): void { +function markMined(tx: Transaction, blockHeight = 800000): void { tx.merklePath = new MerklePath(blockHeight, [ - [{ offset: 0, hash: tx.id('hex'), txid: true }, { offset: 1, duplicate: true }] + [ + { offset: 0, hash: tx.id('hex'), txid: true }, + { offset: 1, duplicate: true } + ] ]) } -async function fundedP2pkhSource (key: PrivateKey, count: number): Promise { +async function fundedP2pkhSource(key: PrivateKey, count: number): Promise { const source = new Transaction() source.addInput({ sourceTXID: '00'.repeat(32), @@ -50,7 +56,7 @@ async function fundedP2pkhSource (key: PrivateKey, count: number): Promise { +async function p2pkhTx(inputCount: number): Promise { const key = new PrivateKey(1000 + inputCount) const source = await fundedP2pkhSource(key, inputCount) const tx = new Transaction() @@ -66,7 +72,7 @@ async function p2pkhTx (inputCount: number): Promise { return tx } -async function scriptTx (lockingASM: string, unlockingASM = ''): Promise { +async function scriptTx(lockingASM: string, unlockingASM = ''): Promise { const source = new Transaction() source.addInput({ sourceTXID: '11'.repeat(32), @@ -86,7 +92,7 @@ async function scriptTx (lockingASM: string, unlockingASM = ''): Promise { +export async function buildCorpus(): Promise { const one = await p2pkhTx(1) const five = await p2pkhTx(5) const twenty = await p2pkhTx(20) @@ -111,7 +117,11 @@ export async function buildCorpus (): Promise { { name: 'p2pkh-5in-valid', tx: five, expected: true }, { name: 'p2pkh-20in-valid', tx: twenty, expected: true }, { name: 'op-true-valid', tx: await scriptTx('OP_TRUE'), expected: true }, - { name: 'arithmetic-valid', tx: await scriptTx('OP_2 OP_3 OP_ADD OP_5 OP_EQUAL'), expected: true }, + { + name: 'arithmetic-valid', + tx: await scriptTx('OP_2 OP_3 OP_ADD OP_5 OP_EQUAL'), + expected: true + }, { name: 'sha256-preimage-valid', tx: await scriptTx( diff --git a/packages/verifast/bench/crypto-benchmark.ts b/packages/verifast/bench/crypto-benchmark.ts index a5f36441c..4ce930f1b 100644 --- a/packages/verifast/bench/crypto-benchmark.ts +++ b/packages/verifast/bench/crypto-benchmark.ts @@ -13,12 +13,12 @@ import BdkVerifier from '../src/BdkVerifier.js' const SAMPLES = 25 const ITERATIONS = 100 -function median (values: number[]): number { +function median(values: number[]): number { const sorted = [...values].sort((a, b) => a - b) return sorted[Math.floor(sorted.length / 2)] } -async function measure ( +async function measure( operation: () => Promise, iterations: number = ITERATIONS ): Promise { @@ -34,19 +34,15 @@ async function measure ( return median(samples) } -function printComparison ( - name: string, - javascriptMs: number, - wasmMs: number -): void { +function printComparison(name: string, javascriptMs: number, wasmMs: number): void { console.log( `${name.padEnd(30)} ${javascriptMs.toFixed(4).padStart(10)} ` + - `${wasmMs.toFixed(4).padStart(10)} ` + - `${(javascriptMs / wasmMs).toFixed(2).padStart(7)}x` + `${wasmMs.toFixed(4).padStart(10)} ` + + `${(javascriptMs / wasmMs).toFixed(2).padStart(7)}x` ) } -async function main (): Promise { +async function main(): Promise { const privateKey = new PrivateKey(42) const privateKeyBytes = Uint8Array.from(privateKey.toArray('be', 32)) const publicKey = privateKey.toPublicKey() @@ -102,7 +98,7 @@ async function main (): Promise { } }), await measure(async () => { - if (!await verifier.verifyDigest(publicKeyBytes, digest, signatureBytes)) { + if (!(await verifier.verifyDigest(publicKeyBytes, digest, signatureBytes))) { throw new Error('BDK rejected benchmark signature') } }) @@ -202,11 +198,13 @@ async function main (): Promise { }, 1) console.log('\n250-signature packed batch (milliseconds per complete batch):') console.log(`SDK JS: ${sdkBatch.toFixed(3)}`) - console.log(`BDK 1 worker: ${wasmBatch.toFixed(3)} (${(sdkBatch / wasmBatch).toFixed(2)}x vs JS)`) + console.log( + `BDK 1 worker: ${wasmBatch.toFixed(3)} (${(sdkBatch / wasmBatch).toFixed(2)}x vs JS)` + ) console.log( `BDK 4 workers: ${parallelBatch.toFixed(3)} ` + - `(${(sdkBatch / parallelBatch).toFixed(2)}x vs JS; ` + - `${(wasmBatch / parallelBatch).toFixed(2)}x vs one worker)` + `(${(sdkBatch / parallelBatch).toFixed(2)}x vs JS; ` + + `${(wasmBatch / parallelBatch).toFixed(2)}x vs one worker)` ) } finally { unregisterAsyncCryptoBackend(parallelVerifier) diff --git a/packages/verifast/bench/results/2026-07-15-m3-max.md b/packages/verifast/bench/results/2026-07-15-m3-max.md index 02d4137f1..f3cd7ece3 100644 --- a/packages/verifast/bench/results/2026-07-15-m3-max.md +++ b/packages/verifast/bench/results/2026-07-15-m3-max.md @@ -19,14 +19,14 @@ Module initialization and one JIT warmup call were excluded. Command: `VERIFAST_ITERATIONS=100 VERIFAST_SAMPLES=7 pnpm --filter @bsv/verifast bench` -| Vector | Inputs | JS median | BDK median | JS inputs/s | BDK inputs/s | BDK speedup | JS p95 | BDK p95 | -|---|---:|---:|---:|---:|---:|---:|---:|---:| -| P2PKH 1 input | 1 | 108.1 ms | 6.7 ms | 925 | 14,825 | 16.03x | 121.1 ms | 9.1 ms | -| P2PKH 5 inputs | 5 | 534.9 ms | 27.0 ms | 935 | 18,489 | 19.78x | 547.0 ms | 28.3 ms | -| P2PKH 20 inputs | 20 | 2156.8 ms | 114.4 ms | 927 | 17,479 | 18.85x | 2164.9 ms | 115.3 ms | -| OP_TRUE | 1 | 0.1 ms | 0.5 ms | 920,598 | 195,503 | 0.21x | 0.3 ms | 0.6 ms | -| Arithmetic | 1 | 0.3 ms | 0.6 ms | 307,495 | 177,699 | 0.58x | 0.7 ms | 0.9 ms | -| SHA-256 preimage | 1 | 0.4 ms | 0.6 ms | 252,207 | 179,883 | 0.71x | 0.5 ms | 0.6 ms | +| Vector | Inputs | JS median | BDK median | JS inputs/s | BDK inputs/s | BDK speedup | JS p95 | BDK p95 | +| ---------------- | -----: | --------: | ---------: | ----------: | -----------: | ----------: | --------: | -------: | +| P2PKH 1 input | 1 | 108.1 ms | 6.7 ms | 925 | 14,825 | 16.03x | 121.1 ms | 9.1 ms | +| P2PKH 5 inputs | 5 | 534.9 ms | 27.0 ms | 935 | 18,489 | 19.78x | 547.0 ms | 28.3 ms | +| P2PKH 20 inputs | 20 | 2156.8 ms | 114.4 ms | 927 | 17,479 | 18.85x | 2164.9 ms | 115.3 ms | +| OP_TRUE | 1 | 0.1 ms | 0.5 ms | 920,598 | 195,503 | 0.21x | 0.3 ms | 0.6 ms | +| Arithmetic | 1 | 0.3 ms | 0.6 ms | 307,495 | 177,699 | 0.58x | 0.7 ms | 0.9 ms | +| SHA-256 preimage | 1 | 0.4 ms | 0.6 ms | 252,207 | 179,883 | 0.71x | 0.5 ms | 0.6 ms | Each sample in the table contains 100 complete verifications. Backend order was alternated between samples to reduce ordering and thermal bias. @@ -35,11 +35,11 @@ alternated between samples to reduce ordering and thermal bias. Command: `pnpm --filter @bsv/verifast test:browser` -| Vector | Inputs | JS median | BDK median | JS inputs/s | BDK inputs/s | BDK speedup | JS p95 | BDK p95 | -|---|---:|---:|---:|---:|---:|---:|---:|---:| -| P2PKH 1 input | 1 | 45.0 ms | 3.7 ms | 1,111 | 13,514 | 12.16x | 51.5 ms | 3.9 ms | -| P2PKH 5 inputs | 5 | 215.3 ms | 15.4 ms | 1,161 | 16,234 | 13.98x | 222.8 ms | 15.7 ms | -| P2PKH 20 inputs | 20 | 848.2 ms | 59.9 ms | 1,179 | 16,694 | 14.16x | 860.4 ms | 60.0 ms | +| Vector | Inputs | JS median | BDK median | JS inputs/s | BDK inputs/s | BDK speedup | JS p95 | BDK p95 | +| --------------- | -----: | --------: | ---------: | ----------: | -----------: | ----------: | -------: | ------: | +| P2PKH 1 input | 1 | 45.0 ms | 3.7 ms | 1,111 | 13,514 | 12.16x | 51.5 ms | 3.9 ms | +| P2PKH 5 inputs | 5 | 215.3 ms | 15.4 ms | 1,161 | 16,234 | 13.98x | 222.8 ms | 15.7 ms | +| P2PKH 20 inputs | 20 | 848.2 ms | 59.9 ms | 1,179 | 16,694 | 14.16x | 860.4 ms | 60.0 ms | Each Chrome sample contains 50 complete verifications, with five samples per backend and alternating backend order. @@ -54,14 +54,14 @@ machine code on this arm64 host. Representative retained compiler experiments show where the speedup came from: -| Curve configuration | Direct WASM us/op | Outcome | -|---|---:|---| -| Default `__int128` lowering | 113.6 | Baseline | -| `int128_struct` | 84.5 | Rejected | -| wasm32 `int64` limbs | 51.7 | Adopted | -| `int64`, ecmult window 10 | 54.8 | Rejected | -| `int64`, ecmult window 12 | 53.2 | Rejected | -| `int64`, ecmult window 15, Binaryen `-O4 --converge` | 50.7 | Final | +| Curve configuration | Direct WASM us/op | Outcome | +| ---------------------------------------------------- | ----------------: | -------- | +| Default `__int128` lowering | 113.6 | Baseline | +| `int128_struct` | 84.5 | Rejected | +| wasm32 `int64` limbs | 51.7 | Adopted | +| `int64`, ecmult window 10 | 54.8 | Rejected | +| `int64`, ecmult window 12 | 53.2 | Rejected | +| `int64`, ecmult window 15, Binaryen `-O4 --converge` | 50.7 | Final | The standalone ECDSA verifier accounts for nearly all of the final interpreter time: the bundled BSV libsecp256k1 0.5.1 curve benchmark averaged 47.9 us versus diff --git a/packages/verifast/bench/results/2026-07-22-m3-max.md b/packages/verifast/bench/results/2026-07-22-m3-max.md index c74a194f6..9421c3906 100644 --- a/packages/verifast/bench/results/2026-07-22-m3-max.md +++ b/packages/verifast/bench/results/2026-07-22-m3-max.md @@ -21,14 +21,14 @@ Command: `pnpm --filter @bsv/verifast bench` Each sample contains 100 complete `Transaction.verify('scripts only')` calls; the table reports the median of seven alternating samples. -| Vector | Inputs | JS median | BDK median | JS inputs/s | BDK inputs/s | Speedup | -|---|---:|---:|---:|---:|---:|---:| -| P2PKH | 1 | 110.7 ms | 6.2 ms | 904 | 16,145 | 17.87x | -| P2PKH | 5 | 543.4 ms | 27.2 ms | 920 | 18,413 | 20.01x | -| P2PKH | 20 | 2,161.6 ms | 109.9 ms | 925 | 18,205 | 19.68x | -| OP_TRUE | 1 | 0.1 ms | 0.3 ms | 943,396 | 306,906 | 0.33x | -| Arithmetic | 1 | 0.3 ms | 0.4 ms | 300,940 | 267,827 | 0.89x | -| SHA-256 preimage | 1 | 0.4 ms | 0.3 ms | 249,922 | 319,872 | 1.28x | +| Vector | Inputs | JS median | BDK median | JS inputs/s | BDK inputs/s | Speedup | +| ---------------- | -----: | ---------: | ---------: | ----------: | -----------: | ------: | +| P2PKH | 1 | 110.7 ms | 6.2 ms | 904 | 16,145 | 17.87x | +| P2PKH | 5 | 543.4 ms | 27.2 ms | 920 | 18,413 | 20.01x | +| P2PKH | 20 | 2,161.6 ms | 109.9 ms | 925 | 18,205 | 19.68x | +| OP_TRUE | 1 | 0.1 ms | 0.3 ms | 943,396 | 306,906 | 0.33x | +| Arithmetic | 1 | 0.3 ms | 0.4 ms | 300,940 | 267,827 | 0.89x | +| SHA-256 preimage | 1 | 0.4 ms | 0.3 ms | 249,922 | 319,872 | 1.28x | The SDK orchestration-only diagnostic lane sustained 2,232,542 inputs/s, while the direct one-input BDK lane sustained 18,135 inputs/s. Signature verification, @@ -67,11 +67,11 @@ P2PKH transaction. “Singles” performs one JS/WASM call per item; “packed one call and contiguous typed buffers. | Items | Spend singles | Spend packed | Spend gain | EF singles | EF packed | EF gain | -|---:|---:|---:|---:|---:|---:|---:| -| 1 | 0.146 ms | 0.135 ms | 1.08x | 0.089 ms | 0.103 ms | 0.87x | -| 10 | 0.745 ms | 0.645 ms | 1.16x | 0.633 ms | 0.562 ms | 1.13x | -| 50 | 3.156 ms | 2.821 ms | 1.12x | 2.931 ms | 2.738 ms | 1.07x | -| 250 | 14.402 ms | 13.822 ms | 1.04x | 13.589 ms | 13.248 ms | 1.03x | +| ----: | ------------: | -----------: | ---------: | ---------: | --------: | ------: | +| 1 | 0.146 ms | 0.135 ms | 1.08x | 0.089 ms | 0.103 ms | 0.87x | +| 10 | 0.745 ms | 0.645 ms | 1.16x | 0.633 ms | 0.562 ms | 1.13x | +| 50 | 3.156 ms | 2.821 ms | 1.12x | 2.931 ms | 2.738 ms | 1.07x | +| 250 | 14.402 ms | 13.822 ms | 1.04x | 13.589 ms | 13.248 ms | 1.03x | Packing removes measurable boundary overhead at moderate batch sizes. At 250 P2PKH inputs the fixed overhead is amortized and ECDSA dominates, so claiming a @@ -88,12 +88,12 @@ listed size. “`number[]` cold” calls the compatibility `toEF()` API after a mutation; “typed cold” calls `toEFBinary()` after a mutation; “cached typed” reuses `toEFBinary()` without mutation. -| Script | EF bytes | `number[]` cold | Typed cold | Cached typed | Cold gain | -|---:|---:|---:|---:|---:|---:| -| 1 KiB | 1,233 | 0.021 ms | 0.003 ms | 0.0001 ms | 6.22x | -| 64 KiB | 65,746 | 1.005 ms | 0.013 ms | <0.0001 ms | 75.76x | -| 1 MiB | 1,048,786 | 17.025 ms | 0.128 ms | <0.0001 ms | 132.76x | -| 4 MiB | 4,194,514 | 67.069 ms | 0.382 ms | <0.0001 ms | 175.61x | +| Script | EF bytes | `number[]` cold | Typed cold | Cached typed | Cold gain | +| -----: | --------: | --------------: | ---------: | -----------: | --------: | +| 1 KiB | 1,233 | 0.021 ms | 0.003 ms | 0.0001 ms | 6.22x | +| 64 KiB | 65,746 | 1.005 ms | 0.013 ms | <0.0001 ms | 75.76x | +| 1 MiB | 1,048,786 | 17.025 ms | 0.128 ms | <0.0001 ms | 132.76x | +| 4 MiB | 4,194,514 | 67.069 ms | 0.382 ms | <0.0001 ms | 175.61x | These figures isolate serialization and legacy array conversion, not complete script verification. The practical verifier win depends on how much ECDSA or diff --git a/packages/verifast/bench/results/2026-07-23-compact-crypto-workers.md b/packages/verifast/bench/results/2026-07-23-compact-crypto-workers.md index 54097005a..b47138428 100644 --- a/packages/verifast/bench/results/2026-07-23-compact-crypto-workers.md +++ b/packages/verifast/bench/results/2026-07-23-compact-crypto-workers.md @@ -26,15 +26,15 @@ Worker-only scheduling and table-snapshot glue stays out of the classic build. Command: `pnpm --filter @bsv/verifast bench:crypto` -| Operation | SDK JavaScript | BDK WASM | Speedup | -|---|---:|---:|---:| -| Deterministic ECDSA sign | 0.9780 ms | 0.0347 ms | 28.19x | -| ECDSA verify | 0.8971 ms | 0.0490 ms | 18.32x | -| Compressed public key | 0.0420 ms | 0.0269 ms | 1.56x | -| BRC-42 public derivation | 0.5138 ms | 0.1773 ms | 2.90x | -| BRC-42 symmetric derivation | 1.4948 ms | 0.1614 ms | 9.26x | -| `ProtoWallet.createSignature` | 0.9812 ms | 0.0375 ms | 26.17x | -| `ProtoWallet.verifySignature` | 0.9143 ms | 0.0555 ms | 16.48x | +| Operation | SDK JavaScript | BDK WASM | Speedup | +| ----------------------------- | -------------: | --------: | ------: | +| Deterministic ECDSA sign | 0.9780 ms | 0.0347 ms | 28.19x | +| ECDSA verify | 0.8971 ms | 0.0490 ms | 18.32x | +| Compressed public key | 0.0420 ms | 0.0269 ms | 1.56x | +| BRC-42 public derivation | 0.5138 ms | 0.1773 ms | 2.90x | +| BRC-42 symmetric derivation | 1.4948 ms | 0.1614 ms | 9.26x | +| `ProtoWallet.createSignature` | 0.9812 ms | 0.0375 ms | 26.17x | +| `ProtoWallet.verifySignature` | 0.9143 ms | 0.0555 ms | 16.48x | An isolated private BRC-42 derivation was deliberately left on the existing TypeScript scalar-add path: crossing into WASM did not repay its fixed overhead. @@ -51,11 +51,11 @@ transaction script policy continues to enforce `LOW_S` independently. The same 250 valid P2PKH digest/public-key/signature tuples were verified through each lane: -| Lane | Complete batch | Relative to SDK JS | -|---|---:|---:| -| SDK JavaScript loop | 224.082 ms | 1.00x | -| Packed BDK, one instance | 12.099 ms | 18.52x | -| Packed BDK, four warm workers | 3.343 ms | 67.02x | +| Lane | Complete batch | Relative to SDK JS | +| ----------------------------- | -------------: | -----------------: | +| SDK JavaScript loop | 224.082 ms | 1.00x | +| Packed BDK, one instance | 12.099 ms | 18.52x | +| Packed BDK, four warm workers | 3.343 ms | 67.02x | Four workers improve the one-instance BDK batch by 3.62x. They are never used for a single verification and are created only by `preloadBatch()` or a @@ -65,12 +65,12 @@ qualifying batch. Command: `pnpm --filter @bsv/verifast bench:batch` -| Items | Spend 1 worker | Spend 4 workers | Gain | EF 1 worker | EF 4 workers | Gain | -|---:|---:|---:|---:|---:|---:|---:| -| 1 | 0.098 ms | 0.089 ms | 1.11x | 0.072 ms | 0.064 ms | 1.11x | -| 10 | 0.620 ms | 0.578 ms | 1.07x | 0.537 ms | 0.533 ms | 1.01x | -| 50 | 2.797 ms | 0.921 ms | 3.04x | 2.684 ms | 0.805 ms | 3.34x | -| 250 | 13.718 ms | 4.088 ms | 3.36x | 13.006 ms | 3.553 ms | 3.66x | +| Items | Spend 1 worker | Spend 4 workers | Gain | EF 1 worker | EF 4 workers | Gain | +| ----: | -------------: | --------------: | ----: | ----------: | -----------: | ----: | +| 1 | 0.098 ms | 0.089 ms | 1.11x | 0.072 ms | 0.064 ms | 1.11x | +| 10 | 0.620 ms | 0.578 ms | 1.07x | 0.537 ms | 0.533 ms | 1.01x | +| 50 | 2.797 ms | 0.921 ms | 3.04x | 2.684 ms | 0.805 ms | 3.34x | +| 250 | 13.718 ms | 4.088 ms | 3.36x | 13.006 ms | 3.553 ms | 3.66x | The configured threshold was 32, so the 1- and 10-item rows stay on one WASM instance. The small differences there are normal run-to-run noise rather than @@ -98,12 +98,12 @@ fallback. The snapshot is runtime-only and does not add 1 MiB to the package. ## EF serialization -| Script | EF bytes | Legacy `number[]` | Typed cold | Cached typed | Cold gain | -|---:|---:|---:|---:|---:|---:| -| 1 KiB | 1,233 | 0.020 ms | 0.003 ms | <0.0001 ms | 6.73x | -| 64 KiB | 65,746 | 1.003 ms | 0.013 ms | <0.0001 ms | 75.55x | -| 1 MiB | 1,048,786 | 15.929 ms | 0.095 ms | 0.0002 ms | 167.94x | -| 4 MiB | 4,194,514 | 66.922 ms | 0.318 ms | 0.0002 ms | 210.56x | +| Script | EF bytes | Legacy `number[]` | Typed cold | Cached typed | Cold gain | +| -----: | --------: | ----------------: | ---------: | -----------: | --------: | +| 1 KiB | 1,233 | 0.020 ms | 0.003 ms | <0.0001 ms | 6.73x | +| 64 KiB | 65,746 | 1.003 ms | 0.013 ms | <0.0001 ms | 75.55x | +| 1 MiB | 1,048,786 | 15.929 ms | 0.095 ms | 0.0002 ms | 167.94x | +| 4 MiB | 4,194,514 | 66.922 ms | 0.318 ms | 0.0002 ms | 210.56x | The worker protocol and primitive ABI use packed typed arrays throughout, so the faster cryptography does not expose a new `number[]`, JSON, or per-item diff --git a/packages/verifast/bench/warmup-benchmark.ts b/packages/verifast/bench/warmup-benchmark.ts index ec906f5eb..7424e5b9a 100644 --- a/packages/verifast/bench/warmup-benchmark.ts +++ b/packages/verifast/bench/warmup-benchmark.ts @@ -5,18 +5,18 @@ import createBdkModule from '../src/wasm/bdk-core.mjs' const SAMPLES = 25 const WORKERS = 4 -function median (values: number[]): number { +function median(values: number[]): number { const sorted = [...values].sort((a, b) => a - b) return sorted[Math.floor(sorted.length / 2)] } -async function loadedWorker (): Promise { +async function loadedWorker(): Promise { const worker = new Worker(new URL('./warmup-worker.mjs', import.meta.url)) await once(worker, 'message') return worker } -async function measure (useSnapshot: boolean): Promise { +async function measure(useSnapshot: boolean): Promise { const main = await createBdkModule() const workers = await Promise.all( Array.from({ length: WORKERS }, async () => await loadedWorker()) @@ -40,7 +40,7 @@ async function measure (useSnapshot: boolean): Promise { } } -async function main (): Promise { +async function main(): Promise { const independent: number[] = [] const snapshot: number[] = [] for (let sample = 0; sample < SAMPLES; sample++) { @@ -59,7 +59,7 @@ async function main (): Promise { console.log(`main generation + snapshot imports: ${snapshotMedian.toFixed(3)} ms`) console.log( `warm-up reduction: ${((1 - snapshotMedian / independentMedian) * 100).toFixed(1)}% ` + - `(${(independentMedian / snapshotMedian).toFixed(2)}x)` + `(${(independentMedian / snapshotMedian).toFixed(2)}x)` ) } diff --git a/packages/verifast/browser/main.ts b/packages/verifast/browser/main.ts index 92aeabc57..575ea7908 100644 --- a/packages/verifast/browser/main.ts +++ b/packages/verifast/browser/main.ts @@ -2,7 +2,7 @@ import { BdkVerifier } from '../mod.browser.js' import { buildCorpus } from '../bench/corpus.js' interface BrowserResult { - vectors: Array<{ name: string, expected: boolean, js: boolean, bdk: boolean }> + vectors: Array<{ name: string; expected: boolean; js: boolean; bdk: boolean }> workerBatch: { count: number allValid: boolean @@ -31,13 +31,13 @@ declare global { } } -function renderResult (value: string): void { +function renderResult(value: string): void { const result = document.querySelector('#result') if (result === null) throw new Error('missing #result element') result.textContent = value } -async function verdict (run: () => Promise): Promise { +async function verdict(run: () => Promise): Promise { try { return await run() } catch { @@ -45,18 +45,21 @@ async function verdict (run: () => Promise): Promise { } } -function percentile (values: number[], fraction: number): number { +function percentile(values: number[], fraction: number): number { const sorted = [...values].sort((a, b) => a - b) return sorted[Math.min(sorted.length - 1, Math.ceil(sorted.length * fraction) - 1)] } -async function timeVerification (tx: Awaited>[number]['tx'], verifier?: BdkVerifier): Promise { +async function timeVerification( + tx: Awaited>[number]['tx'], + verifier?: BdkVerifier +): Promise { const start = performance.now() for (let i = 0; i < 50; i++) await tx.verify('scripts only', undefined, undefined, verifier) return performance.now() - start } -async function run (): Promise { +async function run(): Promise { const verifier = new BdkVerifier({ batchWorkers: 4, batchWorkerThreshold: 32, @@ -72,7 +75,9 @@ async function run (): Promise { const vectors: BrowserResult['vectors'] = [] for (const { name, tx, expected } of corpus) { const js = await verdict(async () => await tx.verify('scripts only')) - const bdk = await verdict(async () => await tx.verify('scripts only', undefined, undefined, verifier)) + const bdk = await verdict( + async () => await tx.verify('scripts only', undefined, undefined, verifier) + ) vectors.push({ name, expected, js, bdk }) } await verifier.preloadBatch() @@ -109,8 +114,8 @@ async function run (): Promise { jsP95Ms: percentile(jsTimes, 0.95), bdkMedianMs, bdkP95Ms: percentile(bdkTimes, 0.95), - jsInputsPerSecond: tx.inputs.length * iterations / (jsMedianMs / 1000), - bdkInputsPerSecond: tx.inputs.length * iterations / (bdkMedianMs / 1000), + jsInputsPerSecond: (tx.inputs.length * iterations) / (jsMedianMs / 1000), + bdkInputsPerSecond: (tx.inputs.length * iterations) / (bdkMedianMs / 1000), speedup: jsMedianMs / bdkMedianMs }) } @@ -129,6 +134,7 @@ async function run (): Promise { try { await run() } catch (error) { - window.__VERIFAST_ERROR__ = error instanceof Error ? error.stack ?? error.message : String(error) + window.__VERIFAST_ERROR__ = + error instanceof Error ? (error.stack ?? error.message) : String(error) renderResult(window.__VERIFAST_ERROR__) } diff --git a/packages/verifast/browser/test.mjs b/packages/verifast/browser/test.mjs index 27352b823..2792befb8 100644 --- a/packages/verifast/browser/test.mjs +++ b/packages/verifast/browser/test.mjs @@ -42,11 +42,14 @@ try { }) const page = await browser.newPage() const browserErrors = [] - page.on('pageerror', (error) => browserErrors.push(error.stack ?? error.message)) + page.on('pageerror', error => browserErrors.push(error.stack ?? error.message)) await page.goto(url, { waitUntil: 'networkidle0' }) - await page.waitForFunction(() => window.__VERIFAST_RESULT__ !== undefined || window.__VERIFAST_ERROR__ !== undefined, { - timeout: 60_000 - }) + await page.waitForFunction( + () => window.__VERIFAST_RESULT__ !== undefined || window.__VERIFAST_ERROR__ !== undefined, + { + timeout: 60_000 + } + ) const state = await page.evaluate(() => ({ result: window.__VERIFAST_RESULT__, error: window.__VERIFAST_ERROR__ @@ -68,10 +71,11 @@ try { const umdPage = await browser.newPage() const umdErrors = [] - umdPage.on('pageerror', (error) => umdErrors.push(error.stack ?? error.message)) + umdPage.on('pageerror', error => umdErrors.push(error.stack ?? error.message)) await umdPage.goto(new URL('umd.html', url).href, { waitUntil: 'networkidle0' }) await umdPage.waitForFunction( - () => window.__VERIFAST_UMD_RESULT__ !== undefined || window.__VERIFAST_UMD_ERROR__ !== undefined, + () => + window.__VERIFAST_UMD_RESULT__ !== undefined || window.__VERIFAST_UMD_ERROR__ !== undefined, { timeout: 60_000 } ) const umdState = await umdPage.evaluate(() => ({ diff --git a/packages/verifast/jest.config.js b/packages/verifast/jest.config.js index 46c07b1b5..d09ef643a 100644 --- a/packages/verifast/jest.config.js +++ b/packages/verifast/jest.config.js @@ -5,28 +5,47 @@ export default { roots: ['/src', '/bench'], testPathIgnorePatterns: ['dist/'], modulePathIgnorePatterns: ['/dist'], + collectCoverageFrom: [ + 'src/**/*.ts', + '!src/**/__tests/**', + '!src/wasm/**', + // These are process entrypoints exercised by real worker/consumer tests; + // Jest cannot merge coverage emitted by their separate runtimes. + '!src/workers/BdkVerifierBrowserWorker.ts', + '!src/workers/BdkVerifierNodeWorker.ts' + ], + coverageThreshold: { + global: { + branches: 70, + functions: 72, + lines: 75, + statements: 72 + } + }, transform: { - '^.+\\.ts$': ['ts-jest', { - useESM: true, - diagnostics: false, - tsconfig: { - target: 'ES2020', - module: 'ESNext', - moduleResolution: 'bundler', - strict: false, - strictNullChecks: false, - noImplicitAny: false, - strictPropertyInitialization: false, - skipLibCheck: true, - allowJs: true, - types: ['node', 'jest'] + '^.+\\.ts$': [ + 'ts-jest', + { + useESM: true, + diagnostics: false, + tsconfig: { + target: 'ES2020', + module: 'ESNext', + moduleResolution: 'bundler', + strict: false, + strictNullChecks: false, + noImplicitAny: false, + strictPropertyInitialization: false, + skipLibCheck: true, + allowJs: true, + types: ['node', 'jest'] + } } - }] + ] }, extensionsToTreatAsEsm: ['.ts'], moduleNameMapper: { - // Dev-only: use LOCAL sdk source so the new Transaction.verify(verifier) - // signature is visible despite the repo-wide @bsv/sdk:2.1.3 override. + // Dev-only: test against the workspace SDK source used by the stack. '^@bsv/sdk$': '/../sdk/mod.ts', '^(\\.{1,2}/.*)\\.js$': '$1' } diff --git a/packages/verifast/mod.browser.ts b/packages/verifast/mod.browser.ts index ae6c59b5f..d3453f65a 100644 --- a/packages/verifast/mod.browser.ts +++ b/packages/verifast/mod.browser.ts @@ -1,8 +1,5 @@ export { default as BdkVerifier } from './src/BdkVerifier.browser.js' -export { - BdkErrorDomain, - BdkVerificationError -} from './src/BdkVerifier.browser.js' +export { BdkErrorDomain, BdkVerificationError } from './src/BdkVerifier.browser.js' export { mapVerifyFlags, BDK_FLAG_BITS } from './src/flags.js' export type { BdkNetwork, diff --git a/packages/verifast/mod.ts b/packages/verifast/mod.ts index cc04adcde..0774b7987 100644 --- a/packages/verifast/mod.ts +++ b/packages/verifast/mod.ts @@ -1,8 +1,5 @@ export { default as BdkVerifier } from './src/BdkVerifier.js' -export { - BdkErrorDomain, - BdkVerificationError -} from './src/BdkVerifier.js' +export { BdkErrorDomain, BdkVerificationError } from './src/BdkVerifier.js' export { mapVerifyFlags, BDK_FLAG_BITS } from './src/flags.js' export type { BdkNetwork, diff --git a/packages/verifast/package.json b/packages/verifast/package.json index 8aca9eed7..9a038d1b6 100644 --- a/packages/verifast/package.json +++ b/packages/verifast/package.json @@ -23,17 +23,31 @@ ], "exports": { ".": { - "types": "./dist/mod.d.ts", - "browser": "./dist/mod.browser.js", - "import": "./dist/mod.js", - "require": "./dist/cjs/mod.cjs", + "browser": { + "types": "./dist/mod.browser.d.ts", + "default": "./dist/mod.browser.js" + }, + "import": { + "types": "./dist/mod.d.ts", + "default": "./dist/mod.js" + }, + "require": { + "types": "./dist/mod.d.cts", + "default": "./dist/cjs/mod.cjs" + }, "default": "./dist/mod.js" }, "./umd": { - "types": "./dist/umd.d.ts", "browser": "./dist/umd/verifast.js", - "require": "./dist/umd/verifast.cjs", - "default": "./dist/umd/verifast.cjs" + "import": { + "types": "./dist/umd.d.ts", + "default": "./dist/umd.js" + }, + "require": { + "types": "./dist/umd.d.cts", + "default": "./dist/umd/verifast.cjs" + }, + "default": "./dist/umd.js" }, "./wasm/bdk-core.umd.js": "./dist/src/wasm/bdk-core.umd.js", "./wasm/bdk-core.umd.wasm": "./dist/src/wasm/bdk-core.umd.wasm", @@ -43,6 +57,9 @@ "build": "node scripts/check-artifacts.mjs && node scripts/clean.mjs && tsc -p tsconfig.build.json && node scripts/copy-wasm.mjs && node scripts/build-cjs.mjs && rspack --config rspack.config.js && node scripts/copy-umd.mjs && node scripts/check-bundle-size.mjs", "test": "pnpm build && NODE_OPTIONS=--experimental-vm-modules jest && node scripts/test-node-consumers.mjs && node scripts/test-browser-bundler.mjs", "test:coverage": "pnpm build && NODE_OPTIONS=--experimental-vm-modules jest --coverage --watchman=false", + "format:check": "pnpm --workspace-root exec prettier --check \"packages/verifast/README.md\" \"packages/verifast/*.{js,json,mjs,ts}\" \"packages/verifast/{bench,browser,scripts}/**/*.{js,json,md,mjs,ts}\" \"packages/verifast/src/{*.ts,__tests/**/*.ts,workers/**/*.ts}\"", + "lint": "oxlint mod.ts mod.browser.ts umd.ts bench browser scripts src --ignore-pattern 'src/wasm/bdk-core.*' --deny-warnings", + "pack:check": "pnpm build && node ../../scripts/check-package-artifact.mjs . --untyped-asset-entrypoints ./wasm/bdk-core.umd.js,./wasm/bdk-core.umd.wasm,./wasm/bdk-core.wasm --exports BDK_FLAG_BITS,BdkErrorDomain,BdkVerificationError,BdkVerifier,mapVerifyFlags", "check:artifacts": "node scripts/check-artifacts.mjs", "bench": "tsx bench/benchmark.ts", "bench:batch": "node --expose-gc --import tsx bench/batch-benchmark.ts", @@ -50,7 +67,8 @@ "bench:warmup": "node --import tsx bench/warmup-benchmark.ts", "test:browser": "pnpm build && node browser/test.mjs", "test:consumers": "pnpm build && node scripts/test-node-consumers.mjs && node scripts/test-browser-bundler.mjs && node browser/test.mjs", - "typecheck": "tsc -p tsconfig.json" + "typecheck": "tsc -p tsconfig.json", + "prepublishOnly": "pnpm build" }, "devDependencies": { "@bsv/sdk": "workspace:^", @@ -65,7 +83,8 @@ "ts-loader": "^9.6.2", "tsx": "^4.23.1", "typescript": "^6.0.3", - "vite": "^8.1.5" + "vite": "^8.1.5", + "oxlint": "^1.75.0" }, "peerDependencies": { "@bsv/sdk": "^2.1.8" @@ -77,5 +96,15 @@ }, "engines": { "node": ">=22" + }, + "typesVersions": { + "*": { + "umd": [ + "dist/umd.d.ts" + ], + "*": [ + "dist/mod.d.ts" + ] + } } } diff --git a/packages/verifast/scripts/build-cjs.mjs b/packages/verifast/scripts/build-cjs.mjs index ef0919bf1..0da675743 100644 --- a/packages/verifast/scripts/build-cjs.mjs +++ b/packages/verifast/scripts/build-cjs.mjs @@ -1,4 +1,4 @@ -import { mkdir, writeFile } from 'node:fs/promises' +import { mkdir, readFile, writeFile } from 'node:fs/promises' const output = new URL('../dist/cjs/mod.cjs', import.meta.url) const source = `'use strict' @@ -182,3 +182,9 @@ module.exports = { await mkdir(new URL('../dist/cjs/', import.meta.url), { recursive: true }) await writeFile(output, source) +await Promise.all( + ['mod', 'umd'].map(async entry => { + const declaration = await readFile(new URL(`../dist/${entry}.d.ts`, import.meta.url), 'utf8') + await writeFile(new URL(`../dist/${entry}.d.cts`, import.meta.url), declaration) + }) +) diff --git a/packages/verifast/scripts/check-artifacts.mjs b/packages/verifast/scripts/check-artifacts.mjs index acdf5a7d7..1243a5b03 100644 --- a/packages/verifast/scripts/check-artifacts.mjs +++ b/packages/verifast/scripts/check-artifacts.mjs @@ -13,9 +13,7 @@ for (const [file, expectedHash] of expected) { const bytes = await readFile(new URL(`../src/wasm/${file}`, import.meta.url)) const actualHash = createHash('sha256').update(bytes).digest('hex') if (actualHash !== expectedHash) { - throw new Error( - `${file} SHA-256 mismatch: expected ${expectedHash}, received ${actualHash}` - ) + throw new Error(`${file} SHA-256 mismatch: expected ${expectedHash}, received ${actualHash}`) } } diff --git a/packages/verifast/scripts/test-browser-bundler.mjs b/packages/verifast/scripts/test-browser-bundler.mjs index 118d94ac1..09891f75b 100644 --- a/packages/verifast/scripts/test-browser-bundler.mjs +++ b/packages/verifast/scripts/test-browser-bundler.mjs @@ -7,25 +7,32 @@ import { rspack } from '@rspack/core' const outputPath = await mkdtemp(join(tmpdir(), 'verifast-rspack-')) try { await new Promise((resolve, reject) => { - rspack({ - mode: 'production', - target: 'web', - entry: fileURLToPath(new URL('../dist/mod.browser.js', import.meta.url)), - output: { - path: outputPath, - filename: 'consumer.js' + rspack( + { + mode: 'production', + target: 'web', + entry: fileURLToPath(new URL('../dist/mod.browser.js', import.meta.url)), + output: { + path: outputPath, + filename: 'consumer.js' + } + }, + (error, stats) => { + if (error != null) { + reject(error) + return + } + if (stats === undefined || stats.hasErrors()) { + reject( + new Error( + stats?.toString({ all: false, errors: true }) ?? 'Rspack returned no build result' + ) + ) + return + } + resolve() } - }, (error, stats) => { - if (error != null) { - reject(error) - return - } - if (stats === undefined || stats.hasErrors()) { - reject(new Error(stats?.toString({ all: false, errors: true }) ?? 'Rspack returned no build result')) - return - } - resolve() - }) + ) }) console.log('ok - browser package export production-bundler build') } finally { diff --git a/packages/verifast/scripts/test-node-consumers.mjs b/packages/verifast/scripts/test-node-consumers.mjs index e3f5cde6e..acc73a7a6 100644 --- a/packages/verifast/scripts/test-node-consumers.mjs +++ b/packages/verifast/scripts/test-node-consumers.mjs @@ -2,16 +2,27 @@ import assert from 'node:assert/strict' import { createRequire } from 'node:module' import { MerklePath, P2PKH, PrivateKey, Script, Spend, Transaction } from '@bsv/sdk' -async function buildTransaction () { +async function buildTransaction() { const key = new PrivateKey(42) const source = new Transaction() - source.addInput({ sourceTXID: '00'.repeat(32), sourceOutputIndex: 0, unlockingScript: Script.fromASM('OP_TRUE') }) + source.addInput({ + sourceTXID: '00'.repeat(32), + sourceOutputIndex: 0, + unlockingScript: Script.fromASM('OP_TRUE') + }) source.addOutput({ satoshis: 2, lockingScript: new P2PKH().lock(key.toAddress()) }) source.merklePath = new MerklePath(777, [ - [{ offset: 0, hash: source.id('hex'), txid: true }, { offset: 1, duplicate: true }] + [ + { offset: 0, hash: source.id('hex'), txid: true }, + { offset: 1, duplicate: true } + ] ]) const tx = new Transaction() - tx.addInput({ sourceTransaction: source, sourceOutputIndex: 0, unlockingScriptTemplate: new P2PKH().unlock(key) }) + tx.addInput({ + sourceTransaction: source, + sourceOutputIndex: 0, + unlockingScriptTemplate: new P2PKH().unlock(key) + }) tx.addOutput({ satoshis: 1, lockingScript: new P2PKH().lock(key.toAddress()) }) await tx.sign() return tx @@ -20,7 +31,8 @@ async function buildTransaction () { const tx = await buildTransaction() const input = tx.inputs[0] const source = input.sourceTransaction -if (source === undefined || input.unlockingScript === undefined) throw new Error('consumer fixture is incomplete') +if (source === undefined || input.unlockingScript === undefined) + throw new Error('consumer fixture is incomplete') const spend = new Spend({ sourceTXID: source.id('hex'), sourceOutputIndex: input.sourceOutputIndex, @@ -39,32 +51,71 @@ const esm = await import('@bsv/verifast') const require = createRequire(import.meta.url) const cjs = require('@bsv/verifast') -for (const [name, api] of [['ESM', esm], ['CommonJS', cjs]]) { +for (const [name, api] of [ + ['ESM', esm], + ['CommonJS', cjs] +]) { const verifier = new api.BdkVerifier() assert.equal(verifier.isReady(), false, `${name} lazy readiness`) - assert.equal(verifier.shouldVerifyScripts({ tx, blockHeight: 800000, consensus: true }), false, `${name} cold P2PKH fallback`) + assert.equal( + verifier.shouldVerifyScripts({ tx, blockHeight: 800000, consensus: true }), + false, + `${name} cold P2PKH fallback` + ) await verifier.preload() assert.equal(verifier.isReady(), true, `${name} preloaded readiness`) - assert.equal(verifier.shouldVerifyScripts({ tx, blockHeight: 800000, consensus: true }), true, `${name} P2PKH auto selection`) + assert.equal( + verifier.shouldVerifyScripts({ tx, blockHeight: 800000, consensus: true }), + true, + `${name} P2PKH auto selection` + ) const sourceLock = source.outputs[input.sourceOutputIndex].lockingScript const transactionVersion = tx.version tx.version = 2 - source.outputs[input.sourceOutputIndex].lockingScript = Script.fromBinaryView(new Uint8Array(100).fill(0x51)) - assert.equal(verifier.shouldVerifyScripts({ tx, blockHeight: 800000, consensus: true }), false, `${name} 100-byte boundary`) - source.outputs[input.sourceOutputIndex].lockingScript = Script.fromBinaryView(new Uint8Array(101).fill(0x51)) - assert.equal(verifier.shouldVerifyScripts({ tx, blockHeight: 800000, consensus: true }), true, `${name} 101-byte boundary`) + source.outputs[input.sourceOutputIndex].lockingScript = Script.fromBinaryView( + new Uint8Array(100).fill(0x51) + ) + assert.equal( + verifier.shouldVerifyScripts({ tx, blockHeight: 800000, consensus: true }), + false, + `${name} 100-byte boundary` + ) + source.outputs[input.sourceOutputIndex].lockingScript = Script.fromBinaryView( + new Uint8Array(101).fill(0x51) + ) + assert.equal( + verifier.shouldVerifyScripts({ tx, blockHeight: 800000, consensus: true }), + true, + `${name} 101-byte boundary` + ) source.outputs[input.sourceOutputIndex].lockingScript = sourceLock tx.version = transactionVersion - assert.equal(await tx.verify('scripts only', undefined, undefined, verifier), true, `${name} SDK auto route`) - assert.equal(await verifier.verifyScripts({ tx, blockHeight: 800000, consensus: true }), true, name) - assert.equal(await verifier.verifyScriptsFromEF({ - extendedTransaction: tx.toEFBinary(), - utxoHeights: [777], - blockHeight: 800000, - consensus: true - }), true, `${name} pre-serialized EF`) + assert.equal( + await tx.verify('scripts only', undefined, undefined, verifier), + true, + `${name} SDK auto route` + ) + assert.equal( + await verifier.verifyScripts({ tx, blockHeight: 800000, consensus: true }), + true, + name + ) + assert.equal( + await verifier.verifyScriptsFromEF({ + extendedTransaction: tx.toEFBinary(), + utxoHeights: [777], + blockHeight: 800000, + consensus: true + }), + true, + `${name} pre-serialized EF` + ) assert.equal(await verifier.verifySpend(spend), true, `${name} Spend`) - assert.deepEqual(await verifier.verifySpendsBatch([{ spend }, { spend }]), [true, true], `${name} Spend batch`) + assert.deepEqual( + await verifier.verifySpendsBatch([{ spend }, { spend }]), + [true, true], + `${name} Spend batch` + ) assert.equal(api.BdkErrorDomain.OK, 0, `${name} enum name`) assert.equal(api.BdkErrorDomain[0], 'OK', `${name} enum reverse mapping`) verifier.dispose() @@ -78,9 +129,7 @@ const workerVerifier = new esm.BdkVerifier({ }) await workerVerifier.preloadBatch() assert.deepEqual( - await workerVerifier.verifySpendsBatch( - Array.from({ length: 5 }, () => ({ spend })) - ), + await workerVerifier.verifySpendsBatch(Array.from({ length: 5 }, () => ({ spend }))), [true, true, true, true, true], 'ESM real Node worker batch' ) diff --git a/packages/verifast/src/BdkBatch.ts b/packages/verifast/src/BdkBatch.ts index f0a1099a5..f75c6320b 100644 --- a/packages/verifast/src/BdkBatch.ts +++ b/packages/verifast/src/BdkBatch.ts @@ -10,7 +10,7 @@ export interface PackedArrays { offsets: Uint32Array } -export function flagsForInputCount ( +export function flagsForInputCount( inputCount: number, verifyFlags?: string | string[], customFlags?: readonly number[] | Uint32Array @@ -25,7 +25,7 @@ export function flagsForInputCount ( return new Uint32Array(inputCount).fill(mapVerifyFlags(verifyFlags)) } -export function packArrays ( +export function packArrays( arrays: readonly T[], make: (length: number) => T ): PackedArrays { @@ -45,10 +45,7 @@ export function packArrays ( return { values, offsets } } -export function decodeResults ( - flat: Int32Array, - count: number -): BdkVerificationResult[] { +export function decodeResults(flat: Int32Array, count: number): BdkVerificationResult[] { if (flat.length !== count * 2) { throw new BdkVerificationError({ domain: BdkErrorDomain.EXCEPTION, code: 0 }) } @@ -58,7 +55,7 @@ export function decodeResults ( })) } -export function verdict (result: BdkVerificationResult): boolean { +export function verdict(result: BdkVerificationResult): boolean { if (result.domain === BdkErrorDomain.OK) return true if (result.domain === BdkErrorDomain.SCRIPT || result.domain === BdkErrorDomain.DOS) return false throw new BdkVerificationError(result) diff --git a/packages/verifast/src/BdkVerifier.browser.ts b/packages/verifast/src/BdkVerifier.browser.ts index 31f6728b3..9a9087dd7 100644 --- a/packages/verifast/src/BdkVerifier.browser.ts +++ b/packages/verifast/src/BdkVerifier.browser.ts @@ -1,27 +1,21 @@ import createBdkModule from './wasm/bdk-core.browser.mjs' -import BdkVerifierCore, { - type BdkVerifierOptions, - type BdkWasmFactory -} from './BdkVerifierCore.js' +import BdkVerifierCore, { type BdkVerifierOptions, type BdkWasmFactory } from './BdkVerifierCore.js' import BdkWorkerPool, { type WorkerAdapter } from './workers/BdkWorkerPool.js' import BdkWorkerScheduler from './workers/BdkWorkerScheduler.js' -import type { - BdkWorkerRequest, - BdkWorkerResponse -} from './workers/BdkWorkerProtocol.js' +import type { BdkWorkerRequest, BdkWorkerResponse } from './workers/BdkWorkerProtocol.js' export * from './BdkVerifierCore.js' -const createBundledModule: BdkWasmFactory = async () => await createBdkModule({ - locateFile: (path: string, prefix: string): string => - path.endsWith('.wasm') ? `${prefix}bdk-core.wasm` : `${prefix}${path}` -}) +const createBundledModule: BdkWasmFactory = async () => + await createBdkModule({ + locateFile: (path: string, prefix: string): string => + path.endsWith('.wasm') ? `${prefix}bdk-core.wasm` : `${prefix}${path}` + }) -function createBrowserWorker (): WorkerAdapter { - const worker = new Worker( - new URL('./workers/BdkVerifierBrowserWorker.js', import.meta.url), - { type: 'module' } - ) +function createBrowserWorker(): WorkerAdapter { + const worker = new Worker(new URL('./workers/BdkVerifierBrowserWorker.js', import.meta.url), { + type: 'module' + }) return { post: (request: BdkWorkerRequest, transfer: ArrayBuffer[]) => { worker.postMessage(request, transfer) @@ -33,13 +27,13 @@ function createBrowserWorker (): WorkerAdapter { worker.onerror = event => handler(new Error(event.message)) }, onExit: () => {}, - terminate: () => { worker.terminate() } + terminate: () => { + worker.terminate() + } } } -function createBrowserWorkerPool ( - options: BdkVerifierOptions -): BdkWorkerScheduler | undefined { +function createBrowserWorkerPool(options: BdkVerifierOptions): BdkWorkerScheduler | undefined { if ( options.batchWorkers !== undefined && (!Number.isSafeInteger(options.batchWorkers) || @@ -50,8 +44,7 @@ function createBrowserWorkerPool ( } if (typeof Worker === 'undefined') return undefined const logicalCores = globalThis.navigator?.hardwareConcurrency ?? 1 - const workerCount = options.batchWorkers ?? - Math.max(1, Math.min(4, Math.floor(logicalCores / 4))) + const workerCount = options.batchWorkers ?? Math.max(1, Math.min(4, Math.floor(logicalCores / 4))) if (workerCount <= 1) return undefined return new BdkWorkerScheduler( onFailure => new BdkWorkerPool(workerCount, createBrowserWorker, onFailure), @@ -61,15 +54,14 @@ function createBrowserWorkerPool ( /** Browser/worker BDK verifier using glue with no Node imports. */ export default class BdkVerifier extends BdkVerifierCore { - constructor (factoryOrOptions: BdkWasmFactory | BdkVerifierOptions = {}, options: BdkVerifierOptions = {}) { + constructor( + factoryOrOptions: BdkWasmFactory | BdkVerifierOptions = {}, + options: BdkVerifierOptions = {} + ) { if (typeof factoryOrOptions === 'function') { super(factoryOrOptions, options) } else { - super( - createBundledModule, - factoryOrOptions, - createBrowserWorkerPool(factoryOrOptions) - ) + super(createBundledModule, factoryOrOptions, createBrowserWorkerPool(factoryOrOptions)) } } } diff --git a/packages/verifast/src/BdkVerifier.ts b/packages/verifast/src/BdkVerifier.ts index 79f6ed045..ad4605607 100644 --- a/packages/verifast/src/BdkVerifier.ts +++ b/packages/verifast/src/BdkVerifier.ts @@ -1,23 +1,15 @@ import createBdkModule from './wasm/bdk-core.mjs' import { availableParallelism } from 'node:os' import { Worker as NodeWorker } from 'node:worker_threads' -import BdkVerifierCore, { - type BdkVerifierOptions, - type BdkWasmFactory -} from './BdkVerifierCore.js' +import BdkVerifierCore, { type BdkVerifierOptions, type BdkWasmFactory } from './BdkVerifierCore.js' import BdkWorkerPool, { type WorkerAdapter } from './workers/BdkWorkerPool.js' import BdkWorkerScheduler from './workers/BdkWorkerScheduler.js' -import type { - BdkWorkerRequest, - BdkWorkerResponse -} from './workers/BdkWorkerProtocol.js' +import type { BdkWorkerRequest, BdkWorkerResponse } from './workers/BdkWorkerProtocol.js' export * from './BdkVerifierCore.js' -function createNodeWorker (): WorkerAdapter { - const worker = new NodeWorker( - new URL('./workers/BdkVerifierNodeWorker.js', import.meta.url) - ) +function createNodeWorker(): WorkerAdapter { + const worker = new NodeWorker(new URL('./workers/BdkVerifierNodeWorker.js', import.meta.url)) let activeRequests = 0 worker.unref() return { @@ -52,13 +44,13 @@ function createNodeWorker (): WorkerAdapter { handler(new Error(`BDK worker exited unexpectedly with code ${code}`)) }) }, - terminate: () => { void worker.terminate() } + terminate: () => { + void worker.terminate() + } } } -function createNodeWorkerPool ( - options: BdkVerifierOptions -): BdkWorkerScheduler | undefined { +function createNodeWorkerPool(options: BdkVerifierOptions): BdkWorkerScheduler | undefined { if ( options.batchWorkers !== undefined && (!Number.isSafeInteger(options.batchWorkers) || @@ -67,8 +59,8 @@ function createNodeWorkerPool ( ) { throw new RangeError('batchWorkers must be a safe integer from 1 to 16') } - const workerCount = options.batchWorkers ?? - Math.max(1, Math.min(4, Math.floor(availableParallelism() / 4))) + const workerCount = + options.batchWorkers ?? Math.max(1, Math.min(4, Math.floor(availableParallelism() / 4))) if (workerCount <= 1) return undefined return new BdkWorkerScheduler( onFailure => new BdkWorkerPool(workerCount, createNodeWorker, onFailure), @@ -78,15 +70,14 @@ function createNodeWorkerPool ( /** Node.js BDK verifier using the Node-only Emscripten loader. */ export default class BdkVerifier extends BdkVerifierCore { - constructor (factoryOrOptions: BdkWasmFactory | BdkVerifierOptions = {}, options: BdkVerifierOptions = {}) { + constructor( + factoryOrOptions: BdkWasmFactory | BdkVerifierOptions = {}, + options: BdkVerifierOptions = {} + ) { if (typeof factoryOrOptions === 'function') { super(factoryOrOptions, options) } else { - super( - createBdkModule, - factoryOrOptions, - createNodeWorkerPool(factoryOrOptions) - ) + super(createBdkModule, factoryOrOptions, createNodeWorkerPool(factoryOrOptions)) } } } diff --git a/packages/verifast/src/BdkVerifierCore.ts b/packages/verifast/src/BdkVerifierCore.ts index d409f2b79..b8330ec88 100644 --- a/packages/verifast/src/BdkVerifierCore.ts +++ b/packages/verifast/src/BdkVerifierCore.ts @@ -4,12 +4,7 @@ import type { Spend, SpendVerificationContext } from '@bsv/sdk' -import { - decodeResults, - flagsForInputCount, - packArrays, - verdict -} from './BdkBatch.js' +import { decodeResults, flagsForInputCount, packArrays, verdict } from './BdkBatch.js' import type BdkVerifierInterface from './BdkVerifierInterface.js' import { mapVerifyFlags } from './flags.js' import { @@ -52,7 +47,7 @@ const NETWORK_IDS: Record = { tstn: 5 } -function toVector (Vector: EmbindVectorCtor, values: Iterable): EmbindVector { +function toVector(Vector: EmbindVectorCtor, values: Iterable): EmbindVector { const vec = new Vector() for (const value of values) vec.push_back(value) return vec @@ -63,7 +58,7 @@ interface OptionalBackendGlobal { __bsvSdkScriptVerificationBackendV1?: BdkVerifierCore } -function backendGlobal (): typeof globalThis & OptionalBackendGlobal { +function backendGlobal(): typeof globalThis & OptionalBackendGlobal { return globalThis as typeof globalThis & OptionalBackendGlobal } @@ -87,7 +82,7 @@ export default class BdkVerifierCore implements BdkVerifierInterface, AsyncCrypt private modulePrepared = false private disposed = false - constructor ( + constructor( private readonly factory: BdkWasmFactory, options: BdkVerifierOptions = {}, private readonly workerScheduler?: BdkWorkerScheduler @@ -134,25 +129,29 @@ export default class BdkVerifierCore implements BdkVerifierInterface, AsyncCrypt } } - private async getModule (): Promise { + private async getModule(): Promise { if (this.disposed) throw new Error('BDK verifier has been disposed') if (this.module !== undefined) return this.module if (this.loading === undefined) { - const loading = Promise.resolve().then(async () => await this.factory()).then(module => { - if (this.disposed) throw new Error('BDK verifier has been disposed') - this.module = module - return module - }) + const loading = Promise.resolve() + .then(async () => await this.factory()) + .then(module => { + if (this.disposed) throw new Error('BDK verifier has been disposed') + this.module = module + return module + }) this.loading = loading - void loading.finally(() => { - if (this.loading === loading) this.loading = undefined - }).catch(() => {}) + void loading + .finally(() => { + if (this.loading === loading) this.loading = undefined + }) + .catch(() => {}) } return await this.loading } /** Load and instantiate the optional backend before latency-sensitive work. */ - async preload (): Promise { + async preload(): Promise { const module = await this.getModule() if (this.modulePrepared) return module.PrepareVerification?.() @@ -164,7 +163,7 @@ export default class BdkVerifierCore implements BdkVerifierInterface, AsyncCrypt * Warm both the main module and the explicit large-batch worker pool. * Single-item verification never waits for or dispatches through this pool. */ - async preloadBatch (): Promise { + async preloadBatch(): Promise { await this.preload() if (this.workerScheduler !== undefined && this.module !== undefined) { await this.workerScheduler.preload(this.module) @@ -172,12 +171,12 @@ export default class BdkVerifierCore implements BdkVerifierInterface, AsyncCrypt } /** True only after the WASM module has finished loading successfully. */ - isReady (): boolean { + isReady(): boolean { return !this.disposed && this.module !== undefined } /** Stop using this instance as the SDK's optional default backend. */ - dispose (): void { + dispose(): void { if (this.disposed) return this.disposed = true this.workerScheduler?.terminate() @@ -194,22 +193,35 @@ export default class BdkVerifierCore implements BdkVerifierInterface, AsyncCrypt } } - supportsCrypto (operation: AsyncCryptoOperation): boolean { + supportsCrypto(operation: AsyncCryptoOperation): boolean { const bdk = this.module if (bdk === undefined) return false switch (operation) { - case 'signDigest': return bdk.SignDigest !== undefined - case 'verifyDigest': return bdk.VerifyDigest !== undefined - case 'verifyDigestBatch': return bdk.VerifyDigestBatchArray !== undefined - case 'publicKeyFromPrivate': return bdk.PublicKeyFromPrivate !== undefined - case 'multiplyPublicKey': return bdk.MultiplyPublicKey !== undefined - case 'tweakPublicKeyAdd': return bdk.TweakPublicKeyAdd !== undefined - case 'tweakPrivateKeyAdd': return bdk.TweakPrivateKeyAdd !== undefined - } - } - - private schedulePreload (): void { - if (this.disposed || this.module !== undefined || this.loading !== undefined || this.preloadScheduled) return + case 'signDigest': + return bdk.SignDigest !== undefined + case 'verifyDigest': + return bdk.VerifyDigest !== undefined + case 'verifyDigestBatch': + return bdk.VerifyDigestBatchArray !== undefined + case 'publicKeyFromPrivate': + return bdk.PublicKeyFromPrivate !== undefined + case 'multiplyPublicKey': + return bdk.MultiplyPublicKey !== undefined + case 'tweakPublicKeyAdd': + return bdk.TweakPublicKeyAdd !== undefined + case 'tweakPrivateKeyAdd': + return bdk.TweakPrivateKeyAdd !== undefined + } + } + + private schedulePreload(): void { + if ( + this.disposed || + this.module !== undefined || + this.loading !== undefined || + this.preloadScheduled + ) + return this.preloadScheduled = true setTimeout(() => { this.preloadScheduled = false @@ -217,7 +229,7 @@ export default class BdkVerifierCore implements BdkVerifierInterface, AsyncCrypt }, 0) } - private prepareCandidate (): boolean { + private prepareCandidate(): boolean { if (this.disposed) return false if (this.mode === 'always') return true if (this.isReady()) return true @@ -228,46 +240,48 @@ export default class BdkVerifierCore implements BdkVerifierInterface, AsyncCrypt } /** Selection hook consumed by Transaction.verify without coupling the SDK to this package. */ - shouldVerifyScripts (params: BdkVerifyParams): boolean { + shouldVerifyScripts(params: BdkVerifyParams): boolean { if (params.memoryLimit !== undefined) return false if (this.mode === 'always') return !this.disposed - const sourceOutputs = params.tx.inputs.map(input => - input.sourceTransaction?.outputs[input.sourceOutputIndex] + const sourceOutputs = params.tx.inputs.map( + input => input.sourceTransaction?.outputs[input.sourceOutputIndex] ) if ( !params.consensus && params.tx.version <= 1 && - sourceOutputs.some(output => - output === undefined || !isStandardP2PKHScript(output.lockingScript) + sourceOutputs.some( + output => output === undefined || !isStandardP2PKHScript(output.lockingScript) ) ) { return false } const candidate = sourceOutputs.some(sourceOutput => { - return sourceOutput !== undefined && + return ( + sourceOutput !== undefined && isVeriFastCandidateScript(sourceOutput.lockingScript, this.scriptByteThreshold) + ) }) return candidate && this.prepareCandidate() } /** Selection hook consumed by Spend.validateWith. */ - shouldVerifySpend ( - spend: Spend, - context?: SpendVerificationContext - ): boolean { + shouldVerifySpend(spend: Spend, context?: SpendVerificationContext): boolean { if (spend.hasExplicitMemoryLimit) return false if (this.mode === 'always') return !this.disposed if ( context?.consensus !== true && spend.transactionVersion <= 1 && !isStandardP2PKHScript(spend.lockingScript) - ) return false + ) + return false if (this.module !== undefined && this.module.VerifySpendArray === undefined) return false - return isVeriFastCandidateScript(spend.lockingScript, this.scriptByteThreshold) && + return ( + isVeriFastCandidateScript(spend.lockingScript, this.scriptByteThreshold) && this.prepareCandidate() + ) } - private transactionParams (params: BdkVerifyParams): BdkVerifyFromEFParams { + private transactionParams(params: BdkVerifyParams): BdkVerifyFromEFParams { if (params.memoryLimit !== undefined) { throw new Error('VeriFast cannot enforce a custom script memory limit') } @@ -282,7 +296,7 @@ export default class BdkVerifierCore implements BdkVerifierInterface, AsyncCrypt } } - private verifyFromEFWithModule ( + private verifyFromEFWithModule( bdk: BdkWasmModule, params: BdkVerifyFromEFParams ): BdkVerificationResult { @@ -291,8 +305,12 @@ export default class BdkVerifierCore implements BdkVerifierInterface, AsyncCrypt if (bdk.VerifyScriptArrayNetwork !== undefined) { return bdk.VerifyScriptArrayNetwork( - params.extendedTransaction, heights, params.blockHeight, - params.consensus, customFlags, this.network + params.extendedTransaction, + heights, + params.blockHeight, + params.consensus, + customFlags, + this.network ) } if (this.network !== NETWORK_IDS.main) { @@ -300,8 +318,11 @@ export default class BdkVerifierCore implements BdkVerifierInterface, AsyncCrypt } if (bdk.VerifyScriptArray !== undefined) { return bdk.VerifyScriptArray( - params.extendedTransaction, heights, params.blockHeight, - params.consensus, customFlags + params.extendedTransaction, + heights, + params.blockHeight, + params.consensus, + customFlags ) } @@ -321,9 +342,7 @@ export default class BdkVerifierCore implements BdkVerifierInterface, AsyncCrypt const utxoHeights = toVector(VectorInt32, heights) const flags = toVector(VectorUInt32, customFlags) try { - return verifyScript( - extendedTX, utxoHeights, params.blockHeight, params.consensus, flags - ) + return verifyScript(extendedTX, utxoHeights, params.blockHeight, params.consensus, flags) } finally { extendedTX.delete() utxoHeights.delete() @@ -331,30 +350,35 @@ export default class BdkVerifierCore implements BdkVerifierInterface, AsyncCrypt } } - async verifyScriptsDetailed (params: BdkVerifyParams): Promise { + async verifyScriptsDetailed(params: BdkVerifyParams): Promise { return await this.verifyScriptsFromEFDetailed(this.transactionParams(params)) } - async verifyScriptsFromEFDetailed (params: BdkVerifyFromEFParams): Promise { + async verifyScriptsFromEFDetailed(params: BdkVerifyFromEFParams): Promise { return this.verifyFromEFWithModule(await this.getModule(), params) } - async verifyScripts (params: BdkVerifyParams): Promise { + async verifyScripts(params: BdkVerifyParams): Promise { return verdict(await this.verifyScriptsDetailed(params)) } - async verifyScriptsFromEF (params: BdkVerifyFromEFParams): Promise { + async verifyScriptsFromEF(params: BdkVerifyFromEFParams): Promise { return verdict(await this.verifyScriptsFromEFDetailed(params)) } - private chunkEFParams (params: readonly BdkVerifyFromEFParams[]): BdkVerifyFromEFParams[][] { + private chunkEFParams(params: readonly BdkVerifyFromEFParams[]): BdkVerifyFromEFParams[][] { const chunks: BdkVerifyFromEFParams[][] = [] let chunk: BdkVerifyFromEFParams[] = [] let bytes = 0 for (const item of params) { - const itemBytes = item.extendedTransaction.byteLength + item.utxoHeights.length * 4 + + const itemBytes = + item.extendedTransaction.byteLength + + item.utxoHeights.length * 4 + (item.customFlags?.length ?? 0) * 4 - if (chunk.length > 0 && (chunk.length >= this.maxBatchItems || bytes + itemBytes > this.maxBatchBytes)) { + if ( + chunk.length > 0 && + (chunk.length >= this.maxBatchItems || bytes + itemBytes > this.maxBatchBytes) + ) { chunks.push(chunk) chunk = [] bytes = 0 @@ -366,10 +390,11 @@ export default class BdkVerifierCore implements BdkVerifierInterface, AsyncCrypt return chunks } - private packEFChunk ( - chunk: readonly BdkVerifyFromEFParams[] - ): ScriptBatchPayload { - const transactions = packArrays(chunk.map(item => item.extendedTransaction), length => new Uint8Array(length)) + private packEFChunk(chunk: readonly BdkVerifyFromEFParams[]): ScriptBatchPayload { + const transactions = packArrays( + chunk.map(item => item.extendedTransaction), + length => new Uint8Array(length) + ) const heightsByItem = chunk.map(item => Int32Array.from(item.utxoHeights)) const heights = packArrays(heightsByItem, length => new Int32Array(length)) const flagsByItem = chunk.map((item, index) => @@ -382,14 +407,17 @@ export default class BdkVerifierCore implements BdkVerifierInterface, AsyncCrypt utxoHeights: heights.values, heightOffsets: heights.offsets, blockHeights: Int32Array.from(chunk.map(item => item.blockHeight)), - consensus: Uint8Array.from(chunk.map(item => item.consensus ? 1 : 0)), + consensus: Uint8Array.from(chunk.map(item => (item.consensus ? 1 : 0))), customFlags: flags.values, customFlagOffsets: flags.offsets, network: this.network } } - private verifyEFChunk (bdk: BdkWasmModule, chunk: readonly BdkVerifyFromEFParams[]): BdkVerificationResult[] { + private verifyEFChunk( + bdk: BdkWasmModule, + chunk: readonly BdkVerifyFromEFParams[] + ): BdkVerificationResult[] { if (bdk.VerifyScriptBatchArray === undefined) { return chunk.map(params => this.verifyFromEFWithModule(bdk, params)) } @@ -408,26 +436,35 @@ export default class BdkVerifierCore implements BdkVerifierInterface, AsyncCrypt return decodeResults(flat, chunk.length) } - async verifyScriptsBatchDetailed (params: readonly BdkVerifyParams[]): Promise { - return await this.verifyScriptsBatchFromEFDetailed(params.map(item => this.transactionParams(item))) + async verifyScriptsBatchDetailed( + params: readonly BdkVerifyParams[] + ): Promise { + return await this.verifyScriptsBatchFromEFDetailed( + params.map(item => this.transactionParams(item)) + ) } - async verifyScriptsBatchFromEFDetailed (params: readonly BdkVerifyFromEFParams[]): Promise { + async verifyScriptsBatchFromEFDetailed( + params: readonly BdkVerifyFromEFParams[] + ): Promise { if (params.length === 0) return [] - if (this.workerScheduler?.shouldUse( - params.length, async () => await this.preloadBatch() - ) === true) { + if ( + this.workerScheduler?.shouldUse(params.length, async () => await this.preloadBatch()) === true + ) { const chunks = this.workerScheduler.parallelChunks( params, - item => item.extendedTransaction.byteLength + + item => + item.extendedTransaction.byteLength + item.utxoHeights.length * 4 + (item.customFlags?.length ?? 0) * 4 ) if (chunks.length > 1) { - const results = await this.workerScheduler.execute(chunks.map(chunk => ({ - operation: 'verifyScripts' as const, - payload: this.packEFChunk(chunk) - }))) + const results = await this.workerScheduler.execute( + chunks.map(chunk => ({ + operation: 'verifyScripts' as const, + payload: this.packEFChunk(chunk) + })) + ) return results.flatMap((result, index) => { if (!(result instanceof Int32Array)) { throw new Error('BDK script worker returned an invalid result type') @@ -440,15 +477,15 @@ export default class BdkVerifierCore implements BdkVerifierInterface, AsyncCrypt return this.chunkEFParams(params).flatMap(chunk => this.verifyEFChunk(bdk, chunk)) } - async verifyScriptsBatch (params: readonly BdkVerifyParams[]): Promise { + async verifyScriptsBatch(params: readonly BdkVerifyParams[]): Promise { return (await this.verifyScriptsBatchDetailed(params)).map(verdict) } - async verifyScriptsBatchFromEF (params: readonly BdkVerifyFromEFParams[]): Promise { + async verifyScriptsBatchFromEF(params: readonly BdkVerifyFromEFParams[]): Promise { return (await this.verifyScriptsBatchFromEFDetailed(params)).map(verdict) } - private spendContext ( + private spendContext( spend: Spend, options: BdkVerifySpendOptions = {}, transaction?: Uint8Array @@ -456,7 +493,8 @@ export default class BdkVerifierCore implements BdkVerifierInterface, AsyncCrypt if (!Number.isSafeInteger(spend.sourceSatoshis) || spend.sourceSatoshis < 0) { throw new RangeError('sourceSatoshis must be a non-negative safe integer') } - const verifyFlags = options.verifyFlags ?? (spend.verifyFlags === undefined ? undefined : [...spend.verifyFlags]) + const verifyFlags = + options.verifyFlags ?? (spend.verifyFlags === undefined ? undefined : [...spend.verifyFlags]) return { transaction: transaction ?? spend.toTransactionUint8Array(), lockingScript: spend.lockingScript.toUint8Array(), @@ -467,7 +505,7 @@ export default class BdkVerifierCore implements BdkVerifierInterface, AsyncCrypt } } - private verifySpendWithModule ( + private verifySpendWithModule( bdk: BdkWasmModule, spend: Spend, options: BdkVerifySpendOptions = {} @@ -490,22 +528,25 @@ export default class BdkVerifierCore implements BdkVerifierInterface, AsyncCrypt ) } - async verifySpendDetailed (spend: Spend, options: BdkVerifySpendOptions = {}): Promise { + async verifySpendDetailed( + spend: Spend, + options: BdkVerifySpendOptions = {} + ): Promise { return this.verifySpendWithModule(await this.getModule(), spend, options) } - async verifySpend (spend: Spend, options: BdkVerifySpendOptions = {}): Promise { + async verifySpend(spend: Spend, options: BdkVerifySpendOptions = {}): Promise { return verdict(await this.verifySpendDetailed(spend, options)) } - verifySpendSync (spend: Spend, options: BdkVerifySpendOptions = {}): boolean { + verifySpendSync(spend: Spend, options: BdkVerifySpendOptions = {}): boolean { if (this.module === undefined) { throw new Error('Synchronous Spend verification requires a preloaded BDK module') } return verdict(this.verifySpendWithModule(this.module, spend, options)) } - private packSpendChunk ( + private packSpendChunk( items: readonly BdkSpendBatchItem[], contexts: readonly BdkSpendContext[] ): SpendBatchPayload { @@ -526,16 +567,18 @@ export default class BdkVerifierCore implements BdkVerifierInterface, AsyncCrypt sourceSatoshis: Float64Array.from(items.map(item => item.spend.sourceSatoshis)), utxoHeights: Int32Array.from(contexts.map(item => item.utxoHeight)), blockHeights: Int32Array.from(contexts.map(item => item.blockHeight)), - consensus: Uint8Array.from(contexts.map(item => item.consensus ? 1 : 0)), + consensus: Uint8Array.from(contexts.map(item => (item.consensus ? 1 : 0))), hasCustomFlags: Uint8Array.from( - contexts.map(item => item.customFlags === undefined ? 0 : 1) + contexts.map(item => (item.customFlags === undefined ? 0 : 1)) ), customFlags: Uint32Array.from(contexts.map(item => item.customFlags ?? 0)), network: this.network } } - async verifySpendsBatchDetailed (items: readonly BdkSpendBatchItem[]): Promise { + async verifySpendsBatchDetailed( + items: readonly BdkSpendBatchItem[] + ): Promise { if (items.length === 0) return [] const serializedTransactions: Array<{ inputs: NonNullable @@ -546,14 +589,16 @@ export default class BdkVerifierCore implements BdkVerifierInterface, AsyncCrypt }> = [] const allContexts = items.map(item => { const spend = item.spend - const existing = spend.allInputs === undefined - ? undefined - : serializedTransactions.find(candidate => - candidate.inputs === spend.allInputs && - candidate.outputs === spend.outputs && - candidate.version === spend.transactionVersion && - candidate.lockTime === spend.lockTime - ) + const existing = + spend.allInputs === undefined + ? undefined + : serializedTransactions.find( + candidate => + candidate.inputs === spend.allInputs && + candidate.outputs === spend.outputs && + candidate.version === spend.transactionVersion && + candidate.lockTime === spend.lockTime + ) const transaction = existing?.bytes ?? spend.toTransactionUint8Array() if (existing === undefined && spend.allInputs !== undefined) { serializedTransactions.push({ @@ -566,26 +611,27 @@ export default class BdkVerifierCore implements BdkVerifierInterface, AsyncCrypt } return this.spendContext(spend, item, transaction) }) - if (this.workerScheduler?.shouldUse( - items.length, async () => await this.preloadBatch() - ) === true) { + if ( + this.workerScheduler?.shouldUse(items.length, async () => await this.preloadBatch()) === true + ) { const indexedItems = items.map((item, index) => ({ item, context: allContexts[index] })) const chunks = this.workerScheduler.parallelChunks( indexedItems, - entry => entry.context.transaction.byteLength + - entry.context.lockingScript.byteLength + 32 + entry => entry.context.transaction.byteLength + entry.context.lockingScript.byteLength + 32 ) if (chunks.length > 1) { - const results = await this.workerScheduler.execute(chunks.map(chunk => ({ - operation: 'verifySpends' as const, - payload: this.packSpendChunk( - chunk.map(entry => entry.item), - chunk.map(entry => entry.context) - ) - }))) + const results = await this.workerScheduler.execute( + chunks.map(chunk => ({ + operation: 'verifySpends' as const, + payload: this.packSpendChunk( + chunk.map(entry => entry.item), + chunk.map(entry => entry.context) + ) + })) + ) return results.flatMap((result, index) => { if (!(result instanceof Int32Array)) { throw new Error('BDK Spend worker returned an invalid result type') @@ -632,7 +678,10 @@ export default class BdkVerifierCore implements BdkVerifierInterface, AsyncCrypt const item = items[index] const context = allContexts[index] const itemBytes = context.transaction.byteLength + context.lockingScript.byteLength + 32 - if (chunk.length > 0 && (chunk.length >= this.maxBatchItems || chunkBytes + itemBytes > this.maxBatchBytes)) { + if ( + chunk.length > 0 && + (chunk.length >= this.maxBatchItems || chunkBytes + itemBytes > this.maxBatchBytes) + ) { flush() } chunk.push(item) @@ -643,11 +692,11 @@ export default class BdkVerifierCore implements BdkVerifierInterface, AsyncCrypt return results } - async verifySpendsBatch (items: readonly BdkSpendBatchItem[]): Promise { + async verifySpendsBatch(items: readonly BdkSpendBatchItem[]): Promise { return (await this.verifySpendsBatchDetailed(items)).map(verdict) } - private requiredCryptoMethod ( + private requiredCryptoMethod( bdk: BdkWasmModule, method: K ): NonNullable { @@ -658,25 +707,21 @@ export default class BdkVerifierCore implements BdkVerifierInterface, AsyncCrypt return implementation as NonNullable } - async signDigest (privateKey: Uint8Array, digest: Uint8Array): Promise { + async signDigest(privateKey: Uint8Array, digest: Uint8Array): Promise { const bdk = await this.getModule() return this.requiredCryptoMethod(bdk, 'SignDigest')(privateKey, digest) } - async verifyDigest ( + async verifyDigest( publicKey: Uint8Array, digest: Uint8Array, signature: Uint8Array ): Promise { const bdk = await this.getModule() - return this.requiredCryptoMethod(bdk, 'VerifyDigest')( - publicKey, digest, signature - ) + return this.requiredCryptoMethod(bdk, 'VerifyDigest')(publicKey, digest, signature) } - private packDigestBatch ( - items: readonly BdkDigestVerification[] - ): DigestBatchPayload { + private packDigestBatch(items: readonly BdkDigestVerification[]): DigestBatchPayload { const publicKeys = packArrays( items.map(item => item.publicKey), length => new Uint8Array(length) @@ -701,24 +746,22 @@ export default class BdkVerifierCore implements BdkVerifierInterface, AsyncCrypt } } - async verifyDigestBatch ( - items: readonly BdkDigestVerification[] - ): Promise { + async verifyDigestBatch(items: readonly BdkDigestVerification[]): Promise { if (items.length === 0) return [] - if (this.workerScheduler?.shouldUse( - items.length, async () => await this.preloadBatch() - ) === true) { + if ( + this.workerScheduler?.shouldUse(items.length, async () => await this.preloadBatch()) === true + ) { const chunks = this.workerScheduler.parallelChunks( items, - item => item.publicKey.byteLength + - item.digest.byteLength + - item.signature.byteLength + item => item.publicKey.byteLength + item.digest.byteLength + item.signature.byteLength ) if (chunks.length > 1) { - const results = await this.workerScheduler.execute(chunks.map(chunk => ({ - operation: 'verifyDigests' as const, - payload: this.packDigestBatch(chunk) - }))) + const results = await this.workerScheduler.execute( + chunks.map(chunk => ({ + operation: 'verifyDigests' as const, + payload: this.packDigestBatch(chunk) + })) + ) return results.flatMap((result, index) => { if (!(result instanceof Uint8Array) || result.length !== chunks[index].length) { throw new Error('BDK digest worker returned an invalid result') @@ -731,10 +774,12 @@ export default class BdkVerifierCore implements BdkVerifierInterface, AsyncCrypt let chunk: BdkDigestVerification[] = [] let chunkBytes = 0 for (const item of items) { - const itemBytes = item.publicKey.byteLength + - item.digest.byteLength + item.signature.byteLength - if (chunk.length > 0 && - (chunk.length >= this.maxBatchItems || chunkBytes + itemBytes > this.maxBatchBytes)) { + const itemBytes = + item.publicKey.byteLength + item.digest.byteLength + item.signature.byteLength + if ( + chunk.length > 0 && + (chunk.length >= this.maxBatchItems || chunkBytes + itemBytes > this.maxBatchBytes) + ) { chunks.push(chunk) chunk = [] chunkBytes = 0 @@ -746,7 +791,7 @@ export default class BdkVerifierCore implements BdkVerifierInterface, AsyncCrypt if (chunks.length > 1) { const results: boolean[] = [] for (const batch of chunks) { - results.push(...await this.verifyDigestBatch(batch)) + results.push(...(await this.verifyDigestBatch(batch))) } return results } @@ -766,31 +811,22 @@ export default class BdkVerifierCore implements BdkVerifierInterface, AsyncCrypt return Array.from(results, result => result === 1) } - async publicKeyFromPrivate (privateKey: Uint8Array): Promise { + async publicKeyFromPrivate(privateKey: Uint8Array): Promise { const bdk = await this.getModule() return this.requiredCryptoMethod(bdk, 'PublicKeyFromPrivate')(privateKey) } - async multiplyPublicKey ( - publicKey: Uint8Array, - scalar: Uint8Array - ): Promise { + async multiplyPublicKey(publicKey: Uint8Array, scalar: Uint8Array): Promise { const bdk = await this.getModule() return this.requiredCryptoMethod(bdk, 'MultiplyPublicKey')(publicKey, scalar) } - async tweakPublicKeyAdd ( - publicKey: Uint8Array, - tweak: Uint8Array - ): Promise { + async tweakPublicKeyAdd(publicKey: Uint8Array, tweak: Uint8Array): Promise { const bdk = await this.getModule() return this.requiredCryptoMethod(bdk, 'TweakPublicKeyAdd')(publicKey, tweak) } - async tweakPrivateKeyAdd ( - privateKey: Uint8Array, - tweak: Uint8Array - ): Promise { + async tweakPrivateKeyAdd(privateKey: Uint8Array, tweak: Uint8Array): Promise { const bdk = await this.getModule() return this.requiredCryptoMethod(bdk, 'TweakPrivateKeyAdd')(privateKey, tweak) } diff --git a/packages/verifast/src/BdkVerifierTypes.ts b/packages/verifast/src/BdkVerifierTypes.ts index 647d591ca..6dd07b55c 100644 --- a/packages/verifast/src/BdkVerifierTypes.ts +++ b/packages/verifast/src/BdkVerifierTypes.ts @@ -17,14 +17,7 @@ export enum BdkErrorDomain { } export type BdkNetwork = - | 'main' - | 'test' - | 'stn' - | 'regtest' - | 'ttn' - | 'teratestnet' - | 'terratestnet' - | 'tstn' + 'main' | 'test' | 'stn' | 'regtest' | 'ttn' | 'teratestnet' | 'terratestnet' | 'tstn' export interface BdkVerificationResult { domain: number @@ -109,7 +102,7 @@ const SIGNATURE_OPS = new Set([ * WASM advantage: more than the byte threshold or an executed signature opcode. * Pushed data is not scanned as opcodes, avoiding false positives. */ -export function isVeriFastCandidateScript ( +export function isVeriFastCandidateScript( script: Script, scriptByteThreshold: number = DEFAULT_VERIFAST_SCRIPT_BYTE_THRESHOLD ): boolean { @@ -121,19 +114,21 @@ export function isVeriFastCandidateScript ( } /** True only for the canonical 25-byte DUP HASH160 PUSH20 EQUALVERIFY CHECKSIG form. */ -export function isStandardP2PKHScript (script: Script): boolean { +export function isStandardP2PKHScript(script: Script): boolean { const bytes = script.toUint8Array() - return bytes.byteLength === 25 && + return ( + bytes.byteLength === 25 && bytes[0] === 0x76 && bytes[1] === 0xa9 && bytes[2] === 0x14 && bytes[23] === 0x88 && bytes[24] === 0xac + ) } /** Raised when BDK reports an exception domain, malformed result, or unknown ABI domain. */ export class BdkVerificationError extends Error { - constructor (public readonly result: BdkVerificationResult) { + constructor(public readonly result: BdkVerificationResult) { super(`BDK verification failed in domain ${result.domain} with code ${result.code}`) this.name = 'BdkVerificationError' } @@ -147,53 +142,61 @@ export interface EmbindVector { export type EmbindVectorCtor = new () => EmbindVector -type BdkVerifyScriptBatchArray = (...args: [ - extendedTXs: Uint8Array, - txOffsets: Uint32Array, - utxoHeights: Int32Array, - heightOffsets: Uint32Array, - blockHeights: Int32Array, - consensus: Uint8Array, - customFlags: Uint32Array, - customFlagOffsets: Uint32Array, - network: number -]) => Int32Array - -type BdkVerifySpendArray = (...args: [ - transaction: Uint8Array, - inputIndex: number, - lockingScript: Uint8Array, - sourceSatoshis: number, - utxoHeight: number, - blockHeight: number, - consensus: boolean, - hasCustomFlags: boolean, - customFlags: number, - network: number -]) => BdkVerificationResult - -type BdkVerifySpendBatchArray = (...args: [ - transactions: Uint8Array, - transactionOffsets: Uint32Array, - inputIndices: Uint32Array, - lockingScripts: Uint8Array, - lockingScriptOffsets: Uint32Array, - sourceSatoshis: Float64Array, - utxoHeights: Int32Array, - blockHeights: Int32Array, - consensus: Uint8Array, - hasCustomFlags: Uint8Array, - customFlags: Uint32Array, - network: number -]) => Int32Array - -type BdkVerifyDigestBatchArray = (...args: [ - publicKeys: Uint8Array, - publicKeyOffsets: Uint32Array, - digests: Uint8Array, - signatures: Uint8Array, - signatureOffsets: Uint32Array -]) => Uint8Array +type BdkVerifyScriptBatchArray = ( + ...args: [ + extendedTXs: Uint8Array, + txOffsets: Uint32Array, + utxoHeights: Int32Array, + heightOffsets: Uint32Array, + blockHeights: Int32Array, + consensus: Uint8Array, + customFlags: Uint32Array, + customFlagOffsets: Uint32Array, + network: number + ] +) => Int32Array + +type BdkVerifySpendArray = ( + ...args: [ + transaction: Uint8Array, + inputIndex: number, + lockingScript: Uint8Array, + sourceSatoshis: number, + utxoHeight: number, + blockHeight: number, + consensus: boolean, + hasCustomFlags: boolean, + customFlags: number, + network: number + ] +) => BdkVerificationResult + +type BdkVerifySpendBatchArray = ( + ...args: [ + transactions: Uint8Array, + transactionOffsets: Uint32Array, + inputIndices: Uint32Array, + lockingScripts: Uint8Array, + lockingScriptOffsets: Uint32Array, + sourceSatoshis: Float64Array, + utxoHeights: Int32Array, + blockHeights: Int32Array, + consensus: Uint8Array, + hasCustomFlags: Uint8Array, + customFlags: Uint32Array, + network: number + ] +) => Int32Array + +type BdkVerifyDigestBatchArray = ( + ...args: [ + publicKeys: Uint8Array, + publicKeyOffsets: Uint32Array, + digests: Uint8Array, + signatures: Uint8Array, + signatureOffsets: Uint32Array + ] +) => Uint8Array /** The BDK WASM verifier ABI. New methods remain optional for custom older modules. */ export interface BdkWasmModule { @@ -230,11 +233,7 @@ export interface BdkWasmModule { ExportVerificationTables?: () => Uint8Array ImportVerificationTables?: (snapshot: Uint8Array) => void SignDigest?: (privateKey: Uint8Array, digest: Uint8Array) => Uint8Array - VerifyDigest?: ( - publicKey: Uint8Array, - digest: Uint8Array, - signature: Uint8Array - ) => boolean + VerifyDigest?: (publicKey: Uint8Array, digest: Uint8Array, signature: Uint8Array) => boolean VerifyDigestBatchArray?: BdkVerifyDigestBatchArray PublicKeyFromPrivate?: (privateKey: Uint8Array) => Uint8Array MultiplyPublicKey?: (publicKey: Uint8Array, scalar: Uint8Array) => Uint8Array diff --git a/packages/verifast/src/__tests/BdkBatch.test.ts b/packages/verifast/src/__tests/BdkBatch.test.ts new file mode 100644 index 000000000..05fd205cd --- /dev/null +++ b/packages/verifast/src/__tests/BdkBatch.test.ts @@ -0,0 +1,41 @@ +import { BdkErrorDomain, BdkVerificationError } from '../BdkVerifierTypes.js' +import { decodeResults, flagsForInputCount, packArrays, verdict } from '../BdkBatch.js' + +describe('BDK batch helpers', () => { + it('normalizes explicit and named flag sets for every input', () => { + expect(flagsForInputCount(2, undefined, [1, 2])).toEqual(Uint32Array.of(1, 2)) + expect(flagsForInputCount(2, undefined, [])).toEqual(new Uint32Array()) + expect(flagsForInputCount(2)).toEqual(new Uint32Array()) + expect(flagsForInputCount(2, 'P2SH')).toEqual(Uint32Array.of(1, 1)) + expect(() => flagsForInputCount(2, undefined, [1])).toThrow( + 'Custom flag count must be zero or match the input count' + ) + }) + + it('packs typed arrays and records every boundary', () => { + const packed = packArrays( + [Uint8Array.of(1, 2), Uint8Array.of(), Uint8Array.of(3)], + length => new Uint8Array(length) + ) + + expect(packed.values).toEqual(Uint8Array.of(1, 2, 3)) + expect(packed.offsets).toEqual(Uint32Array.of(0, 2, 2, 3)) + }) + + it('decodes flat result pairs and rejects malformed output', () => { + expect(decodeResults(Int32Array.of(0, 0, 1, 7), 2)).toEqual([ + { domain: 0, code: 0 }, + { domain: 1, code: 7 } + ]) + expect(() => decodeResults(Int32Array.of(0), 1)).toThrow(BdkVerificationError) + }) + + it('maps structured domains to success, rejection, or an exception', () => { + expect(verdict({ domain: BdkErrorDomain.OK, code: 0 })).toBe(true) + expect(verdict({ domain: BdkErrorDomain.SCRIPT, code: 1 })).toBe(false) + expect(verdict({ domain: BdkErrorDomain.DOS, code: 1 })).toBe(false) + expect(() => verdict({ domain: BdkErrorDomain.EXCEPTION, code: 1 })).toThrow( + BdkVerificationError + ) + }) +}) diff --git a/packages/verifast/src/__tests/BdkVerifier.test.ts b/packages/verifast/src/__tests/BdkVerifier.test.ts index 9c1f4e153..5f6dceeb5 100644 --- a/packages/verifast/src/__tests/BdkVerifier.test.ts +++ b/packages/verifast/src/__tests/BdkVerifier.test.ts @@ -15,7 +15,7 @@ interface TestBackendGlobal { __bsvSdkScriptVerificationBackendV1?: object } -function clearDefaultBackends (): void { +function clearDefaultBackends(): void { const registry = globalThis as typeof globalThis & TestBackendGlobal delete registry.__bsvSdkAsyncCryptoBackendV1 delete registry.__bsvSdkScriptVerificationBackendV1 @@ -23,8 +23,10 @@ function clearDefaultBackends (): void { class MockVector { items: number[] = [] - push_back (value: number): void { this.items.push(value) } - delete (): void {} + push_back(value: number): void { + this.items.push(value) + } + delete(): void {} } interface MockCall { @@ -35,7 +37,7 @@ interface MockCall { customFlags: number[] } -function makeMockModule (result: BdkVerificationResult, calls: MockCall[]): BdkWasmModule { +function makeMockModule(result: BdkVerificationResult, calls: MockCall[]): BdkWasmModule { return { VectorUInt8: MockVector, VectorInt32: MockVector, @@ -53,7 +55,7 @@ function makeMockModule (result: BdkVerificationResult, calls: MockCall[]): BdkW } } -async function buildTx (inputCount = 1): Promise { +async function buildTx(inputCount = 1): Promise { const key = new PrivateKey(42) const source = new Transaction() source.addInput({ @@ -65,7 +67,10 @@ async function buildTx (inputCount = 1): Promise { source.addOutput({ satoshis: 2, lockingScript: new P2PKH().lock(key.toAddress()) }) } source.merklePath = new MerklePath(777, [ - [{ offset: 0, hash: source.id('hex'), txid: true }, { offset: 1, duplicate: true }] + [ + { offset: 0, hash: source.id('hex'), txid: true }, + { offset: 1, duplicate: true } + ] ]) const tx = new Transaction() for (let i = 0; i < inputCount; i++) { @@ -80,10 +85,11 @@ async function buildTx (inputCount = 1): Promise { return tx } -function spendForInput (tx: Transaction, inputIndex = 0): Spend { +function spendForInput(tx: Transaction, inputIndex = 0): Spend { const input = tx.inputs[inputIndex] const source = input.sourceTransaction - if (source === undefined || input.unlockingScript === undefined) throw new Error('missing fixture source data') + if (source === undefined || input.unlockingScript === undefined) + throw new Error('missing fixture source data') const sourceOutput = source.outputs[input.sourceOutputIndex] return new Spend({ sourceTXID: input.sourceTXID ?? source.id('hex'), @@ -151,31 +157,36 @@ describe('BdkVerifier', () => { }) it('makes disposal final', async () => { - const verifier = new BdkVerifier( - async () => makeMockModule({ domain: 0, code: 0 }, []), - { registerAsDefault: false } - ) + const verifier = new BdkVerifier(async () => makeMockModule({ domain: 0, code: 0 }, []), { + registerAsDefault: false + }) const tx = await buildTx() await verifier.preload() verifier.dispose() expect(verifier.isReady()).toBe(false) - expect(verifier.shouldVerifyScripts({ - tx, - blockHeight: 1, - consensus: false - })).toBe(false) - await expect(verifier.verifyScripts({ - tx, - blockHeight: 1, - consensus: false - })).rejects.toThrow('disposed') + expect( + verifier.shouldVerifyScripts({ + tx, + blockHeight: 1, + consensus: false + }) + ).toBe(false) + await expect( + verifier.verifyScripts({ + tx, + blockHeight: 1, + consensus: false + }) + ).rejects.toThrow('disposed') }) it('keeps a cold eligible transaction on JS, then selects WASM when ready', async () => { const tx = await buildTx() let resolveModule: (module: BdkWasmModule) => void = () => {} - const pendingModule = new Promise(resolve => { resolveModule = resolve }) + const pendingModule = new Promise(resolve => { + resolveModule = resolve + }) let wasmCalls = 0 const module = makeMockModule({ domain: 0, code: 0 }, []) module.VerifyScriptArray = () => { @@ -201,52 +212,62 @@ describe('BdkVerifier', () => { }) it('allows strict always mode to select the backend before it is warm', async () => { - const verifier = new BdkVerifier( - async () => makeMockModule({ domain: 0, code: 0 }, []), - { mode: 'always', registerAsDefault: false } - ) - expect(verifier.shouldVerifyScripts({ tx: await buildTx(), blockHeight: 1, consensus: true })).toBe(true) + const verifier = new BdkVerifier(async () => makeMockModule({ domain: 0, code: 0 }, []), { + mode: 'always', + registerAsDefault: false + }) + expect( + verifier.shouldVerifyScripts({ tx: await buildTx(), blockHeight: 1, consensus: true }) + ).toBe(true) expect(verifier.isReady()).toBe(false) }) it('distinguishes version-1 policy routing from explicit consensus validation', async () => { const tx = await buildTx() - const verifier = new BdkVerifier(async () => - makeMockModule({ domain: 0, code: 0 }, []) - ) + const verifier = new BdkVerifier(async () => makeMockModule({ domain: 0, code: 0 }, [])) await verifier.preload() - expect(verifier.shouldVerifyScripts({ - tx, - blockHeight: 1, - consensus: false - })).toBe(true) + expect( + verifier.shouldVerifyScripts({ + tx, + blockHeight: 1, + consensus: false + }) + ).toBe(true) const source = tx.inputs[0].sourceTransaction if (source === undefined) throw new Error('missing fixture source') source.outputs[0].lockingScript = Script.fromASM('OP_CHECKSIG') - expect(verifier.shouldVerifyScripts({ - tx, - blockHeight: 1, - consensus: false - })).toBe(false) - expect(verifier.shouldVerifyScripts({ - tx, - blockHeight: 1, - consensus: true - })).toBe(true) + expect( + verifier.shouldVerifyScripts({ + tx, + blockHeight: 1, + consensus: false + }) + ).toBe(false) + expect( + verifier.shouldVerifyScripts({ + tx, + blockHeight: 1, + consensus: true + }) + ).toBe(true) tx.version = 2 - expect(verifier.shouldVerifyScripts({ - tx, - blockHeight: 1, - consensus: true - })).toBe(true) - expect(verifier.shouldVerifyScripts({ - tx, - blockHeight: 1, - consensus: true, - memoryLimit: 1024 - })).toBe(false) + expect( + verifier.shouldVerifyScripts({ + tx, + blockHeight: 1, + consensus: true + }) + ).toBe(true) + expect( + verifier.shouldVerifyScripts({ + tx, + blockHeight: 1, + consensus: true, + memoryLimit: 1024 + }) + ).toBe(false) }) it('rejects invalid adaptive routing options', () => { @@ -257,7 +278,9 @@ describe('BdkVerifier', () => { it('applies the same cold-fallback and warm-selection policy to Spend.validateWith', async () => { const spend = spendForInput(await buildTx()) let resolveModule: (module: BdkWasmModule) => void = () => {} - const pendingModule = new Promise(resolve => { resolveModule = resolve }) + const pendingModule = new Promise(resolve => { + resolveModule = resolve + }) let wasmCalls = 0 const module = makeMockModule({ domain: 0, code: 0 }, []) module.VerifySpendArray = () => { @@ -278,7 +301,9 @@ describe('BdkVerifier', () => { it('uses the bulk-copy ABI when the module provides it', async () => { const calls: MockCall[] = [] const module = makeMockModule({ domain: 0, code: 0 }, []) - module.VerifyScript = () => { throw new Error('legacy vector ABI should not be called') } + module.VerifyScript = () => { + throw new Error('legacy vector ABI should not be called') + } module.VerifyScriptArray = (extendedTX, utxoHeights, blockHeight, consensus, customFlags) => { calls.push({ extendedTX: Array.from(extendedTX), @@ -292,15 +317,18 @@ describe('BdkVerifier', () => { const tx = await buildTx(2) const verifier = new BdkVerifier(async () => module) - await expect(verifier.verifyScripts({ tx, blockHeight: 800000, consensus: true })) - .resolves.toBe(true) - expect(calls).toEqual([{ - extendedTX: tx.toEF(), - utxoHeights: [777, 777], - blockHeight: 800000, - consensus: true, - customFlags: [] - }]) + await expect( + verifier.verifyScripts({ tx, blockHeight: 800000, consensus: true }) + ).resolves.toBe(true) + expect(calls).toEqual([ + { + extendedTX: tx.toEF(), + utxoHeights: [777, 777], + blockHeight: 800000, + consensus: true, + customFlags: [] + } + ]) }) it('marshals EF, heights, and one custom flag word per input', async () => { @@ -334,24 +362,27 @@ describe('BdkVerifier', () => { it('returns false for script and DoS domains', async () => { for (const domain of [BdkErrorDomain.SCRIPT, BdkErrorDomain.DOS]) { const verifier = new BdkVerifier(async () => makeMockModule({ domain, code: 39 }, [])) - await expect(verifier.verifyScripts({ tx: await buildTx(), blockHeight: 1, consensus: true })) - .resolves.toBe(false) + await expect( + verifier.verifyScripts({ tx: await buildTx(), blockHeight: 1, consensus: true }) + ).resolves.toBe(false) } }) it('throws a typed error for BDK exception and unknown domains', async () => { for (const domain of [BdkErrorDomain.EXCEPTION, 99]) { const verifier = new BdkVerifier(async () => makeMockModule({ domain, code: 0 }, [])) - await expect(verifier.verifyScripts({ tx: await buildTx(), blockHeight: 1, consensus: true })) - .rejects.toBeInstanceOf(BdkVerificationError) + await expect( + verifier.verifyScripts({ tx: await buildTx(), blockHeight: 1, consensus: true }) + ).rejects.toBeInstanceOf(BdkVerificationError) } }) it('exposes BDK domain and code through the detailed API', async () => { const expected = { domain: BdkErrorDomain.SCRIPT, code: 39 } const verifier = new BdkVerifier(async () => makeMockModule(expected, [])) - await expect(verifier.verifyScriptsDetailed({ tx: await buildTx(), blockHeight: 1, consensus: true })) - .resolves.toEqual(expected) + await expect( + verifier.verifyScriptsDetailed({ tx: await buildTx(), blockHeight: 1, consensus: true }) + ).resolves.toEqual(expected) }) it('uses the Chronicle height fallback for an unmined source', async () => { @@ -391,49 +422,82 @@ describe('BdkVerifier', () => { return { domain: 0, code: 0 } } const verifier = new BdkVerifier(async () => module, { network: 'test' }) - await expect(verifier.verifyScriptsFromEF({ - extendedTransaction: ef, - utxoHeights: [100], - blockHeight: 200, - consensus: false - })).resolves.toBe(true) + await expect( + verifier.verifyScriptsFromEF({ + extendedTransaction: ef, + utxoHeights: [100], + blockHeight: 200, + consensus: false + }) + ).resolves.toBe(true) expect(calls).toEqual([ef]) }) - it.each(['ttn', 'teratestnet', 'terratestnet'] as const)('maps the %s alias to TeraTestNet', async (network) => { - const module = makeMockModule({ domain: 0, code: 0 }, []) - module.VerifyScriptArrayNetwork = (_bytes, _heights, _blockHeight, _consensus, _flags, networkId) => { - expect(networkId).toBe(4) - return { domain: 0, code: 0 } + it.each(['ttn', 'teratestnet', 'terratestnet'] as const)( + 'maps the %s alias to TeraTestNet', + async network => { + const module = makeMockModule({ domain: 0, code: 0 }, []) + module.VerifyScriptArrayNetwork = ( + _bytes, + _heights, + _blockHeight, + _consensus, + _flags, + networkId + ) => { + expect(networkId).toBe(4) + return { domain: 0, code: 0 } + } + const verifier = new BdkVerifier(async () => module, { network }) + await expect( + verifier.verifyScriptsFromEF({ + extendedTransaction: Uint8Array.of(1), + utxoHeights: [1], + blockHeight: 1, + consensus: true + }) + ).resolves.toBe(true) } - const verifier = new BdkVerifier(async () => module, { network }) - await expect(verifier.verifyScriptsFromEF({ - extendedTransaction: Uint8Array.of(1), - utxoHeights: [1], - blockHeight: 1, - consensus: true - })).resolves.toBe(true) - }) + ) it('keeps Tera Scaling Test Network distinct from TeraTestNet aliases', async () => { const module = makeMockModule({ domain: 0, code: 0 }, []) - module.VerifyScriptArrayNetwork = (_bytes, _heights, _blockHeight, _consensus, _flags, networkId) => { + module.VerifyScriptArrayNetwork = ( + _bytes, + _heights, + _blockHeight, + _consensus, + _flags, + networkId + ) => { expect(networkId).toBe(5) return { domain: 0, code: 0 } } const verifier = new BdkVerifier(async () => module, { network: 'tstn' }) - await expect(verifier.verifyScriptsFromEF({ - extendedTransaction: Uint8Array.of(1), - utxoHeights: [1], - blockHeight: 1, - consensus: true - })).resolves.toBe(true) + await expect( + verifier.verifyScriptsFromEF({ + extendedTransaction: Uint8Array.of(1), + utxoHeights: [1], + blockHeight: 1, + consensus: true + }) + ).resolves.toBe(true) }) it('packs a transaction batch into one ABI call and preserves result order', async () => { const module = makeMockModule({ domain: 0, code: 0 }, []) let batchCalls = 0 - module.VerifyScriptBatchArray = (transactions, offsets, heights, heightOffsets, blockHeights, consensus, flags, flagOffsets, network) => { + module.VerifyScriptBatchArray = ( + transactions, + offsets, + heights, + heightOffsets, + blockHeights, + consensus, + flags, + flagOffsets, + network + ) => { batchCalls++ expect(Array.from(offsets)).toEqual([0, 2, 5]) expect(Array.from(transactions)).toEqual([1, 2, 3, 4, 5]) @@ -447,10 +511,22 @@ describe('BdkVerifier', () => { return Int32Array.from([0, 0, 1, 39]) } const verifier = new BdkVerifier(async () => module) - await expect(verifier.verifyScriptsBatchFromEF([ - { extendedTransaction: Uint8Array.of(1, 2), utxoHeights: [10], blockHeight: 30, consensus: true }, - { extendedTransaction: Uint8Array.of(3, 4, 5), utxoHeights: [20, 21], blockHeight: 31, consensus: false } - ])).resolves.toEqual([true, false]) + await expect( + verifier.verifyScriptsBatchFromEF([ + { + extendedTransaction: Uint8Array.of(1, 2), + utxoHeights: [10], + blockHeight: 30, + consensus: true + }, + { + extendedTransaction: Uint8Array.of(3, 4, 5), + utxoHeights: [20, 21], + blockHeight: 31, + consensus: false + } + ]) + ).resolves.toEqual([true, false]) expect(batchCalls).toBe(1) }) @@ -471,25 +547,31 @@ describe('BdkVerifier', () => { calls.push('digest') return Uint8Array.of(1) } - const verifier = new BdkVerifier( - async () => module, - { maxBatchBytes: 1, registerAsDefault: false } - ) + const verifier = new BdkVerifier(async () => module, { + maxBatchBytes: 1, + registerAsDefault: false + }) - await expect(verifier.verifyScriptsBatchFromEF([{ - extendedTransaction: Uint8Array.of(1, 2), - utxoHeights: [10], - blockHeight: 30, - consensus: true - }])).resolves.toEqual([true]) - await expect(verifier.verifySpendsBatch([ - { spend, consensus: true } - ])).resolves.toEqual([true]) - await expect(verifier.verifyDigestBatch([{ - publicKey: Uint8Array.of(2, 3), - digest: new Uint8Array(32), - signature: Uint8Array.of(4, 5) - }])).resolves.toEqual([true]) + await expect( + verifier.verifyScriptsBatchFromEF([ + { + extendedTransaction: Uint8Array.of(1, 2), + utxoHeights: [10], + blockHeight: 30, + consensus: true + } + ]) + ).resolves.toEqual([true]) + await expect(verifier.verifySpendsBatch([{ spend, consensus: true }])).resolves.toEqual([true]) + await expect( + verifier.verifyDigestBatch([ + { + publicKey: Uint8Array.of(2, 3), + digest: new Uint8Array(32), + signature: Uint8Array.of(4, 5) + } + ]) + ).resolves.toEqual([true]) expect(calls).toEqual(['script', 'spend', 'digest']) }) @@ -498,15 +580,12 @@ describe('BdkVerifier', () => { const spends = [spendForInput(tx, 0), spendForInput(tx, 1)] const module = makeMockModule({ domain: 0, code: 0 }, []) module.VerifySpendBatchArray = () => Int32Array.from([0, 0, 0, 0]) - const verifier = new BdkVerifier( - async () => module, - { registerAsDefault: false } - ) + const verifier = new BdkVerifier(async () => module, { registerAsDefault: false }) const serialize = jest.spyOn(Spend.prototype, 'toTransactionUint8Array') - await expect(verifier.verifySpendsBatch( - spends.map(spend => ({ spend, consensus: true })) - )).resolves.toEqual([true, true]) + await expect( + verifier.verifySpendsBatch(spends.map(spend => ({ spend, consensus: true }))) + ).resolves.toEqual([true, true]) expect(serialize).toHaveBeenCalledTimes(1) serialize.mockRestore() }) @@ -521,7 +600,18 @@ describe('BdkVerifier', () => { const module = makeMockModule({ domain: 0, code: 0 }, []) let calls = 0 const consensusValues: boolean[] = [] - module.VerifySpendArray = (transaction, inputIndex, lockingScript, sourceSatoshis, utxoHeight, blockHeight, consensus, hasFlags, flags, network) => { + module.VerifySpendArray = ( + transaction, + inputIndex, + lockingScript, + sourceSatoshis, + utxoHeight, + blockHeight, + consensus, + hasFlags, + flags, + network + ) => { calls++ expect(transaction).toEqual(tx.toUint8Array()) expect(inputIndex).toBe(0) @@ -550,9 +640,9 @@ describe('BdkVerifier', () => { module.VerifySpendBatchArray = () => new Int32Array() const verifier = new BdkVerifier(async () => module) - await expect(verifier.verifySpend(spend)) - .rejects.toThrow('non-negative safe integer') - await expect(verifier.verifySpendsBatch([{ spend }])) - .rejects.toThrow('non-negative safe integer') + await expect(verifier.verifySpend(spend)).rejects.toThrow('non-negative safe integer') + await expect(verifier.verifySpendsBatch([{ spend }])).rejects.toThrow( + 'non-negative safe integer' + ) }) }) diff --git a/packages/verifast/src/__tests/BdkVerifierEntrypoints.test.ts b/packages/verifast/src/__tests/BdkVerifierEntrypoints.test.ts new file mode 100644 index 000000000..a41299921 --- /dev/null +++ b/packages/verifast/src/__tests/BdkVerifierEntrypoints.test.ts @@ -0,0 +1,85 @@ +import NodeBdkVerifier from '../BdkVerifier.js' +import BrowserBdkVerifier from '../BdkVerifier.browser.js' +import type { BdkVerificationResult, BdkWasmModule } from '../BdkVerifierTypes.js' +import { jest } from '@jest/globals' + +class MockVector { + push_back(_value: number): void {} + delete(): void {} +} + +function module(): BdkWasmModule { + return { + VectorUInt8: MockVector, + VectorInt32: MockVector, + VectorUInt32: MockVector, + VerifyScript: (): BdkVerificationResult => ({ domain: 0, code: 0 }) + } +} + +describe('published verifier entrypoints', () => { + it.each([ + ['node', NodeBdkVerifier], + ['browser', BrowserBdkVerifier] + ] as const)( + 'supports an injected WASM factory through the %s entrypoint', + async (_name, Verifier) => { + const factory = jest.fn(async () => module()) + const verifier = new Verifier(factory, { registerAsDefault: false }) + + await verifier.preload() + + expect(factory).toHaveBeenCalledTimes(1) + expect(verifier.isReady()).toBe(true) + verifier.dispose() + } + ) + + it.each([ + ['node', NodeBdkVerifier], + ['browser', BrowserBdkVerifier] + ] as const)('validates worker counts through the %s entrypoint', (_name, Verifier) => { + expect( + () => + new Verifier({ + batchWorkers: 0, + registerAsDefault: false + }) + ).toThrow('batchWorkers must be a safe integer from 1 to 16') + expect( + () => + new Verifier({ + batchWorkers: 17, + registerAsDefault: false + }) + ).toThrow('batchWorkers must be a safe integer from 1 to 16') + }) + + it('can disable Node worker fan-out explicitly', () => { + const verifier = new NodeBdkVerifier({ + batchWorkers: 1, + registerAsDefault: false + }) + verifier.dispose() + }) + + it('falls back to the main thread when browser workers are unavailable', () => { + const originalWorker = globalThis.Worker + Object.defineProperty(globalThis, 'Worker', { + configurable: true, + value: undefined + }) + try { + const verifier = new BrowserBdkVerifier({ + batchWorkers: 2, + registerAsDefault: false + }) + verifier.dispose() + } finally { + Object.defineProperty(globalThis, 'Worker', { + configurable: true, + value: originalWorker + }) + } + }) +}) diff --git a/packages/verifast/src/__tests/BdkWorkers.test.ts b/packages/verifast/src/__tests/BdkWorkers.test.ts index 168b52d00..765f3c369 100644 --- a/packages/verifast/src/__tests/BdkWorkers.test.ts +++ b/packages/verifast/src/__tests/BdkWorkers.test.ts @@ -5,17 +5,15 @@ import { type BdkWorkerRequest, type BdkWorkerResponse } from '../workers/BdkWorkerProtocol.js' -import BdkWorkerPool, { - type WorkerAdapter -} from '../workers/BdkWorkerPool.js' +import BdkWorkerPool, { type WorkerAdapter } from '../workers/BdkWorkerPool.js' import BdkWorkerScheduler from '../workers/BdkWorkerScheduler.js' class MockVector { - push_back (_value: number): void {} - delete (): void {} + push_back(_value: number): void {} + delete(): void {} } -function mockModule (): BdkWasmModule { +function mockModule(): BdkWasmModule { return { VectorUInt8: MockVector, VectorInt32: MockVector, @@ -26,55 +24,63 @@ function mockModule (): BdkWasmModule { describe('BDK worker protocol validation', () => { it('accepts each supported request shape', () => { - expect(isBdkWorkerRequest({ - id: 0, - operation: 'preload', - verificationTables: new Uint8Array() - })).toBe(true) - expect(isBdkWorkerRequest({ - id: 1, - operation: 'verifyScripts', - payload: { - extendedTransactions: new Uint8Array(), - transactionOffsets: new Uint32Array(), - utxoHeights: new Int32Array(), - heightOffsets: new Uint32Array(), - blockHeights: new Int32Array(), - consensus: new Uint8Array(), - customFlags: new Uint32Array(), - customFlagOffsets: new Uint32Array(), - network: 0 - } - })).toBe(true) - expect(isBdkWorkerRequest({ - id: 2, - operation: 'verifySpends', - payload: { - transactions: new Uint8Array(), - transactionOffsets: new Uint32Array(), - inputIndices: new Uint32Array(), - lockingScripts: new Uint8Array(), - lockingScriptOffsets: new Uint32Array(), - sourceSatoshis: new Float64Array(), - utxoHeights: new Int32Array(), - blockHeights: new Int32Array(), - consensus: new Uint8Array(), - hasCustomFlags: new Uint8Array(), - customFlags: new Uint32Array(), - network: 5 - } - })).toBe(true) - expect(isBdkWorkerRequest({ - id: 3, - operation: 'verifyDigests', - payload: { - publicKeys: new Uint8Array(), - publicKeyOffsets: new Uint32Array(), - digests: new Uint8Array(), - signatures: new Uint8Array(), - signatureOffsets: new Uint32Array() - } - })).toBe(true) + expect( + isBdkWorkerRequest({ + id: 0, + operation: 'preload', + verificationTables: new Uint8Array() + }) + ).toBe(true) + expect( + isBdkWorkerRequest({ + id: 1, + operation: 'verifyScripts', + payload: { + extendedTransactions: new Uint8Array(), + transactionOffsets: new Uint32Array(), + utxoHeights: new Int32Array(), + heightOffsets: new Uint32Array(), + blockHeights: new Int32Array(), + consensus: new Uint8Array(), + customFlags: new Uint32Array(), + customFlagOffsets: new Uint32Array(), + network: 0 + } + }) + ).toBe(true) + expect( + isBdkWorkerRequest({ + id: 2, + operation: 'verifySpends', + payload: { + transactions: new Uint8Array(), + transactionOffsets: new Uint32Array(), + inputIndices: new Uint32Array(), + lockingScripts: new Uint8Array(), + lockingScriptOffsets: new Uint32Array(), + sourceSatoshis: new Float64Array(), + utxoHeights: new Int32Array(), + blockHeights: new Int32Array(), + consensus: new Uint8Array(), + hasCustomFlags: new Uint8Array(), + customFlags: new Uint32Array(), + network: 5 + } + }) + ).toBe(true) + expect( + isBdkWorkerRequest({ + id: 3, + operation: 'verifyDigests', + payload: { + publicKeys: new Uint8Array(), + publicKeyOffsets: new Uint32Array(), + digests: new Uint8Array(), + signatures: new Uint8Array(), + signatureOffsets: new Uint32Array() + } + }) + ).toBe(true) }) it.each([ @@ -116,10 +122,14 @@ describe('BDK worker warm-up', () => { messageHandler({ id: request.id, result: new Uint8Array() }) }) }, - onMessage: handler => { messageHandler = handler }, + onMessage: handler => { + messageHandler = handler + }, onError: () => {}, onExit: () => {}, - terminate: () => { terminated++ } + terminate: () => { + terminated++ + } } } const scheduler = new BdkWorkerScheduler(() => { @@ -142,12 +152,18 @@ describe('BDK worker warm-up', () => { const module = mockModule() const imported: Uint8Array[] = [] let prepared = 0 - module.ImportVerificationTables = snapshot => { imported.push(snapshot) } - module.PrepareVerification = () => { prepared++ } + module.ImportVerificationTables = snapshot => { + imported.push(snapshot) + } + module.PrepareVerification = () => { + prepared++ + } const responses: BdkWorkerResponse[] = [] const handle = createWorkerRequestHandler( async () => module, - response => { responses.push(response) } + response => { + responses.push(response) + } ) const snapshot = Uint8Array.of(1, 2, 3) @@ -162,8 +178,13 @@ describe('BDK worker warm-up', () => { it('retains generation as a compatibility fallback for older BDK modules', async () => { const module = mockModule() let prepared = 0 - module.PrepareVerification = () => { prepared++ } - const handle = createWorkerRequestHandler(async () => module, () => {}) + module.PrepareVerification = () => { + prepared++ + } + const handle = createWorkerRequestHandler( + async () => module, + () => {} + ) await handle({ id: 1, operation: 'preload' }) @@ -179,7 +200,9 @@ describe('BDK worker warm-up', () => { if (attempts === 1) throw new Error('transient load failure') return mockModule() }, - response => { responses.push(response) } + response => { + responses.push(response) + } ) await handle({ id: 1, operation: 'preload' }) @@ -196,20 +219,24 @@ describe('BDK worker warm-up', () => { post: () => {}, onMessage: () => {}, onError: () => {}, - onExit: handler => { exit = handler }, + onExit: handler => { + exit = handler + }, terminate: () => {} }) const pool = new BdkWorkerPool(1, worker) - const pending = pool.execute([{ - operation: 'verifyDigests', - payload: { - publicKeys: new Uint8Array(), - publicKeyOffsets: new Uint32Array(), - digests: new Uint8Array(), - signatures: new Uint8Array(), - signatureOffsets: new Uint32Array() + const pending = pool.execute([ + { + operation: 'verifyDigests', + payload: { + publicKeys: new Uint8Array(), + publicKeyOffsets: new Uint32Array(), + digests: new Uint8Array(), + signatures: new Uint8Array(), + signatureOffsets: new Uint32Array() + } } - }]) + ]) exit(new Error('worker exited')) await expect(pending).rejects.toThrow('worker exited') }) @@ -221,9 +248,13 @@ describe('BDK worker warm-up', () => { return { post: request => { const result = Uint8Array.of(nextResult++) - queueMicrotask(() => { messageHandler({ id: request.id, result }) }) + queueMicrotask(() => { + messageHandler({ id: request.id, result }) + }) + }, + onMessage: handler => { + messageHandler = handler }, - onMessage: handler => { messageHandler = handler }, onError: () => {}, onExit: () => {}, terminate: () => {} @@ -247,12 +278,16 @@ describe('BDK worker warm-up', () => { return { post: request => { queueMicrotask(() => { - messageHandler(fail - ? { id: request.id, error: 'startup failed' } - : { id: request.id, result: new Uint8Array() }) + messageHandler( + fail + ? { id: request.id, error: 'startup failed' } + : { id: request.id, result: new Uint8Array() } + ) }) }, - onMessage: handler => { messageHandler = handler }, + onMessage: handler => { + messageHandler = handler + }, onError: () => {}, onExit: () => {}, terminate: () => {} @@ -275,7 +310,9 @@ describe('BDK worker warm-up', () => { messageHandler({ id: request.id, result: new Uint8Array() }) }) }, - onMessage: handler => { messageHandler = handler }, + onMessage: handler => { + messageHandler = handler + }, onError: () => {}, onExit: () => {}, terminate: () => {} @@ -294,25 +331,31 @@ describe('BDK worker warm-up', () => { it('places an item above the byte target in its own worker chunk', async () => { const scheduler = new BdkWorkerScheduler( - onFailure => new BdkWorkerPool(2, () => { - let messageHandler: (response: BdkWorkerResponse) => void = () => {} - return { - post: request => { - queueMicrotask(() => { - messageHandler({ id: request.id, result: new Uint8Array() }) - }) + onFailure => + new BdkWorkerPool( + 2, + () => { + let messageHandler: (response: BdkWorkerResponse) => void = () => {} + return { + post: request => { + queueMicrotask(() => { + messageHandler({ id: request.id, result: new Uint8Array() }) + }) + }, + onMessage: handler => { + messageHandler = handler + }, + onError: () => {}, + onExit: () => {}, + terminate: () => {} + } }, - onMessage: handler => { messageHandler = handler }, - onError: () => {}, - onExit: () => {}, - terminate: () => {} - } - }, onFailure), + onFailure + ), { maxBatchBytes: 1 } ) await scheduler.preload(mockModule()) - expect(scheduler.parallelChunks([0, 1], item => item === 0 ? 2 : 1)) - .toEqual([[0], [1]]) + expect(scheduler.parallelChunks([0, 1], item => (item === 0 ? 2 : 1))).toEqual([[0], [1]]) }) }) diff --git a/packages/verifast/src/__tests/flags.test.ts b/packages/verifast/src/__tests/flags.test.ts index 253a35af2..575bc7231 100644 --- a/packages/verifast/src/__tests/flags.test.ts +++ b/packages/verifast/src/__tests/flags.test.ts @@ -10,23 +10,19 @@ describe('mapVerifyFlags', () => { }) it('ORs comma-separated string flags', () => { - expect(mapVerifyFlags('P2SH,MINIMALDATA')).toBe( - BDK_FLAG_BITS.P2SH | BDK_FLAG_BITS.MINIMALDATA - ) + expect(mapVerifyFlags('P2SH,MINIMALDATA')).toBe(BDK_FLAG_BITS.P2SH | BDK_FLAG_BITS.MINIMALDATA) }) it('ORs an array of flags and trims whitespace', () => { - expect(mapVerifyFlags([' P2SH ', 'LOW_S'])).toBe( - BDK_FLAG_BITS.P2SH | BDK_FLAG_BITS.LOW_S - ) + expect(mapVerifyFlags([' P2SH ', 'LOW_S'])).toBe(BDK_FLAG_BITS.P2SH | BDK_FLAG_BITS.LOW_S) }) it('maps the post-Genesis and Chronicle bits exactly', () => { expect(mapVerifyFlags(['MINIMALIF', 'NULLFAIL', 'CHRONICLE', 'UTXO_AFTER_CHRONICLE'])).toBe( BDK_FLAG_BITS.MINIMALIF | - BDK_FLAG_BITS.NULLFAIL | - BDK_FLAG_BITS.CHRONICLE | - BDK_FLAG_BITS.UTXO_AFTER_CHRONICLE + BDK_FLAG_BITS.NULLFAIL | + BDK_FLAG_BITS.CHRONICLE | + BDK_FLAG_BITS.UTXO_AFTER_CHRONICLE ) }) diff --git a/packages/verifast/src/__tests/realWasm.test.ts b/packages/verifast/src/__tests/realWasm.test.ts index d6c39fee1..e4d6aece3 100644 --- a/packages/verifast/src/__tests/realWasm.test.ts +++ b/packages/verifast/src/__tests/realWasm.test.ts @@ -21,13 +21,13 @@ interface TestBackendGlobal { __bsvSdkScriptVerificationBackendV1?: object } -function clearDefaultBackends (): void { +function clearDefaultBackends(): void { const registry = globalThis as typeof globalThis & TestBackendGlobal delete registry.__bsvSdkAsyncCryptoBackendV1 delete registry.__bsvSdkScriptVerificationBackendV1 } -async function p2pkhTransaction (key: PrivateKey): Promise { +async function p2pkhTransaction(key: PrivateKey): Promise { const source = new Transaction() source.addInput({ sourceTXID: '00'.repeat(32), @@ -39,7 +39,10 @@ async function p2pkhTransaction (key: PrivateKey): Promise { lockingScript: new P2PKH().lock(key.toAddress()) }) source.merklePath = new MerklePath(777, [ - [{ offset: 0, hash: source.id('hex'), txid: true }, { offset: 1, duplicate: true }] + [ + { offset: 0, hash: source.id('hex'), txid: true }, + { offset: 1, duplicate: true } + ] ]) const transaction = new Transaction() transaction.addInput({ @@ -63,13 +66,16 @@ describe('bundled BDK WASM in Node', () => { const corpus = await buildCorpus() const valid = corpus.find(({ name }) => name === 'p2pkh-1in-valid') const invalid = corpus.find(({ name }) => name === 'p2pkh-corrupt-signature') - if (valid === undefined || invalid === undefined) throw new Error('required corpus vectors are missing') + if (valid === undefined || invalid === undefined) + throw new Error('required corpus vectors are missing') - await expect(verifier.verifyScriptsDetailed({ - tx: valid.tx, - blockHeight: 943816, - consensus: true - })).resolves.toEqual({ domain: BdkErrorDomain.OK, code: 0 }) + await expect( + verifier.verifyScriptsDetailed({ + tx: valid.tx, + blockHeight: 943816, + consensus: true + }) + ).resolves.toEqual({ domain: BdkErrorDomain.OK, code: 0 }) const invalidResult = await verifier.verifyScriptsDetailed({ tx: invalid.tx, @@ -83,8 +89,8 @@ describe('bundled BDK WASM in Node', () => { it('validates SDK Spend objects singly and through one packed WASM batch', async () => { const verifier = new BdkVerifier({ registerAsDefault: false }) const corpus = await buildCorpus() - const selected = corpus.filter(({ name }) => - name === 'p2pkh-5in-valid' || name === 'p2pkh-5in-one-corrupt-signature' + const selected = corpus.filter( + ({ name }) => name === 'p2pkh-5in-valid' || name === 'p2pkh-5in-one-corrupt-signature' ) const spends = selected.flatMap(({ tx }) => spendsForTransaction(tx)) const expected = spends.map(spend => { @@ -96,7 +102,9 @@ describe('bundled BDK WASM in Node', () => { }) await expect(verifier.verifySpend(spends[0])).resolves.toBe(true) - await expect(verifier.verifySpendsBatch(spends.map(spend => ({ spend })))).resolves.toEqual(expected) + await expect(verifier.verifySpendsBatch(spends.map(spend => ({ spend })))).resolves.toEqual( + expected + ) expect(expected.filter(valid => !valid)).toHaveLength(1) }) @@ -112,7 +120,10 @@ describe('bundled BDK WASM in Node', () => { lockingScript: Script.fromASM('OP_DROP OP_TRUE') }) source.merklePath = new MerklePath(777, [ - [{ offset: 0, hash: source.id('hex'), txid: true }, { offset: 1, duplicate: true }] + [ + { offset: 0, hash: source.id('hex'), txid: true }, + { offset: 1, duplicate: true } + ] ]) const tx = new Transaction() tx.addInput({ @@ -129,29 +140,36 @@ describe('bundled BDK WASM in Node', () => { scriptByteThreshold: 0 }) await verifier.preload() - expect(verifier.shouldVerifyScripts({ - tx, - blockHeight: 943816, - consensus: false - })).toBe(false) - expect(verifier.shouldVerifyScripts({ - tx, - blockHeight: 943816, - consensus: true - })).toBe(true) - await expect(tx.verify('scripts only', undefined, undefined, verifier)) - .resolves.toBe(true) + expect( + verifier.shouldVerifyScripts({ + tx, + blockHeight: 943816, + consensus: false + }) + ).toBe(false) + expect( + verifier.shouldVerifyScripts({ + tx, + blockHeight: 943816, + consensus: true + }) + ).toBe(true) + await expect(tx.verify('scripts only', undefined, undefined, verifier)).resolves.toBe(true) - await expect(verifier.verifyScripts({ - tx, - blockHeight: 943816, - consensus: true - })).resolves.toBe(true) - await expect(verifier.verifyScripts({ - tx, - blockHeight: 943816, - consensus: false - })).resolves.toBe(false) + await expect( + verifier.verifyScripts({ + tx, + blockHeight: 943816, + consensus: true + }) + ).resolves.toBe(true) + await expect( + verifier.verifyScripts({ + tx, + blockHeight: 943816, + consensus: false + }) + ).resolves.toBe(false) const highSTx = await p2pkhTransaction(new PrivateKey(42)) const unlockingScript = highSTx.inputs[0].unlockingScript @@ -165,29 +183,33 @@ describe('bundled BDK WASM in Node', () => { new Curve().n.sub(signature.s), signature.scope ).toChecksigFormat() - unlockingScript.chunks = [ - { op: highSignature.length, data: highSignature }, - chunks[1] - ] + unlockingScript.chunks = [{ op: highSignature.length, data: highSignature }, chunks[1]] await expect(highSTx.verify('scripts only')).rejects.toThrow() - expect(verifier.shouldVerifyScripts({ - tx: highSTx, - blockHeight: 943816, - consensus: false - })).toBe(true) - await expect(highSTx.verify('scripts only', undefined, undefined, verifier)) - .rejects.toThrow(`Script verification failed for transaction ${highSTx.id('hex')}`) - await expect(verifier.verifyScripts({ - tx: highSTx, - blockHeight: 943816, - consensus: true - })).resolves.toBe(false) - await expect(verifier.verifyScripts({ - tx: highSTx, - blockHeight: 943816, - consensus: false - })).resolves.toBe(false) + expect( + verifier.shouldVerifyScripts({ + tx: highSTx, + blockHeight: 943816, + consensus: false + }) + ).toBe(true) + await expect(highSTx.verify('scripts only', undefined, undefined, verifier)).rejects.toThrow( + `Script verification failed for transaction ${highSTx.id('hex')}` + ) + await expect( + verifier.verifyScripts({ + tx: highSTx, + blockHeight: 943816, + consensus: true + }) + ).resolves.toBe(false) + await expect( + verifier.verifyScripts({ + tx: highSTx, + blockHeight: 943816, + consensus: false + }) + ).resolves.toBe(false) verifier.dispose() }) @@ -196,26 +218,27 @@ describe('bundled BDK WASM in Node', () => { ['teratestnet', 4], ['terratestnet', 4], ['tstn', 5] - ] as const)('validates through the real %s network path (BDK ID %i)', async (network, _networkId) => { - const verifier = new BdkVerifier({ network, registerAsDefault: false }) - const valid = (await buildCorpus()).find(({ name }) => name === 'p2pkh-1in-valid') - if (valid === undefined) throw new Error('required corpus vector is missing') - await expect(verifier.verifyScripts({ - tx: valid.tx, - blockHeight: 943816, - consensus: true - })).resolves.toBe(true) - }) + ] as const)( + 'validates through the real %s network path (BDK ID %i)', + async (network, _networkId) => { + const verifier = new BdkVerifier({ network, registerAsDefault: false }) + const valid = (await buildCorpus()).find(({ name }) => name === 'p2pkh-1in-valid') + if (valid === undefined) throw new Error('required corpus vector is missing') + await expect( + verifier.verifyScripts({ + tx: valid.tx, + blockHeight: 943816, + consensus: true + }) + ).resolves.toBe(true) + } + ) it('matches SDK crypto, BRC-42 derivation, wallet signatures, and P2PKH bytes', async () => { const rootKey = new PrivateKey(42) const counterparty = new PrivateKey(99).toPublicKey() const digest = Uint8Array.from(Hash.sha256([1, 2, 3, 4])) - const sdkSignature = ECDSA.sign( - new BigNumber(Array.from(digest)), - rootKey, - true - ) + const sdkSignature = ECDSA.sign(new BigNumber(Array.from(digest)), rootKey, true) const baselineTransaction = await p2pkhTransaction(rootKey) const baselineWallet = new ProtoWallet(rootKey) const signatureArgs = { @@ -235,60 +258,58 @@ describe('bundled BDK WASM in Node', () => { const verifier = new BdkVerifier({ batchWorkers: 1 }) await verifier.preload() - expect(await verifier.signDigest( - Uint8Array.from(rootKey.toArray('be', 32)), - digest - )).toEqual(Uint8Array.from(sdkSignature.toDER() as number[])) - expect(await verifier.publicKeyFromPrivate( - Uint8Array.from(rootKey.toArray('be', 32)) - )).toEqual(Uint8Array.from(rootKey.toPublicKey().encode(true) as number[])) - expect(await verifier.verifyDigest( - Uint8Array.from(rootKey.toPublicKey().encode(true) as number[]), - digest, + expect(await verifier.signDigest(Uint8Array.from(rootKey.toArray('be', 32)), digest)).toEqual( Uint8Array.from(sdkSignature.toDER() as number[]) - )).toBe(true) - - const highSignature = new Signature( - sdkSignature.r, - new Curve().n.sub(sdkSignature.s) ) - expect(ECDSA.verify( - new BigNumber(Array.from(digest)), - highSignature, - rootKey.toPublicKey() - )).toBe(true) - expect(await verifier.verifyDigest( - Uint8Array.from(rootKey.toPublicKey().encode(true) as number[]), - digest, - Uint8Array.from(highSignature.toDER() as number[]) - )).toBe(true) + expect(await verifier.publicKeyFromPrivate(Uint8Array.from(rootKey.toArray('be', 32)))).toEqual( + Uint8Array.from(rootKey.toPublicKey().encode(true) as number[]) + ) + expect( + await verifier.verifyDigest( + Uint8Array.from(rootKey.toPublicKey().encode(true) as number[]), + digest, + Uint8Array.from(sdkSignature.toDER() as number[]) + ) + ).toBe(true) + + const highSignature = new Signature(sdkSignature.r, new Curve().n.sub(sdkSignature.s)) + expect( + ECDSA.verify(new BigNumber(Array.from(digest)), highSignature, rootKey.toPublicKey()) + ).toBe(true) + expect( + await verifier.verifyDigest( + Uint8Array.from(rootKey.toPublicKey().encode(true) as number[]), + digest, + Uint8Array.from(highSignature.toDER() as number[]) + ) + ).toBe(true) const keyDeriver = new KeyDeriver(rootKey) const protocolID: [2, string] = [2, 'verifast equivalence'] for (const forSelf of [false, true]) { - expect((await keyDeriver.derivePublicKeyAsync( - protocolID, '3', counterparty, forSelf - )).toString()).toBe( - keyDeriver.derivePublicKey(protocolID, '3', counterparty, forSelf).toString() - ) + expect( + (await keyDeriver.derivePublicKeyAsync(protocolID, '3', counterparty, forSelf)).toString() + ).toBe(keyDeriver.derivePublicKey(protocolID, '3', counterparty, forSelf).toString()) } - expect((await keyDeriver.deriveSymmetricKeyAsync( - protocolID, '3', counterparty - )).toHex()).toBe( + expect((await keyDeriver.deriveSymmetricKeyAsync(protocolID, '3', counterparty)).toHex()).toBe( keyDeriver.deriveSymmetricKey(protocolID, '3', counterparty).toHex() ) const acceleratedWallet = new ProtoWallet(rootKey) - await expect(acceleratedWallet.createSignature(signatureArgs)) - .resolves.toEqual(baselineWalletSignature) - await expect(acceleratedWallet.createHmac({ - data: [1, 2, 3], - protocolID: [2, 'verifast equivalence'], - keyID: '2', - counterparty: counterparty.toString() - })).resolves.toEqual(baselineHmac) - expect((await p2pkhTransaction(rootKey)).toUint8Array()) - .toEqual(baselineTransaction.toUint8Array()) + await expect(acceleratedWallet.createSignature(signatureArgs)).resolves.toEqual( + baselineWalletSignature + ) + await expect( + acceleratedWallet.createHmac({ + data: [1, 2, 3], + protocolID: [2, 'verifast equivalence'], + keyID: '2', + counterparty: counterparty.toString() + }) + ).resolves.toEqual(baselineHmac) + expect((await p2pkhTransaction(rootKey)).toUint8Array()).toEqual( + baselineTransaction.toUint8Array() + ) verifier.dispose() }) }) diff --git a/packages/verifast/src/flags.ts b/packages/verifast/src/flags.ts index 08c01d3ab..0211bb010 100644 --- a/packages/verifast/src/flags.ts +++ b/packages/verifast/src/flags.ts @@ -33,7 +33,7 @@ export type BdkFlagName = keyof typeof BDK_FLAG_BITS * Accepts a comma-separated string or an array. Unknown names throw so a typo * cannot silently weaken validation. */ -export function mapVerifyFlags (verifyFlags?: string | string[]): number { +export function mapVerifyFlags(verifyFlags?: string | string[]): number { if (verifyFlags === undefined) return 0 const names = Array.isArray(verifyFlags) ? verifyFlags : verifyFlags.split(',') let bits = 0 diff --git a/packages/verifast/src/workers/BdkVerifierBrowserWorker.ts b/packages/verifast/src/workers/BdkVerifierBrowserWorker.ts index 5edcdf36f..f336b8390 100644 --- a/packages/verifast/src/workers/BdkVerifierBrowserWorker.ts +++ b/packages/verifast/src/workers/BdkVerifierBrowserWorker.ts @@ -13,10 +13,11 @@ interface BrowserWorkerScope { const scope = globalThis as typeof globalThis & BrowserWorkerScope const handle = createWorkerRequestHandler( - async () => await createBdkModule({ - locateFile: (path: string, prefix: string): string => - path.endsWith('.wasm') ? `${prefix}bdk-core.wasm` : `${prefix}${path}` - }), + async () => + await createBdkModule({ + locateFile: (path: string, prefix: string): string => + path.endsWith('.wasm') ? `${prefix}bdk-core.wasm` : `${prefix}${path}` + }), (response, transfer) => scope.postMessage(response, transfer) ) scope.onmessage = event => { diff --git a/packages/verifast/src/workers/BdkVerifierNodeWorker.ts b/packages/verifast/src/workers/BdkVerifierNodeWorker.ts index 8fa32dd43..bce993d57 100644 --- a/packages/verifast/src/workers/BdkVerifierNodeWorker.ts +++ b/packages/verifast/src/workers/BdkVerifierNodeWorker.ts @@ -1,16 +1,12 @@ import { parentPort } from 'node:worker_threads' import createBdkModule from '../wasm/bdk-core.mjs' -import { - createWorkerRequestHandler, - type BdkWorkerRequest -} from './BdkWorkerProtocol.js' +import { createWorkerRequestHandler, type BdkWorkerRequest } from './BdkWorkerProtocol.js' if (parentPort === null) throw new Error('BDK worker requires a parent port') const port = parentPort -const handle = createWorkerRequestHandler( - createBdkModule, - (response, transfer) => port.postMessage(response, transfer) +const handle = createWorkerRequestHandler(createBdkModule, (response, transfer) => + port.postMessage(response, transfer) ) port.on('message', (request: BdkWorkerRequest) => { void handle(request) diff --git a/packages/verifast/src/workers/BdkWorkerPool.ts b/packages/verifast/src/workers/BdkWorkerPool.ts index 3899eb992..0d6279084 100644 --- a/packages/verifast/src/workers/BdkWorkerPool.ts +++ b/packages/verifast/src/workers/BdkWorkerPool.ts @@ -26,7 +26,7 @@ export default class BdkWorkerPool { private nextRequestId = 1 private closed = false - constructor ( + constructor( workerCount: number, createWorker: () => WorkerAdapter, private readonly onFailure?: (error: Error) => void @@ -40,16 +40,20 @@ export default class BdkWorkerPool { if ('error' in response) pending.reject(new Error(response.error)) else pending.resolve(response.result) }) - worker.onError(error => { this.fail(error) }) - worker.onExit(error => { this.fail(error) }) + worker.onError(error => { + this.fail(error) + }) + worker.onExit(error => { + this.fail(error) + }) } } - get size (): number { + get size(): number { return this.workers.length } - private async request ( + private async request( worker: WorkerAdapter, request: BdkWorkerRequestWithoutId ): Promise { @@ -67,39 +71,38 @@ export default class BdkWorkerPool { }) } - async preload (verificationTables?: Uint8Array): Promise { + async preload(verificationTables?: Uint8Array): Promise { let sharedTables = verificationTables - if ( - verificationTables !== undefined && - typeof SharedArrayBuffer !== 'undefined' - ) { + if (verificationTables !== undefined && typeof SharedArrayBuffer !== 'undefined') { const buffer = new SharedArrayBuffer(verificationTables.byteLength) sharedTables = new Uint8Array(buffer) sharedTables.set(verificationTables) } - await Promise.all(this.workers.map(async worker => { - await this.request(worker, { - operation: 'preload', - verificationTables: sharedTables + await Promise.all( + this.workers.map(async worker => { + await this.request(worker, { + operation: 'preload', + verificationTables: sharedTables + }) }) - })) + ) } - async execute ( - requests: readonly BdkWorkerRequestWithoutId[] - ): Promise { + async execute(requests: readonly BdkWorkerRequestWithoutId[]): Promise { const results: BdkWorkerResult[] = [] for (let offset = 0; offset < requests.length; offset += this.workers.length) { - results.push(...await Promise.all( - requests.slice(offset, offset + this.workers.length).map( - async (request, index) => await this.request(this.workers[index], request) - ) - )) + results.push( + ...(await Promise.all( + requests + .slice(offset, offset + this.workers.length) + .map(async (request, index) => await this.request(this.workers[index], request)) + )) + ) } return results } - terminate (): void { + terminate(): void { if (this.closed) return this.closed = true const error = new Error('BDK worker pool terminated') @@ -108,7 +111,7 @@ export default class BdkWorkerPool { for (const worker of this.workers) worker.terminate() } - private fail (error: Error): void { + private fail(error: Error): void { if (this.closed) return this.closed = true for (const pending of this.pending.values()) pending.reject(error) diff --git a/packages/verifast/src/workers/BdkWorkerProtocol.ts b/packages/verifast/src/workers/BdkWorkerProtocol.ts index 08ad86e97..55cdc5f62 100644 --- a/packages/verifast/src/workers/BdkWorkerProtocol.ts +++ b/packages/verifast/src/workers/BdkWorkerProtocol.ts @@ -37,48 +37,41 @@ export interface DigestBatchPayload { export type BdkWorkerRequest = | { - id: number - operation: 'preload' - verificationTables?: Uint8Array - } - | { id: number, operation: 'verifyScripts', payload: ScriptBatchPayload } - | { id: number, operation: 'verifySpends', payload: SpendBatchPayload } - | { id: number, operation: 'verifyDigests', payload: DigestBatchPayload } - -export type BdkWorkerRequestWithoutId = - BdkWorkerRequest extends infer Request - ? Request extends { id: number } - ? Omit - : never + id: number + operation: 'preload' + verificationTables?: Uint8Array + } + | { id: number; operation: 'verifyScripts'; payload: ScriptBatchPayload } + | { id: number; operation: 'verifySpends'; payload: SpendBatchPayload } + | { id: number; operation: 'verifyDigests'; payload: DigestBatchPayload } + +export type BdkWorkerRequestWithoutId = BdkWorkerRequest extends infer Request + ? Request extends { id: number } + ? Omit : never + : never export type BdkWorkerResult = Int32Array | Uint8Array export type BdkWorkerResponse = - | { id: number, result: BdkWorkerResult } - | { id: number, error: string } + { id: number; result: BdkWorkerResult } | { id: number; error: string } export type WorkerModuleFactory = () => Promise -function isObject (value: unknown): value is Record { +function isObject(value: unknown): value is Record { return typeof value === 'object' && value !== null } -function hasTypedArrays ( +function hasTypedArrays( value: unknown, - fields: ReadonlyArray ArrayBufferView - ]> + fields: ReadonlyArray ArrayBufferView]> ): value is Record { if (!isObject(value)) return false return fields.every(([field, Type]) => value[field] instanceof Type) } -function hasNetwork (value: Record): boolean { - return Number.isInteger(value.network) && - Number(value.network) >= 0 && - Number(value.network) <= 5 +function hasNetwork(value: Record): boolean { + return Number.isInteger(value.network) && Number(value.network) >= 0 && Number(value.network) <= 5 } /** @@ -88,7 +81,7 @@ function hasNetwork (value: Record): boolean { * The WASM adapter remains responsible for validating cross-field lengths and * offsets because it has the operation-specific semantic context. */ -export function isBdkWorkerRequest (value: unknown): value is BdkWorkerRequest { +export function isBdkWorkerRequest(value: unknown): value is BdkWorkerRequest { if ( !isObject(value) || !Number.isSafeInteger(value.id) || @@ -99,37 +92,40 @@ export function isBdkWorkerRequest (value: unknown): value is BdkWorkerRequest { } if (value.operation === 'preload') { - return value.verificationTables === undefined || - value.verificationTables instanceof Uint8Array + return value.verificationTables === undefined || value.verificationTables instanceof Uint8Array } if (!isObject(value.payload)) return false switch (value.operation) { case 'verifyScripts': - return hasTypedArrays(value.payload, [ - ['extendedTransactions', Uint8Array], - ['transactionOffsets', Uint32Array], - ['utxoHeights', Int32Array], - ['heightOffsets', Uint32Array], - ['blockHeights', Int32Array], - ['consensus', Uint8Array], - ['customFlags', Uint32Array], - ['customFlagOffsets', Uint32Array] - ]) && hasNetwork(value.payload) + return ( + hasTypedArrays(value.payload, [ + ['extendedTransactions', Uint8Array], + ['transactionOffsets', Uint32Array], + ['utxoHeights', Int32Array], + ['heightOffsets', Uint32Array], + ['blockHeights', Int32Array], + ['consensus', Uint8Array], + ['customFlags', Uint32Array], + ['customFlagOffsets', Uint32Array] + ]) && hasNetwork(value.payload) + ) case 'verifySpends': - return hasTypedArrays(value.payload, [ - ['transactions', Uint8Array], - ['transactionOffsets', Uint32Array], - ['inputIndices', Uint32Array], - ['lockingScripts', Uint8Array], - ['lockingScriptOffsets', Uint32Array], - ['sourceSatoshis', Float64Array], - ['utxoHeights', Int32Array], - ['blockHeights', Int32Array], - ['consensus', Uint8Array], - ['hasCustomFlags', Uint8Array], - ['customFlags', Uint32Array] - ]) && hasNetwork(value.payload) + return ( + hasTypedArrays(value.payload, [ + ['transactions', Uint8Array], + ['transactionOffsets', Uint32Array], + ['inputIndices', Uint32Array], + ['lockingScripts', Uint8Array], + ['lockingScriptOffsets', Uint32Array], + ['sourceSatoshis', Float64Array], + ['utxoHeights', Int32Array], + ['blockHeights', Int32Array], + ['consensus', Uint8Array], + ['hasCustomFlags', Uint8Array], + ['customFlags', Uint32Array] + ]) && hasNetwork(value.payload) + ) case 'verifyDigests': return hasTypedArrays(value.payload, [ ['publicKeys', Uint8Array], @@ -143,7 +139,7 @@ export function isBdkWorkerRequest (value: unknown): value is BdkWorkerRequest { } } -function requiredMethod ( +function requiredMethod( module: BdkWasmModule, method: K ): NonNullable { @@ -154,18 +150,18 @@ function requiredMethod ( return implementation as NonNullable } -export function requestTransferables (request: BdkWorkerRequest): ArrayBuffer[] { +export function requestTransferables(request: BdkWorkerRequest): ArrayBuffer[] { if (request.operation === 'preload') return [] return Object.values(request.payload) .filter((value): value is ArrayBufferView => ArrayBuffer.isView(value)) .map(value => value.buffer as ArrayBuffer) } -export function resultTransferables (result: BdkWorkerResult): ArrayBuffer[] { +export function resultTransferables(result: BdkWorkerResult): ArrayBuffer[] { return [result.buffer as ArrayBuffer] } -export function createWorkerRequestHandler ( +export function createWorkerRequestHandler( factory: WorkerModuleFactory, respond: (response: BdkWorkerResponse, transfer: ArrayBuffer[]) => void ): (request: BdkWorkerRequest) => Promise { @@ -244,10 +240,13 @@ export function createWorkerRequestHandler ( } respond({ id: request.id, result }, resultTransferables(result)) } catch (error) { - respond({ - id: request.id, - error: error instanceof Error ? error.message : String(error) - }, []) + respond( + { + id: request.id, + error: error instanceof Error ? error.message : String(error) + }, + [] + ) } } } diff --git a/packages/verifast/src/workers/BdkWorkerScheduler.ts b/packages/verifast/src/workers/BdkWorkerScheduler.ts index e70cbf83a..679096a97 100644 --- a/packages/verifast/src/workers/BdkWorkerScheduler.ts +++ b/packages/verifast/src/workers/BdkWorkerScheduler.ts @@ -1,12 +1,6 @@ -import type { - BdkWasmModule, - BdkVerifierOptions -} from '../BdkVerifierTypes.js' +import type { BdkWasmModule, BdkVerifierOptions } from '../BdkVerifierTypes.js' import type BdkWorkerPool from './BdkWorkerPool.js' -import type { - BdkWorkerRequestWithoutId, - BdkWorkerResult -} from './BdkWorkerProtocol.js' +import type { BdkWorkerRequestWithoutId, BdkWorkerResult } from './BdkWorkerProtocol.js' /** * Optional multi-worker scheduling kept outside the verifier core so @@ -20,10 +14,8 @@ export default class BdkWorkerScheduler { private ready = false private loading: Promise | undefined - constructor ( - private readonly createPool: ( - onFailure: (error: Error) => void - ) => BdkWorkerPool, + constructor( + private readonly createPool: (onFailure: (error: Error) => void) => BdkWorkerPool, options: BdkVerifierOptions ) { this.itemThreshold = options.batchWorkerThreshold ?? 32 @@ -31,7 +23,7 @@ export default class BdkWorkerScheduler { this.maxBatchBytes = options.maxBatchBytes ?? 32 * 1024 * 1024 } - async preload (module: BdkWasmModule): Promise { + async preload(module: BdkWasmModule): Promise { if (this.ready) return if (this.pool === undefined) { const created = this.createPool(() => { @@ -45,24 +37,24 @@ export default class BdkWorkerScheduler { } const pool = this.pool const snapshot = module.ExportVerificationTables?.() - this.loading ??= pool.preload(snapshot).then(() => { - this.ready = true - }).catch(error => { - if (this.pool === pool) { - pool.terminate() - this.pool = undefined - this.loading = undefined - this.ready = false - } - throw error - }) + this.loading ??= pool + .preload(snapshot) + .then(() => { + this.ready = true + }) + .catch(error => { + if (this.pool === pool) { + pool.terminate() + this.pool = undefined + this.loading = undefined + this.ready = false + } + throw error + }) await this.loading } - shouldUse ( - itemCount: number, - prepare: () => Promise - ): boolean { + shouldUse(itemCount: number, prepare: () => Promise): boolean { if (itemCount < this.itemThreshold) return false if (this.ready) return true // The first large batch retains the single-instance path while worker @@ -71,10 +63,7 @@ export default class BdkWorkerScheduler { return false } - parallelChunks ( - items: readonly T[], - itemBytes: (item: T) => number - ): T[][] { + parallelChunks(items: readonly T[], itemBytes: (item: T) => number): T[][] { if (items.length === 0) return [] if (this.pool === undefined) return [] const sizes = items.map(itemBytes) @@ -84,8 +73,7 @@ export default class BdkWorkerScheduler { for (let index = 0; index < items.length; index++) { if ( chunk.length > 0 && - (chunk.length >= this.maxBatchItems || - chunkBytes + sizes[index] > this.maxBatchBytes) + (chunk.length >= this.maxBatchItems || chunkBytes + sizes[index] > this.maxBatchBytes) ) { chunks.push(chunk) chunk = [] @@ -101,10 +89,7 @@ export default class BdkWorkerScheduler { let splitBytes = -1 for (let index = 0; index < chunks.length; index++) { if (chunks[index].length < 2) continue - const bytes = chunks[index].reduce( - (sum, item) => sum + itemBytes(item), - 0 - ) + const bytes = chunks[index].reduce((sum, item) => sum + itemBytes(item), 0) if (bytes > splitBytes) { splitIndex = index splitBytes = bytes @@ -124,16 +109,14 @@ export default class BdkWorkerScheduler { return chunks } - async execute ( - requests: readonly BdkWorkerRequestWithoutId[] - ): Promise { + async execute(requests: readonly BdkWorkerRequestWithoutId[]): Promise { if (this.pool === undefined) { throw new Error('BDK worker scheduler is not preloaded') } return await this.pool.execute(requests) } - terminate (): void { + terminate(): void { this.pool?.terminate() this.pool = undefined this.loading = undefined diff --git a/packages/verifast/tsconfig.json b/packages/verifast/tsconfig.json index 9f82842ea..51f02c391 100644 --- a/packages/verifast/tsconfig.json +++ b/packages/verifast/tsconfig.json @@ -5,6 +5,13 @@ "noEmit": true, "types": ["jest", "node"] }, - "include": ["mod.ts", "mod.browser.ts", "umd.ts", "src/**/*.ts", "bench/**/*.ts", "browser/**/*.ts"], + "include": [ + "mod.ts", + "mod.browser.ts", + "umd.ts", + "src/**/*.ts", + "bench/**/*.ts", + "browser/**/*.ts" + ], "exclude": ["dist", "node_modules"] } diff --git a/packages/verifast/umd.ts b/packages/verifast/umd.ts index aef4395b0..ce017c2d7 100644 --- a/packages/verifast/umd.ts +++ b/packages/verifast/umd.ts @@ -12,19 +12,23 @@ interface BdkUmdGlobal { }) => ReturnType } -async function globalFactory (): Promise { +async function globalFactory(): Promise { const factory = (globalThis as BdkUmdGlobal).createBdkModule if (factory === undefined) { throw new Error('Load bdk-core.umd.js before constructing the UMD BdkVerifier') } return await factory({ - locateFile: (path, prefix) => path.endsWith('.wasm') ? `${prefix}bdk-core.umd.wasm` : `${prefix}${path}` + locateFile: (path, prefix) => + path.endsWith('.wasm') ? `${prefix}bdk-core.umd.wasm` : `${prefix}${path}` }) } /** Classic-script/UMD verifier using the separately loaded BDK UMD module. */ export class BdkVerifier extends BdkVerifierCore { - constructor (factoryOrOptions: BdkWasmFactory | BdkVerifierOptions = {}, options: BdkVerifierOptions = {}) { + constructor( + factoryOrOptions: BdkWasmFactory | BdkVerifierOptions = {}, + options: BdkVerifierOptions = {} + ) { if (typeof factoryOrOptions === 'function') { super(factoryOrOptions, options) } else { diff --git a/packages/wallet/ts-wallet-relay/AGENTS.md b/packages/wallet/ts-wallet-relay/AGENTS.md index 034cc324a..8fcd27aa4 100644 --- a/packages/wallet/ts-wallet-relay/AGENTS.md +++ b/packages/wallet/ts-wallet-relay/AGENTS.md @@ -1,12 +1,15 @@ # CLAUDE.md — @bsv/wallet-relay v0.1.0 ## Purpose + Wallet Relay enables mobile-to-desktop wallet pairing via QR codes and encrypted WebSocket tunnels. A web app (desktop browser) shows a QR code; user scans with their mobile BSV wallet; all wallet operations (signing, key retrieval, etc.) are proxied over HTTPS+WSS relay servers to the mobile without exposing keys or trust chains to the desktop. Provides both the relay server infrastructure (Node.js) and React frontend components for web apps to add "Connect Mobile Wallet" functionality. ## Public API Surface ### Server-Side (Node.js) + From root exports: + - **`WalletRelayService`** — All-in-one relay server; constructor: `new WalletRelayService(options: WalletRelayServiceOptions)` where options include: - `app: Express` — Express instance to register routes on - `server: http.Server` — HTTP server for WebSocket upgrade @@ -20,17 +23,20 @@ From root exports: - `WS /ws` — WebSocket relay for bidirectional communication ### Lower-level components: + - **`QRSessionManager`** — Session lifecycle management (creation, status tracking, cleanup) - **`WebSocketRelay`** — WebSocket server with message routing, topic validation, token auth - **`WalletRequestHandler`** — Converts RPC calls to wallet method invocations ### Client-Side (Browser) — `@bsv/wallet-relay/client` + - **`WalletRelayClient`** — Direct HTTP/WebSocket client for session management and RPC calls; methods: - `createSession()` → `Promise<{ sessionId, qrDataUrl, pairingUri, desktopToken }>` - `getSessionStatus(sessionId)` → `Promise` - `sendRequest(sessionId, request, desktopToken)` → `Promise` ### React Components — `@bsv/wallet-relay/react` + - **`useWalletRelayClient(relayUrl?)`** — Hook wrapping WalletRelayClient; returns client instance - **`useWalletSession(client, sessionId?)`** — Hook for session state management - **`WalletConnectionModal`** — Pre-built UI component for QR pairing flow (shows modal, displays QR, handles scanning) @@ -38,6 +44,7 @@ From root exports: - **`RequestLog`** — UI for displaying pending/completed RPC requests ### Shared Types & Utilities + - **`Session`** — Session state: `{ id, status, qrData, pairingUri, desktopToken, mobileConnected, createdAt }` - **`SessionStatus`** — Enum: `'pending' | 'paired' | 'disconnected' | 'expired'` - **`PairingParams`** — QR encoding: `{ relayUrl, sessionId, sessionKey }` @@ -47,7 +54,9 @@ From root exports: - **`WalletLike`** — Any object implementing core wallet methods (createAction, signAction, etc.) ### Crypto & Encoding Utilities + From shared exports (also in `./client`): + - **`encryptEnvelope(message, key, iv?)`** → `WireEnvelope` — AES-256-GCM encryption - **`decryptEnvelope(envelope, key)`** → `string` — Decryption - **`parsePairingUri(uri)`** → `ParseResult` — Extract params from QR URI @@ -56,12 +65,14 @@ From shared exports (also in `./client`): - **`bytesToBase64url(bytes)`**, **`base64urlToBytes(b64)`** — URL-safe base64 encoding ### CLI Scaffolding + - **`npx @bsv/wallet-relay init`** — Command to scaffold Express backend + React frontend wired together - Options: `--nextjs`, `--backend`, `--frontend`, `--backend-dir`, `--frontend-dir` ## Real Usage Patterns ### 1. Set up relay server (Express + Node.js) + ```typescript import express from 'express' import { createServer } from 'http' @@ -70,18 +81,20 @@ import { WalletRelayService } from '@bsv/wallet-relay' import { ProtoWallet, PrivateKey } from '@bsv/sdk' const app = express() -app.use(cors({ - origin: process.env.ORIGIN, - allowedHeaders: ['Content-Type', 'Authorization', 'X-Desktop-Token'] -})) +app.use( + cors({ + origin: process.env.ORIGIN, + allowedHeaders: ['Content-Type', 'Authorization', 'X-Desktop-Token'] + }) +) app.use(express.json()) const server = createServer(app) const wallet = new ProtoWallet(PrivateKey.fromHex(process.env.WALLET_PRIVATE_KEY!)) -new WalletRelayService({ - app, - server, +new WalletRelayService({ + app, + server, wallet, relayUrl: process.env.RELAY_URL, origin: process.env.ORIGIN @@ -91,6 +104,7 @@ server.listen(3000) ``` ### 2. Create session and get QR code (frontend React) + ```typescript import { useWalletRelayClient } from '@bsv/wallet-relay/react' import { useEffect, useState } from 'react' @@ -119,13 +133,14 @@ function WalletConnection() { ``` ### 3. Use WalletConnectionModal component + ```typescript import { WalletConnectionModal } from '@bsv/wallet-relay/react' function App() { return ( <> - { console.log('Mobile connected, sessionId:', sessionId) @@ -139,6 +154,7 @@ function App() { ``` ### 4. Send wallet RPC call from desktop to mobile + ```typescript import { useWalletRelayClient } from '@bsv/wallet-relay/react' @@ -147,17 +163,21 @@ function sendPayment(client: WalletRelayClient, sessionId: string, desktopToken: jsonrpc: '2.0', id: 1, method: 'createAction', - params: [{ - description: 'Send payment', - outputs: [{ - satoshis: 5000, - lockingScript: '76a914...' - }] - }] + params: [ + { + description: 'Send payment', + outputs: [ + { + satoshis: 5000, + lockingScript: '76a914...' + } + ] + } + ] } const response = await client.sendRequest(sessionId, request, desktopToken) - + if (response.error) { console.error('Mobile rejected:', response.error) } else { @@ -167,6 +187,7 @@ function sendPayment(client: WalletRelayClient, sessionId: string, desktopToken: ``` ### 5. Mobile wallet implementation (WalletPairingSession) + ```typescript import { WalletPairingSession } from '@bsv/wallet-relay/client' import { PrivateKey } from '@bsv/sdk' @@ -184,7 +205,7 @@ const { relayUrl, sessionId, sessionKey } = parsePairingUri(scannedQR) await session.pair({ relayUrl, sessionId, sessionKey }) // Listen for incoming requests -session.onRequest = async (request) => { +session.onRequest = async request => { // Forward to local wallet const result = await myWallet[request.method](request.params) return result @@ -194,19 +215,20 @@ session.onRequest = async (request) => { ``` ### 6. Using Next.js with wallet relay + ```typescript // pages/api/wallet-request.ts import { WalletRelayClient } from '@bsv/wallet-relay/client' export default async function handler(req, res) { const { sessionId, desktopToken, request } = req.body - + const client = new WalletRelayClient({ baseUrl: process.env.RELAY_URL }) - + const response = await client.sendRequest(sessionId, request, desktopToken) - + // Must forward X-Desktop-Token header res.setHeader('X-Desktop-Token', desktopToken) res.json(response) @@ -226,6 +248,7 @@ export default async function handler(req, res) { ## Dependencies ### Runtime (Peer Deps) + - **`@bsv/sdk`** ^2.0.14 — Cryptography and wallet types - **`express`** >=4.0.0 (optional) — Web framework for server - **`ws`** >=8.0.0 (optional) — WebSocket server (required if using WalletRelayService) @@ -233,12 +256,14 @@ export default async function handler(req, res) { - **`react`** >=17.0.0 (optional) — React (required for react exports) ### Dev + - **`jest`** ^30.3.0 — Test runner - **`ts-jest`** ^29.4.6 — TypeScript support - **`typescript`** ^5.4.0 — Compiler - **`esbuild`** ^0.28.1 — Direct ESM/CJS bundler used by `build.mjs` ### Other ts-stack packages + - **`@bsv/sdk`** — Cryptography, ProtoWallet, PrivateKey types ## Common Pitfalls / Gotchas diff --git a/packages/wallet/ts-wallet-relay/API.md b/packages/wallet/ts-wallet-relay/API.md index 31ef26257..cfe6b199d 100644 --- a/packages/wallet/ts-wallet-relay/API.md +++ b/packages/wallet/ts-wallet-relay/API.md @@ -46,21 +46,21 @@ Express + WebSocket service that handles the full server-side pairing lifecycle. new WalletRelayService(options: WalletRelayServiceOptions) ``` -| Option | Type | Required | Default | Description | -|--------|------|----------|---------|-------------| -| `app` | `RouterLike` | No | — | Express-compatible app with `get`, `post`, and `delete` methods. REST routes are registered on it. Omit when using Next.js or another framework — call `createSession()`, `getSession()`, `sendRequest()`, and `deleteSession()` from your own route handlers instead. Uses a structural duck-type to avoid nominal type conflicts in monorepos. | -| `server` | `http.Server` | **Yes** | — | HTTP server. In default mode a non-greedy `upgrade` listener is attached here — it claims only `path` and ignores other upgrades, so other WebSocket services can share the same server. | -| `path` | `string` | No | `'/ws'` | Path the WebSocket relay claims. Forwarded to `WebSocketRelay`. Set this to mount the relay somewhere other than `/ws` (e.g. to free `/ws` for another service). Exact match only. | -| `noServer` | `boolean` | No | `false` | When `true`, no `upgrade` listener is attached. Route upgrades yourself and call `service.handleUpgrade(req, socket, head)` for this relay's path. Use when running several WebSocket services from one dispatcher. | -| `wallet` | `WalletLike` | **Yes** | — | Backend wallet for encrypting/decrypting messages. Use `ProtoWallet` with a stable private key: `new ProtoWallet(PrivateKey.fromHex(process.env.WALLET_PRIVATE_KEY!))`. The same key must be used across restarts — the mobile derives its ECDH shared secret from the backend identity key embedded in the QR code. | -| `relayUrl` | `string` | No | `process.env.RELAY_URL` → `ws://localhost:3000` | `ws://` or `wss://` base URL of this server. Returned by `GET /api/session/:id` so the mobile can resolve it after scanning the QR. Not embedded in the QR itself. | -| `origin` | `string` | No | `process.env.ORIGIN` → `http://localhost:5173` | Default `http://` or `https://` URL embedded in the QR pairing URI when `createSession()` is called without a per-session `origin` override. The mobile calls `{origin}/api/session/{topic}` over HTTPS to resolve the relay URL — this is the trust anchor. In production this is your app domain. For multi-app deployments (one relay shared by N webapps) leave this unset or set a sensible fallback, and pass `origin` per-call to `createSession({ origin })` instead. In local dev with a split Vite/Node setup, set this to the backend's LAN address so the mobile device can reach it (see `MOBILE_ORIGIN` in the quickstart). | -| `allowedOrigins` | `AllowedOrigins` | No | `origin` (legacy fallback) | Origin allowlist controlling (a) which origins may be claimed by callers of `createSession({ origin })`, and (b) which browser origins may open a desktop-role WebSocket. Accepts a `string`, `string[]`, `RegExp`, or `(origin: string) => boolean` predicate. When unset, falls back to the single-value `origin` for backward compatibility. Required for multi-app deployments. | -| `maxSessions` | `number` | No | unlimited | Maximum number of sessions held in memory at once. `GET /api/session` returns HTTP 429 when the limit is reached. | -| `schema` | `string` | No | `process.env.PAIRING_SCHEMA` → `'bsv-browser'` | Deep-link scheme used in the generated QR URI (without `://`). Defaults to `'bsv-browser'`. Set to your wallet's own scheme (e.g. `'bsv-browser'`, `'my-wallet'`) to target a specific app — the OS will open that app directly instead of showing a picker when multiple wallets are installed. The mobile app must register this scheme and pass it to `parsePairingUri` via `acceptedSchemas`. | -| `signQrCodes` | `boolean` | No | `true` | Sign the QR pairing URI with the backend wallet key. The mobile can verify the signature using `verifyPairingSignature` before connecting — this proves the QR fields have not been tampered with. Set to `false` only for backward compatibility with mobile apps that do not yet call `verifyPairingSignature`. | -| `onSessionConnected` | `(sessionId: string) => void` | No | — | Called when a mobile completes pairing and the session transitions to `'connected'`. | -| `onSessionDisconnected` | `(sessionId: string) => void` | No | — | Called when a connected mobile disconnects and the session transitions to `'disconnected'`. | +| Option | Type | Required | Default | Description | +| ----------------------- | ----------------------------- | -------- | ----------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `app` | `RouterLike` | No | — | Express-compatible app with `get`, `post`, and `delete` methods. REST routes are registered on it. Omit when using Next.js or another framework — call `createSession()`, `getSession()`, `sendRequest()`, and `deleteSession()` from your own route handlers instead. Uses a structural duck-type to avoid nominal type conflicts in monorepos. | +| `server` | `http.Server` | **Yes** | — | HTTP server. In default mode a non-greedy `upgrade` listener is attached here — it claims only `path` and ignores other upgrades, so other WebSocket services can share the same server. | +| `path` | `string` | No | `'/ws'` | Path the WebSocket relay claims. Forwarded to `WebSocketRelay`. Set this to mount the relay somewhere other than `/ws` (e.g. to free `/ws` for another service). Exact match only. | +| `noServer` | `boolean` | No | `false` | When `true`, no `upgrade` listener is attached. Route upgrades yourself and call `service.handleUpgrade(req, socket, head)` for this relay's path. Use when running several WebSocket services from one dispatcher. | +| `wallet` | `WalletLike` | **Yes** | — | Backend wallet for encrypting/decrypting messages. Use `ProtoWallet` with a stable private key: `new ProtoWallet(PrivateKey.fromHex(process.env.WALLET_PRIVATE_KEY!))`. The same key must be used across restarts — the mobile derives its ECDH shared secret from the backend identity key embedded in the QR code. | +| `relayUrl` | `string` | No | `process.env.RELAY_URL` → `ws://localhost:3000` | `ws://` or `wss://` base URL of this server. Returned by `GET /api/session/:id` so the mobile can resolve it after scanning the QR. Not embedded in the QR itself. | +| `origin` | `string` | No | `process.env.ORIGIN` → `http://localhost:5173` | Default `http://` or `https://` URL embedded in the QR pairing URI when `createSession()` is called without a per-session `origin` override. The mobile calls `{origin}/api/session/{topic}` over HTTPS to resolve the relay URL — this is the trust anchor. In production this is your app domain. For multi-app deployments (one relay shared by N webapps) leave this unset or set a sensible fallback, and pass `origin` per-call to `createSession({ origin })` instead. In local dev with a split Vite/Node setup, set this to the backend's LAN address so the mobile device can reach it (see `MOBILE_ORIGIN` in the quickstart). | +| `allowedOrigins` | `AllowedOrigins` | No | Public, or explicit `origin` (legacy fallback) | Optional origin allowlist controlling (a) which origins may be claimed by callers of `createSession({ origin })`, and (b) which browser origins may open a desktop-role WebSocket. Accepts a `string`, `string[]`, `RegExp`, or `(origin: string) => boolean` predicate. An explicitly supplied constructor `origin` remains a legacy exact-origin allowlist. When neither option is supplied, origin validation is disabled for public multi-app use; the `ORIGIN` environment fallback does not silently enable it. | +| `maxSessions` | `number` | No | unlimited | Maximum number of sessions held in memory at once. `GET /api/session` returns HTTP 429 when the limit is reached. | +| `schema` | `string` | No | `process.env.PAIRING_SCHEMA` → `'bsv-browser'` | Deep-link scheme used in the generated QR URI (without `://`). Defaults to `'bsv-browser'`. Set to your wallet's own scheme (e.g. `'bsv-browser'`, `'my-wallet'`) to target a specific app — the OS will open that app directly instead of showing a picker when multiple wallets are installed. The mobile app must register this scheme and pass it to `parsePairingUri` via `acceptedSchemas`. | +| `signQrCodes` | `boolean` | No | `true` | Sign the QR pairing URI with the backend wallet key. The mobile can verify the signature using `verifyPairingSignature` before connecting — this proves the QR fields have not been tampered with. Set to `false` only for backward compatibility with mobile apps that do not yet call `verifyPairingSignature`. | +| `onSessionConnected` | `(sessionId: string) => void` | No | — | Called when a mobile completes pairing and the session transitions to `'connected'`. | +| `onSessionDisconnected` | `(sessionId: string) => void` | No | — | Called when a connected mobile disconnects and the session transitions to `'disconnected'`. | #### Methods @@ -142,12 +142,12 @@ Dispatches a WebSocket upgrade to the relay. Use when the service is constructed #### Registered routes -| Method | Path | Body / Auth | Response | -|--------|------|-------------|----------| -| `GET` | `/api/session` | — | `{ sessionId, status, qrDataUrl, pairingUri, desktopToken }` | -| `GET` | `/api/session/:id` | — | `{ sessionId, status, relay }` | -| `POST` | `/api/request/:id` | Body: `{ method, params }` · Header: `X-Desktop-Token` | `RpcResponse` | -| `DELETE` | `/api/session/:id` | Header: `X-Desktop-Token` | `204 No Content` | +| Method | Path | Body / Auth | Response | +| -------- | ------------------ | ------------------------------------------------------ | ------------------------------------------------------------ | +| `GET` | `/api/session` | — | `{ sessionId, status, qrDataUrl, pairingUri, desktopToken }` | +| `GET` | `/api/session/:id` | — | `{ sessionId, status, relay }` | +| `POST` | `/api/request/:id` | Body: `{ method, params }` · Header: `X-Desktop-Token` | `RpcResponse` | +| `DELETE` | `/api/session/:id` | Header: `X-Desktop-Token` | `204 No Content` | `GET /api/session` automatically forwards the request's `Origin` header into `createSession({ origin })`, so the QR points back at the calling webapp rather than the relay's own URL. Returns `403` when the claimed origin is not in `allowedOrigins`, and `429` when `maxSessions` is reached. @@ -165,8 +165,8 @@ Frontend counterpart to `WalletRelayService`. Manages session creation, status p ```ts const client = new WalletRelayClient({ - onSessionChange: (s) => render(s), - onError: (msg) => showError(msg), + onSessionChange: s => render(s), + onError: msg => showError(msg) }) await client.createSession() const res = await client.sendRequest('getPublicKey', { identityKey: true }) @@ -179,26 +179,26 @@ client.destroy() new WalletRelayClient(options?: WalletRelayClientOptions) ``` -| Option | Type | Default | Description | -|--------|------|---------|-------------| -| `apiUrl` | `string` | `'/api'` | Base URL for the relay HTTP API. Can be the bare host (`'https://api.example.com'`) or include the `/api` suffix — `/api` is appended automatically if missing. | -| `pollInterval` | `number` | `3000` | Session status polling interval in ms while waiting for the mobile to connect. | -| `connectedPollInterval` | `number` | `10000` | Session status polling interval in ms once the mobile is connected. Reduced frequency since the session is stable — polling continues to detect reconnects after a mobile disconnect. | -| `persistSession` | `boolean` | `true` | Persist the active session to `sessionStorage` so a page refresh resumes the existing session. Disable if you want every mount to start fresh. | -| `sessionStorageKey` | `string` | `'wallet-relay-session:'` | Key used in `sessionStorage`. Namespaced by `apiUrl` by default — override if you need multiple relay instances on the same page. | -| `sessionStorageTtl` | `number` | `86400000` (24 h) | Max age (ms) of a persisted session before it is discarded without a network request. The server is still the authority — an expired server session is detected on the first poll and cleared regardless. | -| `onSessionChange` | `(session: SessionInfo) => void` | — | Called on session creation and on every poll that returns a new value. The `qrDataUrl` and `pairingUri` from the initial creation are merged into every subsequent poll response, so they remain available throughout the session lifecycle. | -| `onLogChange` | `(log: RequestLogEntry[]) => void` | — | Called whenever the request log changes — when a request is added or a response arrives. | -| `onError` | `(error: string) => void` | — | Called when `createSession()` fails. | +| Option | Type | Default | Description | +| ----------------------- | ---------------------------------- | --------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `apiUrl` | `string` | `'/api'` | Base URL for the relay HTTP API. Can be the bare host (`'https://api.example.com'`) or include the `/api` suffix — `/api` is appended automatically if missing. | +| `pollInterval` | `number` | `3000` | Session status polling interval in ms while waiting for the mobile to connect. | +| `connectedPollInterval` | `number` | `10000` | Session status polling interval in ms once the mobile is connected. Reduced frequency since the session is stable — polling continues to detect reconnects after a mobile disconnect. | +| `persistSession` | `boolean` | `true` | Persist the active session to `sessionStorage` so a page refresh resumes the existing session. Disable if you want every mount to start fresh. | +| `sessionStorageKey` | `string` | `'wallet-relay-session:'` | Key used in `sessionStorage`. Namespaced by `apiUrl` by default — override if you need multiple relay instances on the same page. | +| `sessionStorageTtl` | `number` | `86400000` (24 h) | Max age (ms) of a persisted session before it is discarded without a network request. The server is still the authority — an expired server session is detected on the first poll and cleared regardless. | +| `onSessionChange` | `(session: SessionInfo) => void` | — | Called on session creation and on every poll that returns a new value. The `qrDataUrl` and `pairingUri` from the initial creation are merged into every subsequent poll response, so they remain available throughout the session lifecycle. | +| `onLogChange` | `(log: RequestLogEntry[]) => void` | — | Called whenever the request log changes — when a request is added or a response arrives. | +| `onError` | `(error: string) => void` | — | Called when `createSession()` fails. | #### Properties -| Property | Type | Description | -|----------|------|-------------| -| `session` | `SessionInfo \| null` | Current session state, or `null` before `createSession()` is called. | -| `log` | `RequestLogEntry[]` | Request log, newest first. | -| `error` | `string \| null` | Error from the last failed `createSession()`, or `null`. | -| `wallet` | `Pick \| null` | `WalletInterface`-compatible proxy when `session.status === 'connected'`, otherwise `null`. Each method forwards to `sendRequest` and throws on error — use as a drop-in replacement for `WalletClient` at existing call sites. See [wallet proxy](#wallet-proxy). | +| Property | Type | Description | +| --------- | ------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `session` | `SessionInfo \| null` | Current session state, or `null` before `createSession()` is called. | +| `log` | `RequestLogEntry[]` | Request log, newest first. | +| `error` | `string \| null` | Error from the last failed `createSession()`, or `null`. | +| `wallet` | `Pick \| null` | `WalletInterface`-compatible proxy when `session.status === 'connected'`, otherwise `null`. Each method forwards to `sendRequest` and throws on error — use as a drop-in replacement for `WalletClient` at existing call sites. See [wallet proxy](#wallet-proxy). | #### Methods @@ -213,7 +213,7 @@ Attempts to resume a previously persisted session from `sessionStorage`. Verifie Use before `createSession()` when you want page refreshes to survive: ```ts -const session = await client.resumeSession() ?? await client.createSession() +const session = (await client.resumeSession()) ?? (await client.createSession()) ``` When using `useWalletRelayClient` with `autoCreate: true` (the default), `resumeSession` is called automatically on mount before falling back to `createSession`. @@ -250,10 +250,10 @@ try { if (err instanceof WalletRelayError) { switch (err.code) { case 'SESSION_NOT_CONNECTED': // no active session or session not paired yet - case 'REQUEST_TIMEOUT': // mobile did not respond within 30 s - case 'SESSION_DISCONNECTED': // mobile dropped while the request was in-flight - case 'INVALID_TOKEN': // desktopToken mismatch — likely a config issue - case 'NETWORK_ERROR': // fetch failed or unexpected HTTP error + case 'REQUEST_TIMEOUT': // mobile did not respond within 30 s + case 'SESSION_DISCONNECTED': // mobile dropped while the request was in-flight + case 'INVALID_TOKEN': // desktopToken mismatch — likely a config issue + case 'NETWORK_ERROR': // fetch failed or unexpected HTTP error } } } @@ -297,11 +297,11 @@ class WalletRelayError extends Error { } type WalletRelayErrorCode = - | 'SESSION_NOT_CONNECTED' // no active session or session not yet in connected state - | 'REQUEST_TIMEOUT' // mobile did not respond within 30 s - | 'SESSION_DISCONNECTED' // mobile dropped while the request was in-flight - | 'INVALID_TOKEN' // desktopToken mismatch — likely a client config issue - | 'NETWORK_ERROR' // fetch failed or unexpected HTTP status + | 'SESSION_NOT_CONNECTED' // no active session or session not yet in connected state + | 'REQUEST_TIMEOUT' // mobile did not respond within 30 s + | 'SESSION_DISCONNECTED' // mobile dropped while the request was in-flight + | 'INVALID_TOKEN' // desktopToken mismatch — likely a client config issue + | 'NETWORK_ERROR' // fetch failed or unexpected HTTP status ``` Use `err instanceof WalletRelayError` to type-narrow, then `err.code` to branch on the failure mode. @@ -352,20 +352,20 @@ new WalletPairingSession( ) ``` -| Parameter | Type | Description | -|-----------|------|-------------| -| `wallet` | `WalletLike` | Mobile wallet. Used to fetch the identity key and to encrypt/decrypt all messages. | -| `params` | `PairingParams` | Parsed pairing parameters from `parsePairingUri()`. | -| `options` | `WalletPairingSessionOptions` | Optional configuration — see below. | +| Parameter | Type | Description | +| --------- | ----------------------------- | ---------------------------------------------------------------------------------- | +| `wallet` | `WalletLike` | Mobile wallet. Used to fetch the identity key and to encrypt/decrypt all messages. | +| `params` | `PairingParams` | Parsed pairing parameters from `parsePairingUri()`. | +| `options` | `WalletPairingSessionOptions` | Optional configuration — see below. | #### WalletPairingSessionOptions -| Option | Type | Default | Description | -|--------|------|---------|-------------| -| `implementedMethods` | `Set` | `DEFAULT_IMPLEMENTED_METHODS` | Methods your handler actually implements. Requests for any other method receive a `501` response without invoking `onApprovalRequired` or `onRequest`. The default covers the full BSV Browser method set: `getPublicKey`, `listOutputs`, `listCertificates`, `createAction`, `signAction`, `createSignature`, `verifySignature`, `listActions`, `internalizeAction`, `acquireCertificate`, `relinquishCertificate`, `revealCounterpartyKeyLinkage`, `createHmac`, `verifyHmac`, `encrypt`, `decrypt`. | -| `autoApproveMethods` | `Set` | `DEFAULT_AUTO_APPROVE_METHODS` | Subset of `implementedMethods` executed without calling `onApprovalRequired`. Defaults to `{ 'getPublicKey' }`. | -| `onApprovalRequired` | `(method, params) => Promise` | `undefined` | Called for every implemented method not in `autoApproveMethods`. Return `true` to approve, `false` to send a `4001 User Rejected` response. If omitted, all implemented methods are auto-approved. | -| `walletMeta` | `Record` | `{}` | Additional metadata sent inside the `pairing_approved` payload. Useful for identifying the wallet on the desktop side (e.g. `{ name, version }`). | +| Option | Type | Default | Description | +| -------------------- | -------------------------------------- | ------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `implementedMethods` | `Set` | `DEFAULT_IMPLEMENTED_METHODS` | Methods your handler actually implements. Requests for any other method receive a `501` response without invoking `onApprovalRequired` or `onRequest`. The default covers the full BSV Browser method set: `getPublicKey`, `listOutputs`, `listCertificates`, `createAction`, `signAction`, `createSignature`, `verifySignature`, `listActions`, `internalizeAction`, `acquireCertificate`, `relinquishCertificate`, `revealCounterpartyKeyLinkage`, `createHmac`, `verifyHmac`, `encrypt`, `decrypt`. | +| `autoApproveMethods` | `Set` | `DEFAULT_AUTO_APPROVE_METHODS` | Subset of `implementedMethods` executed without calling `onApprovalRequired`. Defaults to `{ 'getPublicKey' }`. | +| `onApprovalRequired` | `(method, params) => Promise` | `undefined` | Called for every implemented method not in `autoApproveMethods`. Return `true` to approve, `false` to send a `4001 User Rejected` response. If omitted, all implemented methods are auto-approved. | +| `walletMeta` | `Record` | `{}` | Additional metadata sent inside the `pairing_approved` payload. Useful for identifying the wallet on the desktop side (e.g. `{ name, version }`). | `DEFAULT_IMPLEMENTED_METHODS` and `DEFAULT_AUTO_APPROVE_METHODS` are exported from `@bsv/wallet-relay/client` so you can reference or extend them: @@ -373,7 +373,7 @@ new WalletPairingSession( import { DEFAULT_IMPLEMENTED_METHODS, DEFAULT_AUTO_APPROVE_METHODS } from '@bsv/wallet-relay/client' const session = new WalletPairingSession(wallet, params, { - implementedMethods: new Set([...DEFAULT_IMPLEMENTED_METHODS, 'myCustomMethod']), + implementedMethods: new Set([...DEFAULT_IMPLEMENTED_METHODS, 'myCustomMethod']) }) ``` @@ -462,11 +462,11 @@ on(event: 'error', handler: (msg: string) => void): this Registers an event listener. Multiple listeners per event are supported. Returns `this` for chaining. -| Event | Fires when | -|-------|-----------| -| `connected` | The first successfully decrypted message is received (session is live) | -| `disconnected` | The WebSocket closes after a successful connection | -| `error` | A connection error occurs, or the relay could not be reached | +| Event | Fires when | +| -------------- | ---------------------------------------------------------------------- | +| `connected` | The first successfully decrypted message is received (session is live) | +| `disconnected` | The WebSocket closes after a successful connection | +| `error` | A connection error occurs, or the relay could not be reached | #### `status` property @@ -500,29 +500,29 @@ const { session, log, error, createSession, cancelSession, sendRequest } = useWa All options are optional. -| Option | Type | Default | Description | -|--------|------|---------|-------------| -| `apiUrl` | `string` | `'/api'` | Backend base URL. `/api` is appended automatically if missing. | -| `pollInterval` | `number` | `3000` | Status polling interval in ms while waiting for mobile to connect. | -| `connectedPollInterval` | `number` | `10000` | Status polling interval in ms once connected. | -| `persistSession` | `boolean` | `true` | Persist session to `sessionStorage` for page-refresh survival. | -| `sessionStorageKey` | `string` | `'wallet-relay-session:'` | Override the storage key. | -| `sessionStorageTtl` | `number` | `86400000` | Max age (ms) before a persisted session is discarded client-side. | -| `autoCreate` | `boolean` | `true` | When `true`, `resumeSession()` is tried on mount, falling back to `createSession()` if nothing to resume. Set to `false` to control timing manually. | -| `autoResume` | `boolean` | `false` | When `true` *and* `autoCreate` is `false`, attempts `resumeSession()` on mount but never auto-creates. Useful when a "Sign in with phone" button owns session creation while you still want refreshes to keep the user paired. No effect when `autoCreate !== false` — resume is already part of that path. | +| Option | Type | Default | Description | +| ----------------------- | --------- | --------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `apiUrl` | `string` | `'/api'` | Backend base URL. `/api` is appended automatically if missing. | +| `pollInterval` | `number` | `3000` | Status polling interval in ms while waiting for mobile to connect. | +| `connectedPollInterval` | `number` | `10000` | Status polling interval in ms once connected. | +| `persistSession` | `boolean` | `true` | Persist session to `sessionStorage` for page-refresh survival. | +| `sessionStorageKey` | `string` | `'wallet-relay-session:'` | Override the storage key. | +| `sessionStorageTtl` | `number` | `86400000` | Max age (ms) before a persisted session is discarded client-side. | +| `autoCreate` | `boolean` | `true` | When `true`, `resumeSession()` is tried on mount, falling back to `createSession()` if nothing to resume. Set to `false` to control timing manually. | +| `autoResume` | `boolean` | `false` | When `true` _and_ `autoCreate` is `false`, attempts `resumeSession()` on mount but never auto-creates. Useful when a "Sign in with phone" button owns session creation while you still want refreshes to keep the user paired. No effect when `autoCreate !== false` — resume is already part of that path. | #### Return value -| Property | Type | Description | -|----------|------|-------------| -| `session` | `SessionInfo \| null` | Current session. `qrDataUrl` and `pairingUri` are present from creation and preserved through subsequent polls. | -| `log` | `RequestLogEntry[]` | Request history, newest first. | -| `error` | `string \| null` | Error from the last failed `createSession()`, or `null`. | -| `createSession` | `() => Promise` | Create a new session and restart polling. Safe to call multiple times — replaces the existing session. | -| `resumeSession` | `() => Promise` | Try to resume a persisted session from `sessionStorage`. Returns the resumed `SessionInfo` (which exposes the `wallet` proxy when status is `'connected'`), or `null` if nothing to resume or the server says it's expired. Concurrent calls are deduped — the second caller gets the in-flight promise. | -| `cancelSession` | `() => void` | Resets all state to `null`, then calls `disconnect()` on the client (fire-and-forget). This terminates the session server-side and closes the mobile's WebSocket so the mobile app is notified. Call this on unmount when leaving a QR page, or on user logout. A subsequent `createSession()` starts fresh. | -| `sendRequest` | `(method: string, params?: unknown) => Promise` | Send an RPC call to the paired mobile. Throws if no session is active. | -| `wallet` | `Pick \| null` | Drop-in `WalletInterface` proxy when connected, `null` otherwise. See [wallet proxy](#wallet-proxy). | +| Property | Type | Description | +| --------------- | --------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `session` | `SessionInfo \| null` | Current session. `qrDataUrl` and `pairingUri` are present from creation and preserved through subsequent polls. | +| `log` | `RequestLogEntry[]` | Request history, newest first. | +| `error` | `string \| null` | Error from the last failed `createSession()`, or `null`. | +| `createSession` | `() => Promise` | Create a new session and restart polling. Safe to call multiple times — replaces the existing session. | +| `resumeSession` | `() => Promise` | Try to resume a persisted session from `sessionStorage`. Returns the resumed `SessionInfo` (which exposes the `wallet` proxy when status is `'connected'`), or `null` if nothing to resume or the server says it's expired. Concurrent calls are deduped — the second caller gets the in-flight promise. | +| `cancelSession` | `() => void` | Resets all state to `null`, then calls `disconnect()` on the client (fire-and-forget). This terminates the session server-side and closes the mobile's WebSocket so the mobile app is notified. Call this on unmount when leaving a QR page, or on user logout. A subsequent `createSession()` starts fresh. | +| `sendRequest` | `(method: string, params?: unknown) => Promise` | Send an RPC call to the paired mobile. Throws if no session is active. | +| `wallet` | `Pick \| null` | Drop-in `WalletInterface` proxy when connected, `null` otherwise. See [wallet proxy](#wallet-proxy). | React StrictMode safe — an internal ref guard prevents double session creation on the simulated unmount/remount cycle. @@ -542,34 +542,37 @@ Returns `null` while detecting or once a local wallet is found. ```ts type WalletConnectionModalProps = { - onLocalWallet: (wallet: WalletClient) => void - onMobileQR: () => void - installUrl?: string - installLabel?: string - mobileLabel?: string - installLinkProps?: React.AnchorHTMLAttributes + onLocalWallet: (wallet: WalletClient) => void + onMobileQR: () => void + installUrl?: string + installLabel?: string + mobileLabel?: string + installLinkProps?: React.AnchorHTMLAttributes mobileButtonProps?: React.ButtonHTMLAttributes } & React.HTMLAttributes ``` -| Prop | Default | Description | -|------|---------|-------------| -| `onLocalWallet` | — | Called with the detected `WalletClient` when authentication succeeds. No UI is shown. | -| `onMobileQR` | — | Called when the user clicks the mobile QR button. | -| `installUrl` | `'https://desktop.bsvb.tech'` | `href` of the install link. | -| `installLabel` | `'Install BSV Wallet'` | Text for the install link. | -| `mobileLabel` | `'Connect via Mobile QR'` | Text for the mobile QR button. | -| `installLinkProps` | — | Props forwarded to the install ``. | -| `mobileButtonProps` | — | Props forwarded to the mobile QR `