From c58f81787e7c2a985a167c15db9fd9ca5fed6c35 Mon Sep 17 00:00:00 2001 From: Deggen Date: Tue, 8 Sep 2026 12:23:29 -0500 Subject: [PATCH 01/58] fix(wallet-toolbox): verify overlay identity evidence --- docs/packages/wallet/wallet-toolbox-client.md | 2 +- docs/packages/wallet/wallet-toolbox-mobile.md | 2 +- docs/packages/wallet/wallet-toolbox.md | 2 +- docs/reference/package-api-migrations.md | 84 ++-- docs/reference/stack-facts.md | 6 +- governance/package-release-notes.json | 12 +- governance/repository-health/baselines.json | 6 +- packages/wallet/wallet-toolbox/CHANGELOG.md | 11 + packages/wallet/wallet-toolbox/README.md | 20 +- .../wallet/wallet-toolbox/client/README.md | 7 + .../wallet/wallet-toolbox/client/package.json | 2 +- .../client/platform-budget.json | 8 +- .../docs/identity-verification.md | 106 +++++ .../wallet/wallet-toolbox/mobile/README.md | 7 + .../wallet/wallet-toolbox/mobile/package.json | 2 +- .../mobile/platform-budget.json | 6 +- packages/wallet/wallet-toolbox/package.json | 2 +- packages/wallet/wallet-toolbox/src/Wallet.ts | 74 ++-- .../Wallet.identityVerification.test.ts | 176 ++++++++ .../fixtures/identity-verification.json | 4 + .../identityVerification.fixtures.ts | 149 +++++++ .../__tests__/identityVerification.test.ts | 396 ++++++++++++++++++ .../src/utility/identityUtils.ts | 91 +++- .../src/utility/verifyOverlayOutput.ts | 79 ++++ 24 files changed, 1133 insertions(+), 121 deletions(-) create mode 100644 packages/wallet/wallet-toolbox/docs/identity-verification.md create mode 100644 packages/wallet/wallet-toolbox/src/__tests/Wallet.identityVerification.test.ts create mode 100644 packages/wallet/wallet-toolbox/src/utility/__tests__/fixtures/identity-verification.json create mode 100644 packages/wallet/wallet-toolbox/src/utility/__tests__/identityVerification.fixtures.ts create mode 100644 packages/wallet/wallet-toolbox/src/utility/__tests__/identityVerification.test.ts create mode 100644 packages/wallet/wallet-toolbox/src/utility/verifyOverlayOutput.ts diff --git a/docs/packages/wallet/wallet-toolbox-client.md b/docs/packages/wallet/wallet-toolbox-client.md index d5496eb82..c582ce5f3 100644 --- a/docs/packages/wallet/wallet-toolbox-client.md +++ b/docs/packages/wallet/wallet-toolbox-client.md @@ -3,7 +3,7 @@ id: pkg-wallet-toolbox-client title: '@bsv/wallet-toolbox-client' kind: package domain: wallet -version: '2.11.0' +version: '2.11.1' last_updated: '2026-08-31' last_verified: '2026-08-31' review_cadence_days: 30 diff --git a/docs/packages/wallet/wallet-toolbox-mobile.md b/docs/packages/wallet/wallet-toolbox-mobile.md index beb53276c..b01fad0ce 100644 --- a/docs/packages/wallet/wallet-toolbox-mobile.md +++ b/docs/packages/wallet/wallet-toolbox-mobile.md @@ -3,7 +3,7 @@ id: pkg-wallet-toolbox-mobile title: '@bsv/wallet-toolbox-mobile' kind: package domain: wallet -version: '2.11.0' +version: '2.11.1' last_updated: '2026-08-31' last_verified: '2026-08-31' review_cadence_days: 30 diff --git a/docs/packages/wallet/wallet-toolbox.md b/docs/packages/wallet/wallet-toolbox.md index 5f5487c55..583981ec0 100644 --- a/docs/packages/wallet/wallet-toolbox.md +++ b/docs/packages/wallet/wallet-toolbox.md @@ -4,7 +4,7 @@ title: '@bsv/wallet-toolbox' kind: package domain: wallet npm: '@bsv/wallet-toolbox' -version: '2.11.0' +version: '2.11.1' last_updated: '2026-08-31' last_verified: '2026-08-31' review_cadence_days: 30 diff --git a/docs/reference/package-api-migrations.md b/docs/reference/package-api-migrations.md index 995d1e809..7c20e6db3 100644 --- a/docs/reference/package-api-migrations.md +++ b/docs/reference/package-api-migrations.md @@ -23,42 +23,42 @@ and clean-consumer tests remain the executable type authority. ## Current release boundary -| Package | npm baseline | Source | Candidate | API | Migration | -| --------------------------------- | ------------ | -------- | --------- | --------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `@bsv/402-pay` | `0.2.1` | `0.2.5` | patch | [API and usage](../packages/middleware/402-pay.md) | No consumer migration is required; client and server exports, payment protocol behavior, and runtime defaults are unchanged. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/ with the package. | -| `@bsv/air-gap` | `0.0.0` | `0.1.2` | minor | [API and usage](../packages/helpers/air-gap.md) | No consumer migration is required; this is the first published release of a new package with no prior public API. The experimental pre-release framing that circulated on the unmerged feature branch is not accepted by the v1 decoder. | -| `@bsv/amountinator` | `2.1.1` | `2.1.5` | patch | [API and usage](../packages/helpers/amountinator.md) | No consumer migration is required; this is a backward-compatible patch candidate. | -| `@bsv/auth` | `0.1.1` | `0.1.4` | patch | [API and usage](../packages/middleware/auth.md) | No consumer migration is required; authentication APIs, wire behavior, and runtime defaults are unchanged. | -| `@bsv/auth-express-middleware` | `2.2.0` | `2.2.3` | patch | [API and usage](../packages/middleware/auth-express-middleware.md) | No API migration is required. Upgrade to @bsv/sdk 2.4.1 or later for the shared byte-boundary contract. Generic signed application-body canonicalization remains unchanged so old and new peers verify identical bytes. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/ with the package. | -| `@bsv/authsocket` | `2.1.1` | `2.1.7` | patch | [API and usage](../packages/messaging/authsocket.md) | No API migration is required. Existing event data, including numeric-key objects under byte-like names, is unchanged; typed payment protocols recover historical byte objects at their explicit fields. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/ with the package. | -| `@bsv/authsocket-client` | `2.1.1` | `2.1.6` | patch | [API and usage](../packages/messaging/authsocket-client.md) | No API migration is required. Existing event data, including numeric-key objects under byte-like names, is unchanged; typed payment protocols recover historical byte objects at their explicit fields. Distributors who copy the UMD file must keep THIRD_PARTY_NOTICES.md and LICENSES/ with it. | -| `@bsv/btms` | `1.1.1` | `1.2.2` | minor | [API and usage](../packages/wallet/btms.md) | Existing local, mainnet, testnet, and number-array behavior is unchanged. TTN consumers select networkPreset teratestnet; all consumers should upgrade to @bsv/sdk 2.4.1 or later for byte-boundary compatibility. | -| `@bsv/btms-permission-module` | `1.1.1` | `1.1.4` | patch | [API and usage](../packages/wallet/btms-permission-module.md) | No consumer migration is required; permission-module APIs and token semantics are unchanged. | -| `@bsv/chirp` | `0.0.0` | `0.1.1` | minor | [API and usage](../packages/network/chirp.md) | No consumer migration is required; this is the first release of a new additive package. Existing @bsv/sdk StorageUploader, StorageDownloader, StorageUtils, UHRP identifiers, overlays, and server routes remain unchanged. BRC-167 remains authoritative if the implementation and standard differ. | -| `@bsv/did` | `0.2.1` | `0.2.5` | patch | [API and usage](../packages/helpers/did.md) | No consumer migration is required; DID APIs, encodings, credential behavior, and supported import forms are unchanged. | -| `@bsv/did-client` | `1.2.1` | `1.3.1` | minor | [API and usage](../packages/helpers/did-client.md) | Existing local, mainnet, and testnet behavior is unchanged. TTN consumers select networkPreset teratestnet and use @bsv/sdk 2.4 or later. Distributors who copy the UMD file must keep THIRD_PARTY_NOTICES.md and LICENSES/ with it. | -| `@bsv/ecpm-permission-module` | `0.0.0` | `0.1.0` | minor | [API and usage](../packages/wallet/ecpm-permission-module.md) | No existing consumer migration is required; this is the first release. Wallet hosts register the module under the ecpm scheme and supply their ordinary key deriver, authorization handler, and optional privileged key provider. | -| `@bsv/fund-wallet` | `1.4.1` | `1.4.4` | patch | [API and usage](../packages/helpers/fund-wallet.md) | No consumer migration is required; wallet funding APIs and transaction behavior are unchanged. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/ with the package. | -| `@bsv/gasp` | `1.3.1` | `1.3.6` | patch | [API and usage](../packages/overlays/gasp.md) | No consumer migration is required; existing constructor calls, imports, synchronization behavior, and wire semantics are unchanged. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/ with the package. | -| `@bsv/lch` | `0.0.0` | `0.1.0` | minor | [API and usage](../packages/content/lch.md) | No consumer migration is required; this is the first release of a new additive package. Applications must give quote() the verified Asset encryption descriptor and selected Offer key-delivery mechanism, keep createPayment behind explicit wallet authorization, persist the funded transaction and every partial settlement proof through recovery, retry with that same transaction, distinguish finalized from accepted evidence, fail closed on unknown settlement or evidence profiles, and supply an address-pinning connector for DNS endpoints. Payees should select receipt-complete unless they explicitly accept authorized-output provider, privacy, and pre-internalization key-release tradeoffs. Distributors must retain THIRD_PARTY_NOTICES.md with the package; the new profile adds no dependency. Published BRC-170 remains authoritative if the implementation and standard differ. | -| `@bsv/message-box-client` | `2.4.0` | `2.5.0` | minor | [API and usage](../packages/messaging/message-box-client.md) | No API migration is required. socketOptions is optional and nothing is forwarded by default, so socket transport negotiation and every HTTP code path are unchanged for existing callers; wallet and originator stay client-owned and cannot be overridden through it. managerOptions excludes autoConnect and retries: JavaScript callers receive an immediate error for autoConnect: false or nonzero message retries because the live socket starts immediately and AuthSocket does not send Socket.IO acknowledgements. Connection reconnection options remain supported. Upgrade @bsv/sdk and @bsv/message-box-client together; historical number-array wallets, current Uint8Array substrates, and already-pending numeric-key messages interoperate through the same portable transaction form. Distributors who copy the UMD file must keep THIRD_PARTY_NOTICES.md and LICENSES/ with it. | -| `@bsv/overlay` | `2.2.1` | `2.3.1` | minor | [API and usage](../packages/overlays/overlay.md) | Existing Engine and TopicManager implementations remain valid. Lookup results default to 1,000 formulas; pass -1 only when an equivalent deployment bound exists. Topic managers whose validation creates provisional external state should implement abortAdmissibleOutputs, while read-only managers require no change. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/ with the package. | -| `@bsv/overlay-discovery-services` | `2.1.1` | `2.2.1` | minor | [API and usage](../packages/overlays/overlay-discovery-services.md) | Existing mainnet and testnet advertisers are unchanged. TTN operators pass chain ttn and provision the staging storage and overlay endpoints before advertising. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/ with the package. | -| `@bsv/overlay-express` | `2.5.0` | `2.6.1` | minor | [API and usage](../packages/overlays/overlay-express.md) | Existing mainnet and testnet servers are unchanged. TTN servers call configureNetwork('ttn'), configureArcade with the TTN endpoint, and configureChaintracks or configureChainTracker before engine initialization. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/ with the package. | -| `@bsv/overlay-topics` | `1.6.10` | `1.7.1` | minor | [API and usage](../packages/overlays/overlay-topics.md) | Existing topic and lookup identifiers remain unchanged. Production UMP overlays must give UMPTopicManager and the UMP lookup service Mongo-backed stores that use the same database, then roll out before updated wallet clients; the no-argument manager is bounded but intended only for isolated single-process use. The reservation and bootstrap-marker collections are additive and initialize from currently indexed UMP UTXOs; take a MongoDB backup before rollout. Legacy ambiguous rows remain visible and can be resolved with WAB pinning rather than deleted. | -| `@bsv/paymail` | `2.4.2` | `2.4.7` | patch | [API and usage](../packages/messaging/paymail.md) | Existing Paymail client APIs and protocol semantics are retained. Consumers provide one Express 4.18 or 5 runtime and matching type graph; browser bundles continue to exclude the server router implementation. Consumers of the former bundled Money Button or Tokenized specification documents must follow the authoritative links in docs/specs/README.md. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/ with the package. | -| `@bsv/payment-express-middleware` | `2.1.1` | `2.1.6` | patch | [API and usage](../packages/middleware/payment-express-middleware.md) | No consumer migration is required; legacy x-bsv-payment JSON behavior remains supported, and Express 4 and 5 applications use their own peer-provided Express installation. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/ with the package. | -| `@bsv/sdk` | `2.4.0` | `2.4.2` | patch | [API and usage](../packages/sdk/bsv-sdk.md) | No API migration is required. Historical number-array fast paths and React Native behavior remain compatible. Documentation users should load docs/swagger/swagger.yaml into their preferred viewer instead of using the removed static Swagger UI scaffold. Distributors must keep THIRD_PARTY_NOTICES.md and LICENSES/ with source and browser bundles. | -| `@bsv/simple` | `0.4.1` | `0.5.3` | minor | [API and usage](../packages/helpers/simple.md) | Existing overlay configurations and number-array behavior are unchanged. TTN consumers select network teratestnet; all consumers should upgrade to @bsv/sdk 2.4.2 or later. The Message Box Client dependency advance requires no helper API migration and preserves the default HTTP and live-socket behavior. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/. | -| `@bsv/templates` | `1.9.1` | `1.10.1` | minor | [API and usage](../packages/helpers/templates.md) | No existing consumer migration is required; existing template APIs and generated scripts are unchanged. New R1K1Wallet consumers await lock(), retain each private 32-byte salt, and provide a PIV signer that signs the supplied digest directly without hashing it again. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/ with the package. | -| `@bsv/teranode-listener` | `1.1.1` | `1.1.5` | patch | [API and usage](../packages/network/teranode-listener.md) | No consumer migration is required; listener APIs, topics, and network configuration are unchanged. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/ with the package. | -| `@bsv/verifast` | `0.3.0` | `0.3.5` | patch | [API and usage](../packages/sdk/verifast.md) | No consumer migration is required; exports, verification behavior, worker protocols, package paths, and runtime defaults are unchanged. Keep THIRD_PARTY_NOTICES.md and LICENSES/ with every JavaScript and WebAssembly distribution. | -| `@bsv/wallet-helper` | `0.1.1` | `0.1.7` | patch | [API and usage](../packages/helpers/wallet-helper.md) | No consumer migration is required; fluent builder APIs and transaction semantics are unchanged. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/ with the package. | -| `@bsv/wallet-relay` | `0.2.2` | `0.3.6` | minor | [API and usage](../packages/wallet/wallet-relay.md) | No wallet RPC migration is required; upgrade to @bsv/sdk 2.4.1 or later. Existing relay sessions and number arrays remain valid, and host applications continue to provide their matching Express runtime and type graph. | -| `@bsv/wallet-toolbox` | `2.10.4` | `2.11.0` | minor | [API and usage](../packages/wallet/wallet-toolbox.md) | Existing actions, ordinary noSend calls, permission modules, and UMP v3 tokens require no migration. To use BRC-177, migrate every active Knex store before serving requests and run the default Wallet Toolbox monitor; IndexedDB upgrades automatically to schema version 5. Upgrade signer, active storage service, and remote monitor together to 2.11.0 or later; older remote storage is rejected before prefunding. The Knex migration also adds rebuildable prepared-BEEF and proof-epoch tables with every COOK control disabled. Validate the migration on MySQL before release and the cross-process epoch fence on non-production PXC before enabling writes. Roll out writes before reads, use backfill only after database review, and disable all three flags to roll back. Delete derived prepared rows before downgrading to code that cannot advance the epoch. Semantic modules may add handleRequest; hosts installing @bsv/ecpm-permission-module register it under the ecpm scheme. Argon2id tokens keep the same parameters and derived bytes across WebAssembly and JavaScript runtimes. Host registration is available from each package root; concurrent cold calls share a preload attempt, and hosts must make readiness/preload reentrant and cache permanent failures or back off retries. Native and JavaScript results share byte-type and exact-length validation; unrelated hash-wasm errors still propagate without WebAssembly. Canonical AtomicBEEF and number-array behavior are unchanged; use @bsv/sdk 2.4.2 or later, use docs/storage.md instead of the removed JSight export, and retain THIRD_PARTY_NOTICES.md and LICENSES/. | -| `@bsv/wallet-toolbox-client` | `2.10.4` | `2.11.0` | minor | [API and usage](../packages/wallet/wallet-toolbox-client.md) | Existing browser actions, permission modules, and UMP v3 tokens require no migration; IndexedDB upgrades automatically. To use BRC-177 with remote storage, upgrade the active storage service and its default monitor to Wallet Toolbox 2.11.0 or later before upgrading clients; an older server is rejected before prefunding. Prepared BEEF persistence and rollout controls apply only to the full package's Knex provider, so IndexedDB and remote clients require no COOK configuration. Semantic modules may add handleRequest; installing @bsv/ecpm-permission-module requires registration under the ecpm scheme. Argon2id tokens keep the same parameters and derived bytes across WebAssembly and JavaScript runtimes. Host registration is available from each package root; concurrent cold calls share a preload attempt, and hosts must make readiness/preload reentrant and cache permanent failures or back off retries. Native and JavaScript results share byte-type and exact-length validation; unrelated hash-wasm errors still propagate without WebAssembly. Browser exports, wire types, canonical AtomicBEEF behavior, and pagination contracts are unchanged; use @bsv/sdk 2.4.2 or later and retain THIRD_PARTY_NOTICES.md and LICENSES/. | -| `@bsv/wallet-toolbox-mobile` | `2.10.4` | `2.11.0` | minor | [API and usage](../packages/wallet/wallet-toolbox-mobile.md) | Existing mobile actions, permission modules, and UMP v3 tokens require no migration. To use BRC-177, migrate and upgrade the active remote storage service and its default monitor to Wallet Toolbox 2.11.0 or later before upgrading clients; an older server is rejected before prefunding. Prepared BEEF persistence and rollout controls apply only to the full package's Knex provider, so mobile remote clients require no COOK configuration. Semantic modules may add handleRequest without changing the Wallet interface. Argon2id tokens keep the same parameters and derived bytes across WebAssembly and JavaScript runtimes; no user device setting is required. Host registration is available from the mobile root; concurrent cold calls share a preload attempt, and hosts must make readiness/preload reentrant and cache permanent failures or back off retries. Native and JavaScript results share byte-type and exact-length validation; unrelated hash-wasm errors still propagate without WebAssembly. React Native exports, wire types, and canonical AtomicBEEF behavior are unchanged; use @bsv/sdk 2.4.2 or later and retain THIRD_PARTY_NOTICES.md and LICENSES/. | -| `create-bsv-app` | `1.0.2` | `1.1.1` | minor | [API and usage](../packages/helpers/create-bsv-app.md) | Existing mainnet and testnet scaffolds are unchanged. New TTN projects pass --network ttn or select TerraTestNet in the configurator. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/ with the package. | +| Package | npm baseline | Source | Candidate | API | Migration | +| --------------------------------- | ------------ | -------- | --------- | --------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `@bsv/402-pay` | `0.2.1` | `0.2.5` | patch | [API and usage](../packages/middleware/402-pay.md) | No consumer migration is required; client and server exports, payment protocol behavior, and runtime defaults are unchanged. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/ with the package. | +| `@bsv/air-gap` | `0.0.0` | `0.1.2` | minor | [API and usage](../packages/helpers/air-gap.md) | No consumer migration is required; this is the first published release of a new package with no prior public API. The experimental pre-release framing that circulated on the unmerged feature branch is not accepted by the v1 decoder. | +| `@bsv/amountinator` | `2.1.1` | `2.1.5` | patch | [API and usage](../packages/helpers/amountinator.md) | No consumer migration is required; this is a backward-compatible patch candidate. | +| `@bsv/auth` | `0.1.1` | `0.1.4` | patch | [API and usage](../packages/middleware/auth.md) | No consumer migration is required; authentication APIs, wire behavior, and runtime defaults are unchanged. | +| `@bsv/auth-express-middleware` | `2.2.0` | `2.2.3` | patch | [API and usage](../packages/middleware/auth-express-middleware.md) | No API migration is required. Upgrade to @bsv/sdk 2.4.1 or later for the shared byte-boundary contract. Generic signed application-body canonicalization remains unchanged so old and new peers verify identical bytes. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/ with the package. | +| `@bsv/authsocket` | `2.1.1` | `2.1.7` | patch | [API and usage](../packages/messaging/authsocket.md) | No API migration is required. Existing event data, including numeric-key objects under byte-like names, is unchanged; typed payment protocols recover historical byte objects at their explicit fields. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/ with the package. | +| `@bsv/authsocket-client` | `2.1.1` | `2.1.6` | patch | [API and usage](../packages/messaging/authsocket-client.md) | No API migration is required. Existing event data, including numeric-key objects under byte-like names, is unchanged; typed payment protocols recover historical byte objects at their explicit fields. Distributors who copy the UMD file must keep THIRD_PARTY_NOTICES.md and LICENSES/ with it. | +| `@bsv/btms` | `1.1.1` | `1.2.2` | minor | [API and usage](../packages/wallet/btms.md) | Existing local, mainnet, testnet, and number-array behavior is unchanged. TTN consumers select networkPreset teratestnet; all consumers should upgrade to @bsv/sdk 2.4.1 or later for byte-boundary compatibility. | +| `@bsv/btms-permission-module` | `1.1.1` | `1.1.4` | patch | [API and usage](../packages/wallet/btms-permission-module.md) | No consumer migration is required; permission-module APIs and token semantics are unchanged. | +| `@bsv/chirp` | `0.0.0` | `0.1.1` | minor | [API and usage](../packages/network/chirp.md) | No consumer migration is required; this is the first release of a new additive package. Existing @bsv/sdk StorageUploader, StorageDownloader, StorageUtils, UHRP identifiers, overlays, and server routes remain unchanged. BRC-167 remains authoritative if the implementation and standard differ. | +| `@bsv/did` | `0.2.1` | `0.2.5` | patch | [API and usage](../packages/helpers/did.md) | No consumer migration is required; DID APIs, encodings, credential behavior, and supported import forms are unchanged. | +| `@bsv/did-client` | `1.2.1` | `1.3.1` | minor | [API and usage](../packages/helpers/did-client.md) | Existing local, mainnet, and testnet behavior is unchanged. TTN consumers select networkPreset teratestnet and use @bsv/sdk 2.4 or later. Distributors who copy the UMD file must keep THIRD_PARTY_NOTICES.md and LICENSES/ with it. | +| `@bsv/ecpm-permission-module` | `0.0.0` | `0.1.0` | minor | [API and usage](../packages/wallet/ecpm-permission-module.md) | No existing consumer migration is required; this is the first release. Wallet hosts register the module under the ecpm scheme and supply their ordinary key deriver, authorization handler, and optional privileged key provider. | +| `@bsv/fund-wallet` | `1.4.1` | `1.4.4` | patch | [API and usage](../packages/helpers/fund-wallet.md) | No consumer migration is required; wallet funding APIs and transaction behavior are unchanged. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/ with the package. | +| `@bsv/gasp` | `1.3.1` | `1.3.6` | patch | [API and usage](../packages/overlays/gasp.md) | No consumer migration is required; existing constructor calls, imports, synchronization behavior, and wire semantics are unchanged. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/ with the package. | +| `@bsv/lch` | `0.0.0` | `0.1.0` | minor | [API and usage](../packages/content/lch.md) | No consumer migration is required; this is the first release of a new additive package. Applications must give quote() the verified Asset encryption descriptor and selected Offer key-delivery mechanism, keep createPayment behind explicit wallet authorization, persist the funded transaction and every partial settlement proof through recovery, retry with that same transaction, distinguish finalized from accepted evidence, fail closed on unknown settlement or evidence profiles, and supply an address-pinning connector for DNS endpoints. Payees should select receipt-complete unless they explicitly accept authorized-output provider, privacy, and pre-internalization key-release tradeoffs. Distributors must retain THIRD_PARTY_NOTICES.md with the package; the new profile adds no dependency. Published BRC-170 remains authoritative if the implementation and standard differ. | +| `@bsv/message-box-client` | `2.4.0` | `2.5.0` | minor | [API and usage](../packages/messaging/message-box-client.md) | No API migration is required. socketOptions is optional and nothing is forwarded by default, so socket transport negotiation and every HTTP code path are unchanged for existing callers; wallet and originator stay client-owned and cannot be overridden through it. managerOptions excludes autoConnect and retries: JavaScript callers receive an immediate error for autoConnect: false or nonzero message retries because the live socket starts immediately and AuthSocket does not send Socket.IO acknowledgements. Connection reconnection options remain supported. Upgrade @bsv/sdk and @bsv/message-box-client together; historical number-array wallets, current Uint8Array substrates, and already-pending numeric-key messages interoperate through the same portable transaction form. Distributors who copy the UMD file must keep THIRD_PARTY_NOTICES.md and LICENSES/ with it. | +| `@bsv/overlay` | `2.2.1` | `2.3.1` | minor | [API and usage](../packages/overlays/overlay.md) | Existing Engine and TopicManager implementations remain valid. Lookup results default to 1,000 formulas; pass -1 only when an equivalent deployment bound exists. Topic managers whose validation creates provisional external state should implement abortAdmissibleOutputs, while read-only managers require no change. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/ with the package. | +| `@bsv/overlay-discovery-services` | `2.1.1` | `2.2.1` | minor | [API and usage](../packages/overlays/overlay-discovery-services.md) | Existing mainnet and testnet advertisers are unchanged. TTN operators pass chain ttn and provision the staging storage and overlay endpoints before advertising. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/ with the package. | +| `@bsv/overlay-express` | `2.5.0` | `2.6.1` | minor | [API and usage](../packages/overlays/overlay-express.md) | Existing mainnet and testnet servers are unchanged. TTN servers call configureNetwork('ttn'), configureArcade with the TTN endpoint, and configureChaintracks or configureChainTracker before engine initialization. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/ with the package. | +| `@bsv/overlay-topics` | `1.6.10` | `1.7.1` | minor | [API and usage](../packages/overlays/overlay-topics.md) | Existing topic and lookup identifiers remain unchanged. Production UMP overlays must give UMPTopicManager and the UMP lookup service Mongo-backed stores that use the same database, then roll out before updated wallet clients; the no-argument manager is bounded but intended only for isolated single-process use. The reservation and bootstrap-marker collections are additive and initialize from currently indexed UMP UTXOs; take a MongoDB backup before rollout. Legacy ambiguous rows remain visible and can be resolved with WAB pinning rather than deleted. | +| `@bsv/paymail` | `2.4.2` | `2.4.7` | patch | [API and usage](../packages/messaging/paymail.md) | Existing Paymail client APIs and protocol semantics are retained. Consumers provide one Express 4.18 or 5 runtime and matching type graph; browser bundles continue to exclude the server router implementation. Consumers of the former bundled Money Button or Tokenized specification documents must follow the authoritative links in docs/specs/README.md. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/ with the package. | +| `@bsv/payment-express-middleware` | `2.1.1` | `2.1.6` | patch | [API and usage](../packages/middleware/payment-express-middleware.md) | No consumer migration is required; legacy x-bsv-payment JSON behavior remains supported, and Express 4 and 5 applications use their own peer-provided Express installation. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/ with the package. | +| `@bsv/sdk` | `2.4.0` | `2.4.2` | patch | [API and usage](../packages/sdk/bsv-sdk.md) | No API migration is required. Historical number-array fast paths and React Native behavior remain compatible. Documentation users should load docs/swagger/swagger.yaml into their preferred viewer instead of using the removed static Swagger UI scaffold. Distributors must keep THIRD_PARTY_NOTICES.md and LICENSES/ with source and browser bundles. | +| `@bsv/simple` | `0.4.1` | `0.5.3` | minor | [API and usage](../packages/helpers/simple.md) | Existing overlay configurations and number-array behavior are unchanged. TTN consumers select network teratestnet; all consumers should upgrade to @bsv/sdk 2.4.2 or later. The Message Box Client dependency advance requires no helper API migration and preserves the default HTTP and live-socket behavior. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/. | +| `@bsv/templates` | `1.9.1` | `1.10.1` | minor | [API and usage](../packages/helpers/templates.md) | No existing consumer migration is required; existing template APIs and generated scripts are unchanged. New R1K1Wallet consumers await lock(), retain each private 32-byte salt, and provide a PIV signer that signs the supplied digest directly without hashing it again. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/ with the package. | +| `@bsv/teranode-listener` | `1.1.1` | `1.1.5` | patch | [API and usage](../packages/network/teranode-listener.md) | No consumer migration is required; listener APIs, topics, and network configuration are unchanged. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/ with the package. | +| `@bsv/verifast` | `0.3.0` | `0.3.5` | patch | [API and usage](../packages/sdk/verifast.md) | No consumer migration is required; exports, verification behavior, worker protocols, package paths, and runtime defaults are unchanged. Keep THIRD_PARTY_NOTICES.md and LICENSES/ with every JavaScript and WebAssembly distribution. | +| `@bsv/wallet-helper` | `0.1.1` | `0.1.7` | patch | [API and usage](../packages/helpers/wallet-helper.md) | No consumer migration is required; fluent builder APIs and transaction semantics are unchanged. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/ with the package. | +| `@bsv/wallet-relay` | `0.2.2` | `0.3.6` | minor | [API and usage](../packages/wallet/wallet-relay.md) | No wallet RPC migration is required; upgrade to @bsv/sdk 2.4.1 or later. Existing relay sessions and number arrays remain valid, and host applications continue to provide their matching Express runtime and type graph. | +| `@bsv/wallet-toolbox` | `2.10.4` | `2.11.1` | minor | [API and usage](../packages/wallet/wallet-toolbox.md) | Existing actions, ordinary noSend calls, permission modules, and UMP v3 tokens require no migration. To use BRC-177, migrate every active Knex store before serving requests and run the default Wallet Toolbox monitor; IndexedDB upgrades automatically to schema version 5. Upgrade signer, active storage service, and remote monitor together to 2.11.0 or later; older remote storage is rejected before prefunding. The Knex migration also adds rebuildable prepared-BEEF and proof-epoch tables with every COOK control disabled. Validate the migration on MySQL before release and the cross-process epoch fence on non-production PXC before enabling writes. Roll out writes before reads, use backfill only after database review, and disable all three flags to roll back. Delete derived prepared rows before downgrading to code that cannot advance the epoch. Semantic modules may add handleRequest; hosts installing @bsv/ecpm-permission-module register it under the ecpm scheme. Argon2id tokens keep the same parameters and derived bytes across WebAssembly and JavaScript runtimes. Host registration is available from each package root; concurrent cold calls share a preload attempt, and hosts must make readiness/preload reentrant and cache permanent failures or back off retries. Native and JavaScript results share byte-type and exact-length validation; unrelated hash-wasm errors still propagate without WebAssembly. Canonical AtomicBEEF and number-array behavior are unchanged; use @bsv/sdk 2.4.2 or later, use docs/storage.md instead of the removed JSight export, and retain THIRD_PARTY_NOTICES.md and LICENSES/. Identity discovery requires the wallet Services ChainTracker to provide canonical chain evidence. No wallet RPC or storage migration is required for this security correction. Direct identityUtils callers must pass their independently maintained ChainTracker; missing verification context and unverifiable overlay candidates no longer produce identities. Local contacts retain separate provenance; inclusion is not proof of freshness or unspentness. | +| `@bsv/wallet-toolbox-client` | `2.10.4` | `2.11.1` | minor | [API and usage](../packages/wallet/wallet-toolbox-client.md) | Existing browser actions, permission modules, and UMP v3 tokens require no migration; IndexedDB upgrades automatically. To use BRC-177 with remote storage, upgrade the active storage service and its default monitor to Wallet Toolbox 2.11.0 or later before upgrading clients; an older server is rejected before prefunding. Prepared BEEF persistence and rollout controls apply only to the full package's Knex provider, so IndexedDB and remote clients require no COOK configuration. Semantic modules may add handleRequest; installing @bsv/ecpm-permission-module requires registration under the ecpm scheme. Argon2id tokens keep the same parameters and derived bytes across WebAssembly and JavaScript runtimes. Host registration is available from each package root; concurrent cold calls share a preload attempt, and hosts must make readiness/preload reentrant and cache permanent failures or back off retries. Native and JavaScript results share byte-type and exact-length validation; unrelated hash-wasm errors still propagate without WebAssembly. Browser exports, wire types, canonical AtomicBEEF behavior, and pagination contracts are unchanged; use @bsv/sdk 2.4.2 or later and retain THIRD_PARTY_NOTICES.md and LICENSES/. Identity discovery requires the wallet Services ChainTracker to provide canonical chain evidence. No wallet RPC or storage migration is required for this security correction. Direct identityUtils callers must pass their independently maintained ChainTracker; missing verification context and unverifiable overlay candidates no longer produce identities. Local contacts retain separate provenance; inclusion is not proof of freshness or unspentness. | +| `@bsv/wallet-toolbox-mobile` | `2.10.4` | `2.11.1` | minor | [API and usage](../packages/wallet/wallet-toolbox-mobile.md) | Existing mobile actions, permission modules, and UMP v3 tokens require no migration. To use BRC-177, migrate and upgrade the active remote storage service and its default monitor to Wallet Toolbox 2.11.0 or later before upgrading clients; an older server is rejected before prefunding. Prepared BEEF persistence and rollout controls apply only to the full package's Knex provider, so mobile remote clients require no COOK configuration. Semantic modules may add handleRequest without changing the Wallet interface. Argon2id tokens keep the same parameters and derived bytes across WebAssembly and JavaScript runtimes; no user device setting is required. Host registration is available from the mobile root; concurrent cold calls share a preload attempt, and hosts must make readiness/preload reentrant and cache permanent failures or back off retries. Native and JavaScript results share byte-type and exact-length validation; unrelated hash-wasm errors still propagate without WebAssembly. React Native exports, wire types, and canonical AtomicBEEF behavior are unchanged; use @bsv/sdk 2.4.2 or later and retain THIRD_PARTY_NOTICES.md and LICENSES/. Identity discovery requires the wallet Services ChainTracker to provide canonical chain evidence. No wallet RPC or storage migration is required for this security correction. Direct identityUtils callers must pass their independently maintained ChainTracker; missing verification context and unverifiable overlay candidates no longer produce identities. Local contacts retain separate provenance; inclusion is not proof of freshness or unspentness. | +| `create-bsv-app` | `1.0.2` | `1.1.1` | minor | [API and usage](../packages/helpers/create-bsv-app.md) | Existing mainnet and testnet scaffolds are unchanged. New TTN projects pass --network ttn or select TerraTestNet in the configurator. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/ with the package. | `none` means the source manifest matches the recorded npm baseline. Any other value is an unpublished candidate. Publication, tags, releases, registry @@ -520,8 +520,8 @@ CLI entry points: `{"wallet-relay":"./bin/init.mjs"}`. - Package documentation: [docs/packages/wallet/wallet-toolbox.md](../packages/wallet/wallet-toolbox.md) - Source: [packages/wallet/wallet-toolbox](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/wallet/wallet-toolbox) -- Release note: Adds the built-in BRC-177 noSend-expiry reference implementation with exact prefunding, durable pre-signed reclaim, atomic active-storage monitoring, backoff-controlled recovery, cross-device lifecycle synchronization, and proof-finalized race handling. Adds opt-in prepared BEEF storage for Knex-backed normal createAction funding: verified, checksummed proof closures are persisted after foreground completion and reused on later hits, while broad lookups, misses, and cache failures retain the canonical path. Reads, writes, bounded queueing, and gradual backfill default off; reorganizations stale derived rows and fence in-flight cross-process writes with a database proof epoch. Also adds the optional semantic handleRequest hook for BRC-98/99/111 permission modules, an interoperable asynchronous JavaScript Argon2id fallback when WebAssembly is unavailable, and an optional proven-ready native Argon2id backend for host runtimes, retains BRC-95/BRC-100 compatibility and stable bounded pagination, removes the obsolete JSight application bundle, and preserves the earlier Open BSV grant. -- Migration: Existing actions, ordinary noSend calls, permission modules, and UMP v3 tokens require no migration. To use BRC-177, migrate every active Knex store before serving requests and run the default Wallet Toolbox monitor; IndexedDB upgrades automatically to schema version 5. Upgrade signer, active storage service, and remote monitor together to 2.11.0 or later; older remote storage is rejected before prefunding. The Knex migration also adds rebuildable prepared-BEEF and proof-epoch tables with every COOK control disabled. Validate the migration on MySQL before release and the cross-process epoch fence on non-production PXC before enabling writes. Roll out writes before reads, use backfill only after database review, and disable all three flags to roll back. Delete derived prepared rows before downgrading to code that cannot advance the epoch. Semantic modules may add handleRequest; hosts installing @bsv/ecpm-permission-module register it under the ecpm scheme. Argon2id tokens keep the same parameters and derived bytes across WebAssembly and JavaScript runtimes. Host registration is available from each package root; concurrent cold calls share a preload attempt, and hosts must make readiness/preload reentrant and cache permanent failures or back off retries. Native and JavaScript results share byte-type and exact-length validation; unrelated hash-wasm errors still propagate without WebAssembly. Canonical AtomicBEEF and number-array behavior are unchanged; use @bsv/sdk 2.4.2 or later, use docs/storage.md instead of the removed JSight export, and retain THIRD_PARTY_NOTICES.md and LICENSES/. +- Release note: Adds the built-in BRC-177 noSend-expiry reference implementation with exact prefunding, durable pre-signed reclaim, atomic active-storage monitoring, backoff-controlled recovery, cross-device lifecycle synchronization, and proof-finalized race handling. Adds opt-in prepared BEEF storage for Knex-backed normal createAction funding: verified, checksummed proof closures are persisted after foreground completion and reused on later hits, while broad lookups, misses, and cache failures retain the canonical path. Reads, writes, bounded queueing, and gradual backfill default off; reorganizations stale derived rows and fence in-flight cross-process writes with a database proof epoch. Also adds the optional semantic handleRequest hook for BRC-98/99/111 permission modules, an interoperable asynchronous JavaScript Argon2id fallback when WebAssembly is unavailable, and an optional proven-ready native Argon2id backend for host runtimes, retains BRC-95/BRC-100 compatibility and stable bounded pagination, removes the obsolete JSight application bundle, and preserves the earlier Open BSV grant. Secures final overlay identity discovery with independently verified, byte-bound transaction evidence, subject-signed output fields, and explicit certificate success; cached response evidence is revalidated before use. +- Migration: Existing actions, ordinary noSend calls, permission modules, and UMP v3 tokens require no migration. To use BRC-177, migrate every active Knex store before serving requests and run the default Wallet Toolbox monitor; IndexedDB upgrades automatically to schema version 5. Upgrade signer, active storage service, and remote monitor together to 2.11.0 or later; older remote storage is rejected before prefunding. The Knex migration also adds rebuildable prepared-BEEF and proof-epoch tables with every COOK control disabled. Validate the migration on MySQL before release and the cross-process epoch fence on non-production PXC before enabling writes. Roll out writes before reads, use backfill only after database review, and disable all three flags to roll back. Delete derived prepared rows before downgrading to code that cannot advance the epoch. Semantic modules may add handleRequest; hosts installing @bsv/ecpm-permission-module register it under the ecpm scheme. Argon2id tokens keep the same parameters and derived bytes across WebAssembly and JavaScript runtimes. Host registration is available from each package root; concurrent cold calls share a preload attempt, and hosts must make readiness/preload reentrant and cache permanent failures or back off retries. Native and JavaScript results share byte-type and exact-length validation; unrelated hash-wasm errors still propagate without WebAssembly. Canonical AtomicBEEF and number-array behavior are unchanged; use @bsv/sdk 2.4.2 or later, use docs/storage.md instead of the removed JSight export, and retain THIRD_PARTY_NOTICES.md and LICENSES/. Identity discovery requires the wallet Services ChainTracker to provide canonical chain evidence. No wallet RPC or storage migration is required for this security correction. Direct identityUtils callers must pass their independently maintained ChainTracker; missing verification context and unverifiable overlay candidates no longer produce identities. Local contacts retain separate provenance; inclusion is not proof of freshness or unspentness. | Public subpath | Runtime target(s) | Declaration target(s) | | ---------------- | ---------------------------------------------------- | -------------------------- | @@ -534,8 +534,8 @@ CLI entry points: `{"wallet-relay":"./bin/init.mjs"}`. - Package documentation: [docs/packages/wallet/wallet-toolbox-client.md](../packages/wallet/wallet-toolbox-client.md) - Source: [packages/wallet/wallet-toolbox/client](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/wallet/wallet-toolbox/client) -- Release note: Adds the built-in browser BRC-177 noSend-expiry signer, IndexedDB schema version 5 lifecycle state, remote storage capability negotiation, default monitor coordination, the optional semantic handleRequest hook, an interoperable asynchronous JavaScript Argon2id fallback when WebAssembly is unavailable, and an optional proven-ready native Argon2id backend for host runtimes. Carries the shared prepared-BEEF-aware createAction contract while IndexedDB remains canonical-only; compatible remote Knex providers can enable COOK independently. Retains transformation modules, BRC-100 wire compatibility, stable IndexedDB totals, current browser compatibility fixes, and earlier Open BSV grants. -- Migration: Existing browser actions, permission modules, and UMP v3 tokens require no migration; IndexedDB upgrades automatically. To use BRC-177 with remote storage, upgrade the active storage service and its default monitor to Wallet Toolbox 2.11.0 or later before upgrading clients; an older server is rejected before prefunding. Prepared BEEF persistence and rollout controls apply only to the full package's Knex provider, so IndexedDB and remote clients require no COOK configuration. Semantic modules may add handleRequest; installing @bsv/ecpm-permission-module requires registration under the ecpm scheme. Argon2id tokens keep the same parameters and derived bytes across WebAssembly and JavaScript runtimes. Host registration is available from each package root; concurrent cold calls share a preload attempt, and hosts must make readiness/preload reentrant and cache permanent failures or back off retries. Native and JavaScript results share byte-type and exact-length validation; unrelated hash-wasm errors still propagate without WebAssembly. Browser exports, wire types, canonical AtomicBEEF behavior, and pagination contracts are unchanged; use @bsv/sdk 2.4.2 or later and retain THIRD_PARTY_NOTICES.md and LICENSES/. +- Release note: Adds the built-in browser BRC-177 noSend-expiry signer, IndexedDB schema version 5 lifecycle state, remote storage capability negotiation, default monitor coordination, the optional semantic handleRequest hook, an interoperable asynchronous JavaScript Argon2id fallback when WebAssembly is unavailable, and an optional proven-ready native Argon2id backend for host runtimes. Carries the shared prepared-BEEF-aware createAction contract while IndexedDB remains canonical-only; compatible remote Knex providers can enable COOK independently. Retains transformation modules, BRC-100 wire compatibility, stable IndexedDB totals, current browser compatibility fixes, and earlier Open BSV grants. Secures final overlay identity discovery with independently verified, byte-bound transaction evidence, subject-signed output fields, and explicit certificate success; cached response evidence is revalidated before use. +- Migration: Existing browser actions, permission modules, and UMP v3 tokens require no migration; IndexedDB upgrades automatically. To use BRC-177 with remote storage, upgrade the active storage service and its default monitor to Wallet Toolbox 2.11.0 or later before upgrading clients; an older server is rejected before prefunding. Prepared BEEF persistence and rollout controls apply only to the full package's Knex provider, so IndexedDB and remote clients require no COOK configuration. Semantic modules may add handleRequest; installing @bsv/ecpm-permission-module requires registration under the ecpm scheme. Argon2id tokens keep the same parameters and derived bytes across WebAssembly and JavaScript runtimes. Host registration is available from each package root; concurrent cold calls share a preload attempt, and hosts must make readiness/preload reentrant and cache permanent failures or back off retries. Native and JavaScript results share byte-type and exact-length validation; unrelated hash-wasm errors still propagate without WebAssembly. Browser exports, wire types, canonical AtomicBEEF behavior, and pagination contracts are unchanged; use @bsv/sdk 2.4.2 or later and retain THIRD_PARTY_NOTICES.md and LICENSES/. Identity discovery requires the wallet Services ChainTracker to provide canonical chain evidence. No wallet RPC or storage migration is required for this security correction. Direct identityUtils callers must pass their independently maintained ChainTracker; missing verification context and unverifiable overlay candidates no longer produce identities. Local contacts retain separate provenance; inclusion is not proof of freshness or unspentness. | Public subpath | Runtime target(s) | Declaration target(s) | | ---------------- | -------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------- | @@ -546,8 +546,8 @@ CLI entry points: `{"wallet-relay":"./bin/init.mjs"}`. - Package documentation: [docs/packages/wallet/wallet-toolbox-mobile.md](../packages/wallet/wallet-toolbox-mobile.md) - Source: [packages/wallet/wallet-toolbox/mobile](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/wallet/wallet-toolbox/mobile) -- Release note: Adds the built-in mobile BRC-177 noSend-expiry signer, remote storage capability negotiation, default-monitor ownership coordination across restarts and devices, the optional semantic handleRequest hook, an interoperable asynchronous JavaScript Argon2id fallback for React Native runtimes without WebAssembly, and an optional proven-ready native Argon2id backend for host runtimes. Carries the shared prepared-BEEF-aware createAction contract while mobile storage remains canonical-only; compatible remote Knex providers can enable COOK independently. Retains transformation modules, BRC-100 wire compatibility, current mobile compatibility fixes, and earlier Open BSV grants. -- Migration: Existing mobile actions, permission modules, and UMP v3 tokens require no migration. To use BRC-177, migrate and upgrade the active remote storage service and its default monitor to Wallet Toolbox 2.11.0 or later before upgrading clients; an older server is rejected before prefunding. Prepared BEEF persistence and rollout controls apply only to the full package's Knex provider, so mobile remote clients require no COOK configuration. Semantic modules may add handleRequest without changing the Wallet interface. Argon2id tokens keep the same parameters and derived bytes across WebAssembly and JavaScript runtimes; no user device setting is required. Host registration is available from the mobile root; concurrent cold calls share a preload attempt, and hosts must make readiness/preload reentrant and cache permanent failures or back off retries. Native and JavaScript results share byte-type and exact-length validation; unrelated hash-wasm errors still propagate without WebAssembly. React Native exports, wire types, and canonical AtomicBEEF behavior are unchanged; use @bsv/sdk 2.4.2 or later and retain THIRD_PARTY_NOTICES.md and LICENSES/. +- Release note: Adds the built-in mobile BRC-177 noSend-expiry signer, remote storage capability negotiation, default-monitor ownership coordination across restarts and devices, the optional semantic handleRequest hook, an interoperable asynchronous JavaScript Argon2id fallback for React Native runtimes without WebAssembly, and an optional proven-ready native Argon2id backend for host runtimes. Carries the shared prepared-BEEF-aware createAction contract while mobile storage remains canonical-only; compatible remote Knex providers can enable COOK independently. Retains transformation modules, BRC-100 wire compatibility, current mobile compatibility fixes, and earlier Open BSV grants. Secures final overlay identity discovery with independently verified, byte-bound transaction evidence, subject-signed output fields, and explicit certificate success; cached response evidence is revalidated before use. +- Migration: Existing mobile actions, permission modules, and UMP v3 tokens require no migration. To use BRC-177, migrate and upgrade the active remote storage service and its default monitor to Wallet Toolbox 2.11.0 or later before upgrading clients; an older server is rejected before prefunding. Prepared BEEF persistence and rollout controls apply only to the full package's Knex provider, so mobile remote clients require no COOK configuration. Semantic modules may add handleRequest without changing the Wallet interface. Argon2id tokens keep the same parameters and derived bytes across WebAssembly and JavaScript runtimes; no user device setting is required. Host registration is available from the mobile root; concurrent cold calls share a preload attempt, and hosts must make readiness/preload reentrant and cache permanent failures or back off retries. Native and JavaScript results share byte-type and exact-length validation; unrelated hash-wasm errors still propagate without WebAssembly. React Native exports, wire types, and canonical AtomicBEEF behavior are unchanged; use @bsv/sdk 2.4.2 or later and retain THIRD_PARTY_NOTICES.md and LICENSES/. Identity discovery requires the wallet Services ChainTracker to provide canonical chain evidence. No wallet RPC or storage migration is required for this security correction. Direct identityUtils callers must pass their independently maintained ChainTracker; missing verification context and unverifiable overlay candidates no longer produce identities. Local contacts retain separate provenance; inclusion is not proof of freshness or unspentness. | Public subpath | Runtime target(s) | Declaration target(s) | | ---------------- | -------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------- | diff --git a/docs/reference/stack-facts.md b/docs/reference/stack-facts.md index 13c442ae0..45b71107d 100644 --- a/docs/reference/stack-facts.md +++ b/docs/reference/stack-facts.md @@ -68,9 +68,9 @@ authorized release action. | wallet | `@bsv/btms-permission-module` | `1.1.4` | node-library | node-esm | node | `>=22` | [packages/wallet/btms-permission-module](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/wallet/btms-permission-module) | | wallet | `@bsv/ecpm-permission-module` | `0.1.0` | browser-library | browser-bundler, browser-esm, node-esm | browser, node | `>=22` | [packages/wallet/ecpm-permission-module](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/wallet/ecpm-permission-module) | | wallet | `@bsv/wallet-relay` | `0.3.6` | cli-library | browser-bundler, browser-esm, cli, node-cjs, node-esm | browser, node | `>=22` | [packages/wallet/ts-wallet-relay](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/wallet/ts-wallet-relay) | -| wallet | `@bsv/wallet-toolbox` | `2.11.0` | node-library | node-cjs | node | `>=22` | [packages/wallet/wallet-toolbox](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/wallet/wallet-toolbox) | -| wallet | `@bsv/wallet-toolbox-client` | `2.11.0` | browser-library | browser-bundler, browser-esm, node-cjs, node-esm | browser, node | `>=22` | [packages/wallet/wallet-toolbox/client](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/wallet/wallet-toolbox/client) | -| wallet | `@bsv/wallet-toolbox-mobile` | `2.11.0` | react-native-library | react-native-metro | react-native | `>=22` | [packages/wallet/wallet-toolbox/mobile](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/wallet/wallet-toolbox/mobile) | +| wallet | `@bsv/wallet-toolbox` | `2.11.1` | node-library | node-cjs | node | `>=22` | [packages/wallet/wallet-toolbox](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/wallet/wallet-toolbox) | +| wallet | `@bsv/wallet-toolbox-client` | `2.11.1` | browser-library | browser-bundler, browser-esm, node-cjs, node-esm | browser, node | `>=22` | [packages/wallet/wallet-toolbox/client](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/wallet/wallet-toolbox/client) | +| wallet | `@bsv/wallet-toolbox-mobile` | `2.11.1` | react-native-library | react-native-metro | react-native | `>=22` | [packages/wallet/wallet-toolbox/mobile](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/wallet/wallet-toolbox/mobile) | ## Standalone infrastructure manifests diff --git a/governance/package-release-notes.json b/governance/package-release-notes.json index fb190dd4d..eccf304bb 100644 --- a/governance/package-release-notes.json +++ b/governance/package-release-notes.json @@ -217,22 +217,22 @@ "name": "@bsv/wallet-toolbox", "publishedVersion": "2.10.4", "releaseType": "minor", - "summary": "Adds the built-in BRC-177 noSend-expiry reference implementation with exact prefunding, durable pre-signed reclaim, atomic active-storage monitoring, backoff-controlled recovery, cross-device lifecycle synchronization, and proof-finalized race handling. Adds opt-in prepared BEEF storage for Knex-backed normal createAction funding: verified, checksummed proof closures are persisted after foreground completion and reused on later hits, while broad lookups, misses, and cache failures retain the canonical path. Reads, writes, bounded queueing, and gradual backfill default off; reorganizations stale derived rows and fence in-flight cross-process writes with a database proof epoch. Also adds the optional semantic handleRequest hook for BRC-98/99/111 permission modules, an interoperable asynchronous JavaScript Argon2id fallback when WebAssembly is unavailable, and an optional proven-ready native Argon2id backend for host runtimes, retains BRC-95/BRC-100 compatibility and stable bounded pagination, removes the obsolete JSight application bundle, and preserves the earlier Open BSV grant.", - "migration": "Existing actions, ordinary noSend calls, permission modules, and UMP v3 tokens require no migration. To use BRC-177, migrate every active Knex store before serving requests and run the default Wallet Toolbox monitor; IndexedDB upgrades automatically to schema version 5. Upgrade signer, active storage service, and remote monitor together to 2.11.0 or later; older remote storage is rejected before prefunding. The Knex migration also adds rebuildable prepared-BEEF and proof-epoch tables with every COOK control disabled. Validate the migration on MySQL before release and the cross-process epoch fence on non-production PXC before enabling writes. Roll out writes before reads, use backfill only after database review, and disable all three flags to roll back. Delete derived prepared rows before downgrading to code that cannot advance the epoch. Semantic modules may add handleRequest; hosts installing @bsv/ecpm-permission-module register it under the ecpm scheme. Argon2id tokens keep the same parameters and derived bytes across WebAssembly and JavaScript runtimes. Host registration is available from each package root; concurrent cold calls share a preload attempt, and hosts must make readiness/preload reentrant and cache permanent failures or back off retries. Native and JavaScript results share byte-type and exact-length validation; unrelated hash-wasm errors still propagate without WebAssembly. Canonical AtomicBEEF and number-array behavior are unchanged; use @bsv/sdk 2.4.2 or later, use docs/storage.md instead of the removed JSight export, and retain THIRD_PARTY_NOTICES.md and LICENSES/." + "summary": "Adds the built-in BRC-177 noSend-expiry reference implementation with exact prefunding, durable pre-signed reclaim, atomic active-storage monitoring, backoff-controlled recovery, cross-device lifecycle synchronization, and proof-finalized race handling. Adds opt-in prepared BEEF storage for Knex-backed normal createAction funding: verified, checksummed proof closures are persisted after foreground completion and reused on later hits, while broad lookups, misses, and cache failures retain the canonical path. Reads, writes, bounded queueing, and gradual backfill default off; reorganizations stale derived rows and fence in-flight cross-process writes with a database proof epoch. Also adds the optional semantic handleRequest hook for BRC-98/99/111 permission modules, an interoperable asynchronous JavaScript Argon2id fallback when WebAssembly is unavailable, and an optional proven-ready native Argon2id backend for host runtimes, retains BRC-95/BRC-100 compatibility and stable bounded pagination, removes the obsolete JSight application bundle, and preserves the earlier Open BSV grant. Secures final overlay identity discovery with independently verified, byte-bound transaction evidence, subject-signed output fields, and explicit certificate success; cached response evidence is revalidated before use.", + "migration": "Existing actions, ordinary noSend calls, permission modules, and UMP v3 tokens require no migration. To use BRC-177, migrate every active Knex store before serving requests and run the default Wallet Toolbox monitor; IndexedDB upgrades automatically to schema version 5. Upgrade signer, active storage service, and remote monitor together to 2.11.0 or later; older remote storage is rejected before prefunding. The Knex migration also adds rebuildable prepared-BEEF and proof-epoch tables with every COOK control disabled. Validate the migration on MySQL before release and the cross-process epoch fence on non-production PXC before enabling writes. Roll out writes before reads, use backfill only after database review, and disable all three flags to roll back. Delete derived prepared rows before downgrading to code that cannot advance the epoch. Semantic modules may add handleRequest; hosts installing @bsv/ecpm-permission-module register it under the ecpm scheme. Argon2id tokens keep the same parameters and derived bytes across WebAssembly and JavaScript runtimes. Host registration is available from each package root; concurrent cold calls share a preload attempt, and hosts must make readiness/preload reentrant and cache permanent failures or back off retries. Native and JavaScript results share byte-type and exact-length validation; unrelated hash-wasm errors still propagate without WebAssembly. Canonical AtomicBEEF and number-array behavior are unchanged; use @bsv/sdk 2.4.2 or later, use docs/storage.md instead of the removed JSight export, and retain THIRD_PARTY_NOTICES.md and LICENSES/. Identity discovery requires the wallet Services ChainTracker to provide canonical chain evidence. No wallet RPC or storage migration is required for this security correction. Direct identityUtils callers must pass their independently maintained ChainTracker; missing verification context and unverifiable overlay candidates no longer produce identities. Local contacts retain separate provenance; inclusion is not proof of freshness or unspentness." }, { "name": "@bsv/wallet-toolbox-client", "publishedVersion": "2.10.4", "releaseType": "minor", - "summary": "Adds the built-in browser BRC-177 noSend-expiry signer, IndexedDB schema version 5 lifecycle state, remote storage capability negotiation, default monitor coordination, the optional semantic handleRequest hook, an interoperable asynchronous JavaScript Argon2id fallback when WebAssembly is unavailable, and an optional proven-ready native Argon2id backend for host runtimes. Carries the shared prepared-BEEF-aware createAction contract while IndexedDB remains canonical-only; compatible remote Knex providers can enable COOK independently. Retains transformation modules, BRC-100 wire compatibility, stable IndexedDB totals, current browser compatibility fixes, and earlier Open BSV grants.", - "migration": "Existing browser actions, permission modules, and UMP v3 tokens require no migration; IndexedDB upgrades automatically. To use BRC-177 with remote storage, upgrade the active storage service and its default monitor to Wallet Toolbox 2.11.0 or later before upgrading clients; an older server is rejected before prefunding. Prepared BEEF persistence and rollout controls apply only to the full package's Knex provider, so IndexedDB and remote clients require no COOK configuration. Semantic modules may add handleRequest; installing @bsv/ecpm-permission-module requires registration under the ecpm scheme. Argon2id tokens keep the same parameters and derived bytes across WebAssembly and JavaScript runtimes. Host registration is available from each package root; concurrent cold calls share a preload attempt, and hosts must make readiness/preload reentrant and cache permanent failures or back off retries. Native and JavaScript results share byte-type and exact-length validation; unrelated hash-wasm errors still propagate without WebAssembly. Browser exports, wire types, canonical AtomicBEEF behavior, and pagination contracts are unchanged; use @bsv/sdk 2.4.2 or later and retain THIRD_PARTY_NOTICES.md and LICENSES/." + "summary": "Adds the built-in browser BRC-177 noSend-expiry signer, IndexedDB schema version 5 lifecycle state, remote storage capability negotiation, default monitor coordination, the optional semantic handleRequest hook, an interoperable asynchronous JavaScript Argon2id fallback when WebAssembly is unavailable, and an optional proven-ready native Argon2id backend for host runtimes. Carries the shared prepared-BEEF-aware createAction contract while IndexedDB remains canonical-only; compatible remote Knex providers can enable COOK independently. Retains transformation modules, BRC-100 wire compatibility, stable IndexedDB totals, current browser compatibility fixes, and earlier Open BSV grants. Secures final overlay identity discovery with independently verified, byte-bound transaction evidence, subject-signed output fields, and explicit certificate success; cached response evidence is revalidated before use.", + "migration": "Existing browser actions, permission modules, and UMP v3 tokens require no migration; IndexedDB upgrades automatically. To use BRC-177 with remote storage, upgrade the active storage service and its default monitor to Wallet Toolbox 2.11.0 or later before upgrading clients; an older server is rejected before prefunding. Prepared BEEF persistence and rollout controls apply only to the full package's Knex provider, so IndexedDB and remote clients require no COOK configuration. Semantic modules may add handleRequest; installing @bsv/ecpm-permission-module requires registration under the ecpm scheme. Argon2id tokens keep the same parameters and derived bytes across WebAssembly and JavaScript runtimes. Host registration is available from each package root; concurrent cold calls share a preload attempt, and hosts must make readiness/preload reentrant and cache permanent failures or back off retries. Native and JavaScript results share byte-type and exact-length validation; unrelated hash-wasm errors still propagate without WebAssembly. Browser exports, wire types, canonical AtomicBEEF behavior, and pagination contracts are unchanged; use @bsv/sdk 2.4.2 or later and retain THIRD_PARTY_NOTICES.md and LICENSES/. Identity discovery requires the wallet Services ChainTracker to provide canonical chain evidence. No wallet RPC or storage migration is required for this security correction. Direct identityUtils callers must pass their independently maintained ChainTracker; missing verification context and unverifiable overlay candidates no longer produce identities. Local contacts retain separate provenance; inclusion is not proof of freshness or unspentness." }, { "name": "@bsv/wallet-toolbox-mobile", "publishedVersion": "2.10.4", "releaseType": "minor", - "summary": "Adds the built-in mobile BRC-177 noSend-expiry signer, remote storage capability negotiation, default-monitor ownership coordination across restarts and devices, the optional semantic handleRequest hook, an interoperable asynchronous JavaScript Argon2id fallback for React Native runtimes without WebAssembly, and an optional proven-ready native Argon2id backend for host runtimes. Carries the shared prepared-BEEF-aware createAction contract while mobile storage remains canonical-only; compatible remote Knex providers can enable COOK independently. Retains transformation modules, BRC-100 wire compatibility, current mobile compatibility fixes, and earlier Open BSV grants.", - "migration": "Existing mobile actions, permission modules, and UMP v3 tokens require no migration. To use BRC-177, migrate and upgrade the active remote storage service and its default monitor to Wallet Toolbox 2.11.0 or later before upgrading clients; an older server is rejected before prefunding. Prepared BEEF persistence and rollout controls apply only to the full package's Knex provider, so mobile remote clients require no COOK configuration. Semantic modules may add handleRequest without changing the Wallet interface. Argon2id tokens keep the same parameters and derived bytes across WebAssembly and JavaScript runtimes; no user device setting is required. Host registration is available from the mobile root; concurrent cold calls share a preload attempt, and hosts must make readiness/preload reentrant and cache permanent failures or back off retries. Native and JavaScript results share byte-type and exact-length validation; unrelated hash-wasm errors still propagate without WebAssembly. React Native exports, wire types, and canonical AtomicBEEF behavior are unchanged; use @bsv/sdk 2.4.2 or later and retain THIRD_PARTY_NOTICES.md and LICENSES/." + "summary": "Adds the built-in mobile BRC-177 noSend-expiry signer, remote storage capability negotiation, default-monitor ownership coordination across restarts and devices, the optional semantic handleRequest hook, an interoperable asynchronous JavaScript Argon2id fallback for React Native runtimes without WebAssembly, and an optional proven-ready native Argon2id backend for host runtimes. Carries the shared prepared-BEEF-aware createAction contract while mobile storage remains canonical-only; compatible remote Knex providers can enable COOK independently. Retains transformation modules, BRC-100 wire compatibility, current mobile compatibility fixes, and earlier Open BSV grants. Secures final overlay identity discovery with independently verified, byte-bound transaction evidence, subject-signed output fields, and explicit certificate success; cached response evidence is revalidated before use.", + "migration": "Existing mobile actions, permission modules, and UMP v3 tokens require no migration. To use BRC-177, migrate and upgrade the active remote storage service and its default monitor to Wallet Toolbox 2.11.0 or later before upgrading clients; an older server is rejected before prefunding. Prepared BEEF persistence and rollout controls apply only to the full package's Knex provider, so mobile remote clients require no COOK configuration. Semantic modules may add handleRequest without changing the Wallet interface. Argon2id tokens keep the same parameters and derived bytes across WebAssembly and JavaScript runtimes; no user device setting is required. Host registration is available from the mobile root; concurrent cold calls share a preload attempt, and hosts must make readiness/preload reentrant and cache permanent failures or back off retries. Native and JavaScript results share byte-type and exact-length validation; unrelated hash-wasm errors still propagate without WebAssembly. React Native exports, wire types, and canonical AtomicBEEF behavior are unchanged; use @bsv/sdk 2.4.2 or later and retain THIRD_PARTY_NOTICES.md and LICENSES/. Identity discovery requires the wallet Services ChainTracker to provide canonical chain evidence. No wallet RPC or storage migration is required for this security correction. Direct identityUtils callers must pass their independently maintained ChainTracker; missing verification context and unverifiable overlay candidates no longer produce identities. Local contacts retain separate provenance; inclusion is not proof of freshness or unspentness." }, { "name": "create-bsv-app", diff --git a/governance/repository-health/baselines.json b/governance/repository-health/baselines.json index aad0934bd..c75e4504c 100644 --- a/governance/repository-health/baselines.json +++ b/governance/repository-health/baselines.json @@ -328,8 +328,8 @@ "@bsv/btms-permission-module": "1.1.4", "@bsv/ecpm-permission-module": "0.1.0", "@bsv/wallet-relay": "0.3.6", - "@bsv/wallet-toolbox-client": "2.11.0", - "@bsv/wallet-toolbox-mobile": "2.11.0", - "@bsv/wallet-toolbox": "2.11.0" + "@bsv/wallet-toolbox-client": "2.11.1", + "@bsv/wallet-toolbox-mobile": "2.11.1", + "@bsv/wallet-toolbox": "2.11.1" } } diff --git a/packages/wallet/wallet-toolbox/CHANGELOG.md b/packages/wallet/wallet-toolbox/CHANGELOG.md index 15c1b6e53..a2ff7a339 100644 --- a/packages/wallet/wallet-toolbox/CHANGELOG.md +++ b/packages/wallet/wallet-toolbox/CHANGELOG.md @@ -6,6 +6,17 @@ attention to changes that materially alter behavior or extend functionality. ## wallet-toolbox (unreleased) +- Secure final overlay identity discovery with independently verified transaction + evidence, byte-derived txid/hint checks, output bounds, the subject-signed + identity envelope, and explicit certificate success. Cached overlay responses + retain evidence only and repeat verification before returning identities. + Configure the existing wallet Services ChainTracker; direct `identityUtils` + callers must pass it explicitly. Local contacts, permission handling, wallet + RPC shapes, and current pagination behavior are retained. This security fix is + a patch in the full, browser, and mobile packages (2.11.1). No storage migration + is required. See [identity verification](docs/identity-verification.md) for the + supported token contract and remaining chain-cache/discovery limits. + - Keep Argon2id-backed UMP v3 wallets available in React Native and other runtimes without WebAssembly by falling back to an asynchronously yielding, standards-compatible JavaScript implementation. The same KDF parameters and diff --git a/packages/wallet/wallet-toolbox/README.md b/packages/wallet/wallet-toolbox/README.md index 75f6cfa69..e140f2c1b 100644 --- a/packages/wallet/wallet-toolbox/README.md +++ b/packages/wallet/wallet-toolbox/README.md @@ -424,6 +424,17 @@ does not apply schema changes. ## Development +### Overlay identity verification + +Final identity discovery verifies transaction evidence against the wallet's +existing `Services.getChainTracker()` configuration before validating the +subject-signed identity output and certificate. Cached responses are reverified +before use. Direct `identityUtils` callers must supply a canonical `ChainTracker`; +missing context or invalid evidence produces no overlay identities. Local +contacts retain their separate policy. Inclusion does not establish unspentness +or freshness. See [identity verification](docs/identity-verification.md) for +configuration, compatibility, and remaining verification lifecycle limits. + ```bash git clone https://github.com/bsv-blockchain/ts-stack.git cd ts-stack @@ -445,9 +456,12 @@ network access, or long runtimes. Files named `*.live.test.ts` are public-networ checks, also excluded from deterministic PR coverage. Run exactly one governed suite with `test:manual -- ` or `test:live -- ` after reviewing `governance/test-quality/policy.json`; never batch-run operator suites. CI -merges four Wallet Toolbox coverage shards -for reporting; the complete local `test:coverage` run currently measures -69.12% statements, 59.09% branches, 72.83% functions, and 71.06% lines. +merges four Wallet Toolbox coverage shards for reporting. The C01 local +`test:coverage --runInBand` run passed 225 suites and 2,188 tests, with one +pre-existing skipped test. Its all-files totals were 45.75% statements, 38.86% +branches, 42.57% functions, and 45.46% lines; that collection includes imported +`out/src` code as well as source files. Use the exact run's coverage report, +rather than comparing unlike source-only and combined collections. Operational repair, migration, export, and long-running service procedures are not tests. They live under [`operator/`](./operator/README.md), produce an exact diff --git a/packages/wallet/wallet-toolbox/client/README.md b/packages/wallet/wallet-toolbox/client/README.md index 295abbe26..de07e7257 100644 --- a/packages/wallet/wallet-toolbox/client/README.md +++ b/packages/wallet/wallet-toolbox/client/README.md @@ -126,6 +126,13 @@ CSP is an application and deployment concern rather than a package-level access ## Contributor checks +Final overlay identity discovery uses the configured wallet Services ChainTracker +to verify transaction evidence before returning certificate results, including +on response-cache hits. Local contacts retain their separate policy. See the +[identity verification guide](../docs/identity-verification.md) for configuration, +compatibility, and chain-cache limits; inclusion is not proof of freshness or +unspentness. + From the repository root, build the SDK and package before running the installed-consumer browser gate: ```bash diff --git a/packages/wallet/wallet-toolbox/client/package.json b/packages/wallet/wallet-toolbox/client/package.json index a004687ff..1e2e03a3f 100644 --- a/packages/wallet/wallet-toolbox/client/package.json +++ b/packages/wallet/wallet-toolbox/client/package.json @@ -1,6 +1,6 @@ { "name": "@bsv/wallet-toolbox-client", - "version": "2.11.0", + "version": "2.11.1", "type": "module", "sideEffects": false, "engines": { diff --git a/packages/wallet/wallet-toolbox/client/platform-budget.json b/packages/wallet/wallet-toolbox/client/platform-budget.json index 921e99103..734933117 100644 --- a/packages/wallet/wallet-toolbox/client/platform-budget.json +++ b/packages/wallet/wallet-toolbox/client/platform-budget.json @@ -2,13 +2,13 @@ "profile": "browser", "maximumBytes": { "vite": { - "raw": 1693000, - "gzip": 400000, + "raw": 1696000, + "gzip": 401000, "brotli": 314000 }, "esbuild": { - "raw": 1321000, - "gzip": 364000, + "raw": 1324000, + "gzip": 365000, "brotli": 293000 } } diff --git a/packages/wallet/wallet-toolbox/docs/identity-verification.md b/packages/wallet/wallet-toolbox/docs/identity-verification.md new file mode 100644 index 000000000..40d036a81 --- /dev/null +++ b/packages/wallet/wallet-toolbox/docs/identity-verification.md @@ -0,0 +1,106 @@ +# Identity overlay verification + +This page records the bounded C01 source contract for identity results returned by the wallet toolbox. It is an inventory of the current implementation and its compatibility edges. It does not describe completion of the wider overlay reliability plan. + +## Evidence boundary + +An overlay lookup result is a host-supplied `LookupAnswer`. The resolver permits an output to carry a transaction id hint, but the hint is metadata rather than chain authority. C01 treats each output as untrusted evidence and verifies the BEEF bytes and selected output with the wallet's configured `ChainTracker` before decoding an identity certificate. + +The shared transaction seam is [`verifyOverlayOutput`](../src/utility/verifyOverlayOutput.ts). It owns a copy of the BEEF bytes, derives the transaction id from those bytes, and rejects a supplied hint unless it matches that derived id. It checks the selected output and verifies the transaction graph through the caller's tracker. An unconfirmed branch must have ancestry; an input-free unconfirmed leaf cannot anchor an identity result. The preflight also rejects duplicate inputs and conflicting spends across distinct unconfirmed ancestors, while allowing a shared transaction to be traversed once. This is consistency of the supplied graph, not an external unspentness check. The generic helper has no network default, certificate policy, verdict cache, or identity-specific locking-key rule. + +The wallet obtains the tracker through [`WalletServicesOptions.chainTracker`](../src/sdk/WalletServices.interfaces.ts) and [`Services.getChainTracker`](../src/services/Services.ts). A configured tracker is the wallet's chain authority. The utility functions [`queryOverlay`](../src/utility/identityUtils.ts), [`parseResults`](../src/utility/identityUtils.ts), and [`parseResults$`](../src/utility/identityUtils.ts) accept an explicit optional tracker; when it is missing they fail closed with an empty result. This keeps a caller from accidentally treating an overlay response as verified. + +The identity parser then decodes the selected output as PushDrop, requires the subject-signed field payload to verify, requires `VerifiableCertificate.verify()` to return `true`, decrypts the public keyring, and requires nonempty decrypted fields. A cryptographic signature failure is already surfaced as an exception by [`ProtoWallet.verifySignature`](../../../sdk/src/wallet/ProtoWallet.ts), which the parser drops with the candidate. The earlier ignored boolean from certificate verification is therefore not an invalid-certificate bypass; C01 makes the success condition explicit while adding the missing transaction evidence checks. Candidate errors are not logged because parser/decryption exceptions may contain identity data. + +## Standard identity envelope + +The interoperable envelope is the one emitted by the default [`IdentityClientOptions`](../../../sdk/src/identity/types/index.ts): protocol ID `[1, 'identity']`, key ID `'1'`, token amount `1`, and output index `0`. [`IdentityClient.publiclyRevealAttributes`](../../../sdk/src/identity/IdentityClient.ts) creates the PushDrop output and broadcasts it to `tm_identity`. + +The current [`IdentityTopicManager`](../../../../packages/overlays/topics/src/identity/IdentityTopicManager.ts) verifies the same subject-signed PushDrop fields with protocol `[1, 'identity']` and key ID `'1'`, then checks certificate validity and nonempty decrypted attributes. C01 matches that actual server contract. `IdentityClientOptions` exposes custom protocol and key values, but the current topic manager does not accept arbitrary values: a non-default protocol ID or key ID is a compatibility hazard and can cause the topic to reject the output. C01 does not widen topic acceptance. + +There is no existing topic contract for comparing an identity certificate to an arbitrary `lockingPublicKey`, so C01 adds no such equality rule. The subject-signed envelope, certificate signature, successful decryption, and trusted-certifier policy remain the relevant checks. + +## Cache and contact boundaries + +The wallet's overlay evidence cache is a two-minute response cache in [`Wallet.ts`](../src/Wallet.ts). Cached BEEF is revalidated on every use with the current tracker and certificate checks. If any candidate is rejected, the query entry is evicted so a later call can fetch fresh evidence. The cache is not a chain verdict and does not change local contact behavior. + +Local contacts are a separate wallet-owned source. [`Wallet.ts`](../src/Wallet.ts) synthesizes contact results with the local contact's subject/certifier relationship and local trust data. A contact hit may short-circuit the overlay path as before; it must retain local-contact provenance and must not be presented as an overlay SPV result. Contact lookup failures fall through to the network path. + +## Compatibility inventory and limits + +The public identity path spans the SDK wallet interfaces and clients, JSON and binary wallet transports, toolbox wallet managers, and [`IdentityClient`](../../../sdk/src/identity/IdentityClient.ts). C01 preserves those Promise method shapes and the `parseResults$` async-iterable shape. It does not change permission negotiation or pagination. The interface documentation says `seekPermission` defaults true, while the validator currently applies a false default; see [`Wallet.interfaces.ts`](../../../sdk/src/wallet/Wallet.interfaces.ts) and [`validationHelpers.ts`](../../../sdk/src/wallet/validationHelpers.ts). The wallet's current overlay calls also do not forward validated `limit` and `offset`, although the identity lookup service accepts them. These are compatibility characterization items for W00/W02, outside this C01 document. + +The resolver currently validates only the shape of a txid hint in [`LookupResolver.ts`](../../../sdk/src/overlay-tools/LookupResolver.ts). Its aggregation deduplicates by the hinted or derived txid and output index, keeping the first answer, and its fast path trusts a nonempty hint ([`LookupResolver.ts`](../../../sdk/src/overlay-tools/LookupResolver.ts), [`LookupResolver.ts`](../../../sdk/src/overlay-tools/LookupResolver.ts)). C02 owns the first-wins raw resolver suppression and pending full txid sharing work. C01 verifies the evidence that reaches the wallet; it cannot recover an alternate candidate discarded before parsing. + +The existing `ChaintracksChainTracker` still has its height-keyed root-cache and reorganization lifecycle. C01 makes no reorganization-safety claim; C03 covers that Chaintracks cache limitation and lifecycle work. C01 adds no workers, shared transaction jobs, response/graph budgets, or whole-attempt deadline. Large proofs still incur parsing and verification work on the calling runtime. These limits and canonical-context fencing require the later coordinator/runtime slices; no latency, reorganization safety, or deployment claim is made here. + +## Requirement and verification mapping + +The scoped source work supports requirement V1 (independent transaction evidence), the byte-binding portion of V2 (BEEF-derived txid must match any hint), and V5 (certificate success and trust policy). The planned characterization cases are T04 (false txid hint), T07 (confirmed/unconfirmed ancestry, scripts, values, and graph-internal conflicts), T09 (output/envelope/certificate validity), T10 (permission, cache, and local contacts), and T13 (Promise and pagination compatibility). This mapping is evidence for the C01 slice only; it is not a claim that the full verification plan has passed. + +## Configuration and migration + +This is a patch security correction in the 2.11.1 full, browser, and mobile +packages; the aggregate release-note candidate remains minor relative to the +recorded 2.10.4 published baseline. Wallet RPC and stored data need no migration. +Wallet builders keep their existing `Services` chain configuration. Its +`getChainTracker()` selects `options.chainTracker`, or wraps the configured +`options.chaintracks`; a configuration/availability error never becomes an +acceptance verdict. Use a chain source maintained independently of overlay hosts. + +Direct utility callers previously supplied only an answer. They must now pass +their canonical chain source: + +```ts +const tracker = await wallet.getServices().getChainTracker() +const certificates = await parseResults(answer, tracker) +``` + +Omitting the optional argument remains source-compatible but fails closed. +There is no bypass toggle. Failed candidate evidence evicts a wallet response +cache entry; failures never establish a permanent negative verdict for a txid. +Untrusted cached bytes may be shared within this wallet, but decrypted result +objects are rebuilt on each call and then passed through the existing trust +settings. The existing two-minute trust-settings snapshot policy is unchanged. + +## Package size review + +On 8 September 2026, the originating review task +`01a081b5-26d4-7ad1-8d85-243fe238d595` explicitly approved these measured C01 +budget adjustments under +[`governance/browser-artifact-policy.json`](../../../../governance/browser-artifact-policy.json). +The policy requires a versioned source change, composition evidence, and explicit +review. All three published artifacts advance from 2.11.0 to 2.11.1. Mandatory +transaction, graph, and identity checks remain in the portable bundles. + +Measurements used Node 24.15.0 and pnpm 10.33.2 on the same macOS host, with +base commit `2bc799a8d8e535242e6de2d305f426ce3975ea7b` extracted into a temporary +source tree and built against the same unchanged SDK and dependency graph. +`pnpm build` ran in each base/current client and mobile package. A temporary +copy of the platform checker printed every size instead of evaluating budgets: +`node /tmp/c01-measure-baseline.mjs browser`, +`node /tmp/c01-measure-baseline.mjs mobile`, +`node /tmp/c01-measure-platform.mjs browser`, and +`node /tmp/c01-measure-platform.mjs mobile`. +These were **measurements only, not passing platform gates**. The original +platform checker was not modified. + +Each cell lists raw / gzip / Brotli bytes: + +| Consumer | Base | C01 | Reviewed maximum | +| -------- | --------------------------------- | --------------------------------- | --------------------------------- | +| Vite | 1,692,309 / 399,380 / 312,207 | 1,694,805 / 400,062 / 312,426 | 1,696,000 / 401,000 / 314,000 | +| esbuild | 1,320,184 / 363,792 / 291,500 | 1,322,211 / 364,400 / 291,926 | 1,324,000 / 365,000 / 293,000 | +| Metro | 1,747,262 / 443,100 / 343,780 | 1,749,640 / 443,811 / 343,927 | 1,751,000 / 455,000 / 360,000 | +| Hermes | 3,544,570 / 1,440,174 / 1,117,759 | 3,550,004 / 1,442,134 / 1,120,813 | 3,553,000 / 1,443,000 / 1,123,000 | + +Raw growth is 2,496 / 2,027 / 2,378 / 5,434 bytes respectively (about +0.14–0.15%). Vite composition retains 106 modules and the same packages: +`@bsv/sdk`, `@bsv/wallet-toolbox-client`, `@noble/hashes`, `hash-wasm`, and `idb`. +The esbuild module count remains 173. No new dependency or platform-only import +was added. Shared wallet cache/fetch logic removes duplication; independent +verification and subject-envelope checks account for the added code. Only +exceeded dimensions changed, rounded to preserve comparable existing margins; +all other limits stay fixed. This is a reviewed security-feature payload change, +not an analysis exception. Original `test:browser` and `test:mobile` commands +remain the executable gates and are recorded separately in the delivery ledger. diff --git a/packages/wallet/wallet-toolbox/mobile/README.md b/packages/wallet/wallet-toolbox/mobile/README.md index 9af9ad398..3dab43aa3 100644 --- a/packages/wallet/wallet-toolbox/mobile/README.md +++ b/packages/wallet/wallet-toolbox/mobile/README.md @@ -85,6 +85,13 @@ Capability negotiation fails before prefunding against an older server. See ## Use cases +Final overlay identity discovery uses the configured wallet Services ChainTracker +to verify transaction evidence before returning certificate results, including +on response-cache hits. Local contacts retain their separate policy. See the +[identity verification guide](../docs/identity-verification.md) for configuration, +compatibility, and chain-cache limits; inclusion is not proof of freshness or +unspentness. + ### Self-custody BSV wallet on a phone Run the BRC-100 wallet entirely on-device, with remote storage for cross-device sync. diff --git a/packages/wallet/wallet-toolbox/mobile/package.json b/packages/wallet/wallet-toolbox/mobile/package.json index 6e7e72cc8..716215b97 100644 --- a/packages/wallet/wallet-toolbox/mobile/package.json +++ b/packages/wallet/wallet-toolbox/mobile/package.json @@ -1,6 +1,6 @@ { "name": "@bsv/wallet-toolbox-mobile", - "version": "2.11.0", + "version": "2.11.1", "type": "module", "sideEffects": false, "engines": { diff --git a/packages/wallet/wallet-toolbox/mobile/platform-budget.json b/packages/wallet/wallet-toolbox/mobile/platform-budget.json index 53027c0aa..69ddeddf0 100644 --- a/packages/wallet/wallet-toolbox/mobile/platform-budget.json +++ b/packages/wallet/wallet-toolbox/mobile/platform-budget.json @@ -2,13 +2,13 @@ "profile": "mobile", "maximumBytes": { "metro": { - "raw": 1748000, + "raw": 1751000, "gzip": 455000, "brotli": 360000 }, "hermes": { - "raw": 3547000, - "gzip": 1441000, + "raw": 3553000, + "gzip": 1443000, "brotli": 1123000 } } diff --git a/packages/wallet/wallet-toolbox/package.json b/packages/wallet/wallet-toolbox/package.json index 82c024a7b..0ee32633f 100644 --- a/packages/wallet/wallet-toolbox/package.json +++ b/packages/wallet/wallet-toolbox/package.json @@ -1,6 +1,6 @@ { "name": "@bsv/wallet-toolbox", - "version": "2.11.0", + "version": "2.11.1", "sideEffects": false, "type": "commonjs", "engines": { diff --git a/packages/wallet/wallet-toolbox/src/Wallet.ts b/packages/wallet/wallet-toolbox/src/Wallet.ts index e5ffb55ac..65a8cedaf 100644 --- a/packages/wallet/wallet-toolbox/src/Wallet.ts +++ b/packages/wallet/wallet-toolbox/src/Wallet.ts @@ -63,6 +63,8 @@ import { MasterCertificate, Certificate, LookupResolver, + LookupAnswer, + VerifiableCertificate, AtomicBEEF, BEEF, KeyDeriverApi, @@ -79,7 +81,7 @@ import { createAction, CreateActionResultX } from './signer/methods/createAction import { signAction, SignActionResultX } from './signer/methods/signAction' import { internalizeAction } from './signer/methods/internalizeAction' import { WalletSettingsManager } from './WalletSettingsManager' -import { queryOverlay, transformVerifiableCertificatesWithTrust } from './utility/identityUtils' +import { parseResults, queryOverlayEvidence, transformVerifiableCertificatesWithTrust } from './utility/identityUtils' import { maxPossibleSatoshis } from './storage/methods/generateChange' import { hasBrc177NoSendExpiryLabel, parseBrc177NoSendExpiryLabels } from './utility/brc177NoSendExpiry' import { createNoSendExpiryAction } from './signer/methods/createNoSendExpiryAction' @@ -791,8 +793,32 @@ export class Wallet implements WalletInterface, ProtoWallet { trustSettings: Awaited>['trustSettings'] } - /** 2-minute cache of queryOverlay() results keyed by normalized query */ - private readonly _overlayCache: Map = new Map() + /** Two-minute untrusted response cache. Every use repeats transaction and identity checks. */ + private readonly _overlayEvidenceCache = new Map() + + private async discoverOverlayCertificates( + query: unknown, + cacheKey: string, + forceRefresh: boolean, + now: number + ): Promise { + // Use the wallet's existing network/chain configuration, never the overlay host's verdict. + const chainTracker = await this.getServices().getChainTracker() + let cached = forceRefresh ? undefined : this._overlayEvidenceCache.get(cacheKey) + if (cached == null || cached.expiresAt <= now) { + const value = await queryOverlayEvidence(query, this.lookupResolver) + cached = { value, expiresAt: now + 2 * 60 * 1000 } + this._overlayEvidenceCache.set(cacheKey, cached) + } + if (cached.value.type !== 'output-list') { + this._overlayEvidenceCache.delete(cacheKey) + return [] + } + const certificates = await parseResults(cached.value, chainTracker) + // Failed evidence must allow another fetch, including after temporary chain unavailability. + if (certificates.length !== cached.value.outputs.length) this._overlayEvidenceCache.delete(cacheKey) + return certificates + } async discoverByIdentityKey( args: DiscoverByIdentityKeyArgs & { forceRefresh?: boolean }, @@ -831,25 +857,20 @@ export class Wallet implements WalletInterface, ProtoWallet { const certifiers = trustSettings.trustedCertifiers.map(c => c.identityKey).sort((a, b) => a.localeCompare(b)) - // --- queryOverlay cache (2 minutes, client-side, bounded staleness) --- + // --- Untrusted overlay response cache; verify again before use. --- const cacheKey = JSON.stringify({ fn: 'discoverByIdentityKey', identityKey: args.identityKey, certifiers }) - let cached = forceRefresh ? undefined : this._overlayCache.get(cacheKey) - if (cached == null || cached.expiresAt <= now) { - const value = await queryOverlay({ identityKey: args.identityKey, certifiers }, this.lookupResolver) - cached = { value, expiresAt: now + TTL_MS } - this._overlayCache.set(cacheKey, cached) - } - - if (!cached.value) { - return { totalCertificates: 0, certificates: [] } - } - - return transformVerifiableCertificatesWithTrust(trustSettings, cached.value as any) + const certificates = await this.discoverOverlayCertificates( + { identityKey: args.identityKey, certifiers }, + cacheKey, + forceRefresh, + now + ) + return transformVerifiableCertificatesWithTrust(trustSettings, certificates) } async discoverByAttributes( @@ -897,25 +918,20 @@ export class Wallet implements WalletInterface, ProtoWallet { attributesKey = JSON.stringify(args.attributes, keys) } - // --- queryOverlay cache (2 minutes, client-side, bounded staleness) --- + // --- Untrusted overlay response cache; verify again before use. --- const cacheKey = JSON.stringify({ fn: 'discoverByAttributes', attributes: attributesKey, certifiers }) - let cached = forceRefresh ? undefined : this._overlayCache.get(cacheKey) - if (cached == null || cached.expiresAt <= now) { - const value = await queryOverlay({ attributes: args.attributes, certifiers }, this.lookupResolver) - cached = { value, expiresAt: now + TTL_MS } - this._overlayCache.set(cacheKey, cached) - } - - if (!cached.value) { - return { totalCertificates: 0, certificates: [] } - } - - return transformVerifiableCertificatesWithTrust(trustSettings, cached.value as any) + const certificates = await this.discoverOverlayCertificates( + { attributes: args.attributes, certifiers }, + cacheKey, + forceRefresh, + now + ) + return transformVerifiableCertificatesWithTrust(trustSettings, certificates) } verifyReturnedTxidOnly(beef: Beef, knownTxids?: string[]): Beef { diff --git a/packages/wallet/wallet-toolbox/src/__tests/Wallet.identityVerification.test.ts b/packages/wallet/wallet-toolbox/src/__tests/Wallet.identityVerification.test.ts new file mode 100644 index 000000000..23ce78fcc --- /dev/null +++ b/packages/wallet/wallet-toolbox/src/__tests/Wallet.identityVerification.test.ts @@ -0,0 +1,176 @@ +import { KeyDeriver, LookupAnswer, LookupResolver, PrivateKey, Validation } from '@bsv/sdk' +import { Wallet } from '../Wallet' +import { WalletSettingsManager } from '../WalletSettingsManager' +import { WalletStorageManager } from '../storage/WalletStorageManager' +import { WalletServices } from '../sdk/WalletServices.interfaces' +import { + createIdentityVerificationFixture, + IdentityVerificationFixture +} from '../utility/__tests__/identityVerification.fixtures' + +function walletFor(fixture: IdentityVerificationFixture) { + const keyDeriver = new KeyDeriver(new PrivateKey(15)) + const trustSettings = { + trustLevel: 1, + trustedCertifiers: [ + { identityKey: fixture.certificate.certifier, name: 'Synthetic certifier', description: '', trust: 1 } + ] + } + const query = jest.fn(async (): Promise => ({ + type: 'output-list', + outputs: [{ beef: fixture.certificateBEEF, outputIndex: 0 }] + })) + const getChainTracker = jest.fn(async () => fixture.confirmedTracker) + const getSettings = jest.fn(async () => ({ trustSettings })) + const wallet = new Wallet({ + chain: 'main', + keyDeriver, + storage: new WalletStorageManager(keyDeriver.identityKey), + services: { getChainTracker } as unknown as WalletServices, + lookupResolver: { query } as unknown as LookupResolver, + settingsManager: { get: getSettings } as unknown as WalletSettingsManager + }) + return { wallet, query, getChainTracker, getSettings, trustSettings } +} + +describe('Wallet final identity verification and compatibility', () => { + let fixture: IdentityVerificationFixture + beforeEach(async () => { + fixture = await createIdentityVerificationFixture() + }) + + it.each(['identity', 'attributes'] as const)('verifies evidence again on a cached %s discovery', async method => { + const { wallet, query, getChainTracker } = walletFor(fixture) + const discover = () => + method === 'identity' + ? wallet.discoverByIdentityKey({ identityKey: fixture.certificate.subject }, 'app.example') + : wallet.discoverByAttributes({ attributes: { name: 'Alice' } }, 'app.example') + + await expect(discover()).resolves.toMatchObject({ totalCertificates: 1 }) + const checked = fixture.confirmedTracker.checkedRoots.length + await expect(discover()).resolves.toMatchObject({ totalCertificates: 1 }) + expect(query).toHaveBeenCalledTimes(1) + expect(getChainTracker).toHaveBeenCalledTimes(2) + expect(fixture.confirmedTracker.checkedRoots.length).toBeGreaterThan(checked) + + // The configured source changes its verdict. This tests the wallet cache, not + // reorg detection/invalidation inside a particular ChainTracker implementation. + const roots = [...fixture.confirmedTracker.roots] + fixture.confirmedTracker.roots.clear() + await expect(discover()).resolves.toEqual({ totalCertificates: 0, certificates: [] }) + roots.forEach(root => fixture.confirmedTracker.roots.add(root)) + await expect(discover()).resolves.toMatchObject({ totalCertificates: 1 }) + expect(query).toHaveBeenCalledTimes(2) + }) + + it('does not let host or returned-certificate mutation change cached evidence', async () => { + const { wallet, query } = walletFor(fixture) + const args = { identityKey: fixture.certificate.subject } + const first = await wallet.discoverByIdentityKey(args) + fixture.certificateBEEF.fill(0) + first.certificates[0].decryptedFields.name = 'Changed locally' + const second = await wallet.discoverByIdentityKey(args) + expect(second.certificates[0].decryptedFields).toEqual({ name: 'Alice' }) + expect(query).toHaveBeenCalledTimes(1) + }) + + it('keeps trust filtering and forceRefresh on the final Promise API', async () => { + const { wallet, query, trustSettings } = walletFor(fixture) + const args = { identityKey: fixture.certificate.subject } + const promise = wallet.discoverByIdentityKey(args) + expect(promise).toBeInstanceOf(Promise) + await expect(promise).resolves.toMatchObject({ totalCertificates: 1 }) + trustSettings.trustLevel = 2 + await expect(wallet.discoverByIdentityKey(args)).resolves.toEqual({ totalCertificates: 0, certificates: [] }) + trustSettings.trustLevel = 1 + await expect(wallet.discoverByIdentityKey({ ...args, forceRefresh: true })).resolves.toMatchObject({ + totalCertificates: 1 + }) + expect(query).toHaveBeenCalledTimes(2) + trustSettings.trustedCertifiers = [] + await expect(wallet.discoverByIdentityKey(args)).resolves.toEqual({ totalCertificates: 0, certificates: [] }) + }) + + it('preserves local contact provenance and bypasses chain/network work for contacts', async () => { + const { wallet, query, getChainTracker, getSettings } = walletFor(fixture) + const contact = { identityKey: fixture.certificate.subject, decryptedFields: { name: 'Local Alice' } } + wallet.contactSource = { + findByIdentityKey: async () => contact, + findByAttributes: async () => [contact] + } + wallet.services = undefined + for (const result of [ + await wallet.discoverByIdentityKey({ identityKey: contact.identityKey }), + await wallet.discoverByAttributes({ attributes: { name: 'Local Alice' } }) + ]) { + expect(result).toMatchObject({ + totalCertificates: 1, + certificates: [ + { + type: 'contact', + signature: '', + decryptedFields: { name: 'Local Alice' }, + certifierInfo: { trust: Infinity } + } + ] + }) + } + expect(query).not.toHaveBeenCalled() + expect(getChainTracker).not.toHaveBeenCalled() + expect(getSettings).not.toHaveBeenCalled() + await expect( + wallet.discoverByIdentityKey({ identityKey: contact.identityKey, forceRefresh: true }) + ).rejects.toThrow() + }) + + it('rejects missing chain configuration before overlay lookup', async () => { + const { wallet, query, getChainTracker } = walletFor(fixture) + getChainTracker.mockRejectedValueOnce(new Error('Canonical source unavailable')) + await expect(wallet.discoverByIdentityKey({ identityKey: fixture.certificate.subject })).rejects.toThrow( + 'Canonical source unavailable' + ) + expect(query).not.toHaveBeenCalled() + }) + + it('fails closed for a non-output-list response without caching it or throwing a TypeError', async () => { + const { wallet, query } = walletFor(fixture) + query.mockResolvedValueOnce({ type: 'freeform', result: { untrusted: true } } as unknown as LookupAnswer) + const args = { identityKey: fixture.certificate.subject } + await expect(wallet.discoverByIdentityKey(args)).resolves.toEqual({ totalCertificates: 0, certificates: [] }) + await expect(wallet.discoverByIdentityKey(args)).resolves.toMatchObject({ totalCertificates: 1 }) + expect(query).toHaveBeenCalledTimes(2) + }) + + it('characterizes legacy pagination and seekPermission without changing forwarding', async () => { + const { wallet, query } = walletFor(fixture) + const identityArgs = { identityKey: fixture.certificate.subject, limit: 1, offset: 3 } + expect(Validation.validateDiscoverByIdentityKeyArgs({ identityKey: fixture.certificate.subject })).toMatchObject({ + limit: 10, + offset: 0, + seekPermission: false + }) + expect(Validation.validateDiscoverByAttributesArgs({ attributes: { name: 'Alice' } })).toMatchObject({ + limit: 10, + offset: 0, + seekPermission: false + }) + await expect(wallet.discoverByIdentityKey(identityArgs)).resolves.toMatchObject({ totalCertificates: 1 }) + expect(query).toHaveBeenLastCalledWith( + { + service: 'ls_identity', + query: { identityKey: fixture.certificate.subject, certifiers: [fixture.certificate.certifier] } + }, + undefined, + { graceMs: 300 } + ) + await wallet.discoverByAttributes({ attributes: { name: 'Alice' }, limit: 1, offset: 3, seekPermission: true }) + expect(query).toHaveBeenLastCalledWith( + { service: 'ls_identity', query: { attributes: { name: 'Alice' }, certifiers: [fixture.certificate.certifier] } }, + undefined, + { graceMs: 300 } + ) + await expect(wallet.discoverByIdentityKey({ ...identityArgs, limit: 10_001 })).rejects.toThrow() + await expect(wallet.discoverByAttributes({ attributes: {}, offset: -1 })).rejects.toThrow() + expect(query).toHaveBeenCalledTimes(2) + }) +}) diff --git a/packages/wallet/wallet-toolbox/src/utility/__tests__/fixtures/identity-verification.json b/packages/wallet/wallet-toolbox/src/utility/__tests__/fixtures/identity-verification.json new file mode 100644 index 000000000..62e73fb1c --- /dev/null +++ b/packages/wallet/wallet-toolbox/src/utility/__tests__/fixtures/identity-verification.json @@ -0,0 +1,4 @@ +{ + "certificateBEEF": "AQC+7wH+YK4KAAECAABCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQgECl2dy9ADFnVCeuMA5R3KlEhfmb4j4Pqoc8xPSELmlbC0BAQAAAAABCQAAAAAAAAD9WgMhA4aNtbOPYqHd8rvAci59Wcv2cpkCpYJfqMBEHMPQFfbGrE3rAnsidHlwZSI6IkFRRUJBUUVCQVFFQkFRRUJBUUVCQVFFQkFRRUJBUUVCQVFFQkFRRUJBUUU9Iiwic2VyaWFsTnVtYmVyIjoiQWdJQ0FnSUNBZ0lDQWdJQ0FnSUNBZ0lDQWdJQ0FnSUNBZ0lDQWdJQ0FnST0iLCJzdWJqZWN0IjoiMDM3NzRhZTdmODU4YTk0MTFlNWVmNDI0NmI3MGM2NWFhYzU2NDk5ODBiZTVjMTc4OTFiYmVjMTc4OTVkYTAwOGNiIiwiY2VydGlmaWVyIjoiMDNkMDExMTVkNTQ4ZTc1NjFiMTVjMzhmMDA0ZDczNDYzMzY4N2NmNDQxOTYyMDA5NWJjNWIwZjQ3MDcwYWZlODVhIiwicmV2b2NhdGlvbk91dHBvaW50IjoiMDEwMTAxMDEwMTAxMDEwMTAxMDEwMTAxMDEwMTAxMDEwMTAxMDEwMTAxMDEwMTAxMDEwMTAxMDEwMTAxMDEwMS4wIiwiZmllbGRzIjp7Im5hbWUiOiJQUDlBNlRRU3NrSXZFcHA0cDhQamQ2MHRYUms2ekNvMkdSV0ErRGZja0lmejE2c0tpWENYNHR2bGFLM3hJQUxTZmRJbUlOVT0ifSwic2lnbmF0dXJlIjoiMzA0NTAyMjEwMGRjOWZiYjg3MWViOGVkZGU2YTFlZDJjMGRkNWNhZWRhOGQzOTE0ZjI5YzliNTczYTUzMWQ5NDNhYzFkMjY0OWUwMjIwMGJiZTMzNjc3NzYxNDBjNjY0YTQzNDRlNDk2ZGFkNGE4MmQ2N2QxZGNjN2ZlZDg5OTJkMzhiYjJjZTIyZTAwMSIsImtleXJpbmciOnsibmFtZSI6ImZIMklUTlFoYnIvSkdobUs2QVBEUThVRkc5ZXowMHcrempEWmY1bUhaVitNb1NpL0pXaEpzWEJTeWFaMU4yUWxVMUZHY2FxVzRNY25wNmNrZ3pzcjk3SGgzeElTSHlrb09qaVJWdVZSaE1zPSJ9fUcwRQIhAL3vVAviqbcyhtgqNiXHxK33GyRwtfkU5Bgy/470pdPlAiBmzWHNcSJqUgfPQdH/ZuxmujeXrCBU7wxnnOCAn9zlGW0AAAAAAQA=", + "certificateBEEFSha256": "86df273e253fd47750ad25797712c943d628eecf6db115a2b6649366aa274d81" +} diff --git a/packages/wallet/wallet-toolbox/src/utility/__tests__/identityVerification.fixtures.ts b/packages/wallet/wallet-toolbox/src/utility/__tests__/identityVerification.fixtures.ts new file mode 100644 index 000000000..92e567b36 --- /dev/null +++ b/packages/wallet/wallet-toolbox/src/utility/__tests__/identityVerification.fixtures.ts @@ -0,0 +1,149 @@ +import { + ChainTracker, + MerklePath, + P2PKH, + PrivateKey, + ProtoWallet, + PushDrop, + Script, + Transaction, + Utils, + VerifiableCertificate +} from '@bsv/sdk' +import { createHash } from 'node:crypto' +import fixtureJson from './fixtures/identity-verification.json' + +// Synthetic fixture: fixed subject/certifier keys 11/12; pinned BEEF SHA-256 is checked before use. + +export const IDENTITY_VERIFICATION_PROTOCOL: [1, string] = [1, 'identity'] +export const IDENTITY_VERIFICATION_KEY_ID = '1' +export const IDENTITY_VERIFICATION_CONFIRMED_HEIGHT = 700_000 + +/** A local canonical-root source used by identity verification fixtures. */ +export class IdentityVerificationChainTracker implements ChainTracker { + readonly roots = new Set() + readonly checkedRoots: Array<{ root: string; height: number }> = [] + + async currentHeight(): Promise { + return IDENTITY_VERIFICATION_CONFIRMED_HEIGHT + 101 + } + + async isValidRootForHeight(root: string, height: number): Promise { + this.checkedRoots.push({ root, height }) + return height === IDENTITY_VERIFICATION_CONFIRMED_HEIGHT && this.roots.has(root) + } +} + +export interface IdentityVerificationFixture { + certificate: VerifiableCertificate + subjectWallet: ProtoWallet + certificateTransaction: Transaction + certificateBEEF: number[] + confirmedTracker: IdentityVerificationChainTracker + unconfirmedTransaction: Transaction + unconfirmedBEEF: number[] +} + +function sha256d(bytes: Uint8Array): Uint8Array { + return createHash('sha256').update(createHash('sha256').update(bytes).digest()).digest() +} + +/** Independent Node SHA-256d reference for a serialized transaction ID. */ +export function fixtureTxidFromBytes(bytes: Uint8Array): string { + return Buffer.from(sha256d(bytes)).reverse().toString('hex') +} + +/** Independent Bitcoin Merkle reference over display-order transaction IDs. */ +export function fixtureMerkleRoot(left: string, right: string): string { + const leftLE = Buffer.from(left, 'hex').reverse() + const rightLE = Buffer.from(right, 'hex').reverse() + return Buffer.from(sha256d(Buffer.concat([leftLE, rightLE]))) + .reverse() + .toString('hex') +} + +/** Attach a two-leaf, non-coinbase proof and register its root in the local tracker. */ +export function confirmIdentityFixtureTransaction(tx: Transaction, tracker: IdentityVerificationChainTracker): void { + const txid = tx.id('hex') + if (fixtureTxidFromBytes(tx.toUint8Array()) !== txid) throw new Error('SDK transaction ID disagrees with SHA-256d') + const path = new MerklePath(IDENTITY_VERIFICATION_CONFIRMED_HEIGHT, [ + [ + { offset: 0, hash: '42'.repeat(32) }, + { offset: 1, hash: txid, txid: true } + ] + ]) + tx.merklePath = path + const root = fixtureMerkleRoot('42'.repeat(32), txid) + if (path.computeRoot(txid) !== root) throw new Error('SDK Merkle root disagrees with SHA-256d') + tracker.roots.add(root) +} + +/** + * Builds a local, signed identity certificate in a subject-signed PushDrop output. + * The returned unconfirmed transaction spends a separately confirmed P2PKH ancestor. + */ +export async function createIdentityVerificationFixture(): Promise { + const subjectWallet = new ProtoWallet(new PrivateKey(11)) + const certificateBEEF = Utils.toArray(fixtureJson.certificateBEEF, 'base64') + if (createHash('sha256').update(Buffer.from(certificateBEEF)).digest('hex') !== fixtureJson.certificateBEEFSha256) { + throw new Error('Identity fixture BEEF digest mismatch') + } + const certificateTransaction = Transaction.fromBEEF(certificateBEEF) + const decoded = PushDrop.decode(certificateTransaction.outputs[0].lockingScript) + const parsed = JSON.parse(Utils.toUTF8(decoded.fields[0])) + const certificate = new VerifiableCertificate( + parsed.type, + parsed.serialNumber, + parsed.subject, + parsed.certifier, + parsed.revocationOutpoint, + parsed.fields, + parsed.keyring, + parsed.signature + ) + const confirmedTracker = new IdentityVerificationChainTracker() + const certificateTxid = fixtureTxidFromBytes(certificateTransaction.toUint8Array()) + const certificateRoot = fixtureMerkleRoot('42'.repeat(32), certificateTxid) + if ( + certificateTransaction.id('hex') !== certificateTxid || + certificateTransaction.merklePath?.computeRoot(certificateTxid) !== certificateRoot + ) { + throw new Error('Pinned identity fixture does not match its SHA-256d references') + } + confirmedTracker.roots.add(certificateRoot) + + const spendingKey = new PrivateKey(13) + const ancestor = new Transaction() + ancestor.addInput({ + sourceTXID: '00'.repeat(32), + sourceOutputIndex: 0, + unlockingScript: Script.fromASM('OP_TRUE') + }) + ancestor.addOutput({ satoshis: 10, lockingScript: new P2PKH().lock(spendingKey.toAddress()) }) + confirmIdentityFixtureTransaction(ancestor, confirmedTracker) + + const createCertificateSpend = async (): Promise => { + const transaction = new Transaction() + transaction.addInput({ + sourceTransaction: ancestor, + sourceOutputIndex: 0, + unlockingScriptTemplate: new P2PKH().unlock(spendingKey) + }) + transaction.addOutput({ satoshis: 9, lockingScript: certificateTransaction.outputs[0].lockingScript }) + await transaction.sign() + return transaction + } + const confirmedCertificateTransaction = await createCertificateSpend() + confirmIdentityFixtureTransaction(confirmedCertificateTransaction, confirmedTracker) + const unconfirmedTransaction = await createCertificateSpend() + + return { + certificate, + subjectWallet, + certificateTransaction: confirmedCertificateTransaction, + certificateBEEF: confirmedCertificateTransaction.toBEEF(), + confirmedTracker, + unconfirmedTransaction, + unconfirmedBEEF: unconfirmedTransaction.toBEEF() + } +} diff --git a/packages/wallet/wallet-toolbox/src/utility/__tests__/identityVerification.test.ts b/packages/wallet/wallet-toolbox/src/utility/__tests__/identityVerification.test.ts new file mode 100644 index 000000000..0ab698888 --- /dev/null +++ b/packages/wallet/wallet-toolbox/src/utility/__tests__/identityVerification.test.ts @@ -0,0 +1,396 @@ +import { + MerklePath, + P2PKH, + PrivateKey, + ProtoWallet, + PushDrop, + Script, + Transaction, + Utils, + VerifiableCertificate +} from '@bsv/sdk' +import { LookupAnswer, LookupResolver } from '@bsv/sdk' +import { parseResults, parseResults$, queryOverlay } from '../identityUtils' +import { verifyOverlayOutput } from '../verifyOverlayOutput' +import { + createIdentityVerificationFixture, + confirmIdentityFixtureTransaction, + IDENTITY_VERIFICATION_CONFIRMED_HEIGHT, + IDENTITY_VERIFICATION_KEY_ID, + IDENTITY_VERIFICATION_PROTOCOL, + IdentityVerificationChainTracker, + IdentityVerificationFixture +} from './identityVerification.fixtures' + +function answer(...outputs: LookupAnswer['outputs']): LookupAnswer { + return { type: 'output-list', outputs } +} + +function outputFor(beef: number[], outputIndex = 0, txid?: string): LookupAnswer['outputs'][number] { + return txid === undefined ? { beef, outputIndex } : { beef, outputIndex, txid } +} + +async function collect(iterable: AsyncIterable): Promise { + const values: VerifiableCertificate[] = [] + for await (const value of iterable) values.push(value) + return values +} + +describe('identity overlay verification', () => { + let fixture: IdentityVerificationFixture + + beforeEach(async () => { + fixture = await createIdentityVerificationFixture() + }) + + it('accepts a signed, decryptable certificate from a locally confirmed transaction', async () => { + await expect(fixture.certificateTransaction.verify(fixture.confirmedTracker)).resolves.toBe(true) + + const results = await parseResults( + answer(outputFor(fixture.certificateBEEF, 0, fixture.certificateTransaction.id('hex'))), + fixture.confirmedTracker + ) + + expect(results).toHaveLength(1) + expect(results[0].decryptedFields).toEqual({ name: 'Alice' }) + expect(fixture.confirmedTracker.checkedRoots).toContainEqual({ + root: fixture.certificateTransaction.merklePath?.computeRoot(fixture.certificateTransaction.id('hex')), + height: IDENTITY_VERIFICATION_CONFIRMED_HEIGHT + }) + }) + + it('accepts a genuinely signed unconfirmed spend only when its ancestor is confirmed', async () => { + await expect(fixture.unconfirmedTransaction.verify(fixture.confirmedTracker)).resolves.toBe(true) + + await expect( + parseResults(answer(outputFor(fixture.unconfirmedBEEF)), fixture.confirmedTracker) + ).resolves.toHaveLength(1) + }) + + it('rejects confirmed candidates with an invalid or unavailable chain root', async () => { + const noRoots = new IdentityVerificationChainTracker() + + await expect(parseResults(answer(outputFor(fixture.certificateBEEF)), noRoots)).resolves.toEqual([]) + }) + + it('rejects an unconfirmed candidate whose BEEF omits its ancestry', async () => { + const disconnected = Transaction.fromBEEF(fixture.unconfirmedBEEF) + disconnected.inputs[0].sourceTransaction = undefined + disconnected.inputs[0].sourceTXID = fixture.unconfirmedTransaction.inputs[0].sourceTransaction?.id('hex') as string + + await expect(parseResults(answer(outputFor(disconnected.toBEEF(true))), fixture.confirmedTracker)).resolves.toEqual( + [] + ) + }) + + it('rejects invalid spend scripts and values instead of accepting scripts-only evidence', async () => { + const invalidScript = Transaction.fromBEEF(fixture.unconfirmedBEEF) + invalidScript.inputs[0].unlockingScript = Script.fromASM('OP_FALSE') + const invalidValue = Transaction.fromBEEF(fixture.unconfirmedBEEF) + invalidValue.outputs[0].satoshis = 11 + + await expect(parseResults(answer(outputFor(invalidScript.toBEEF())), fixture.confirmedTracker)).resolves.toEqual([]) + await expect(parseResults(answer(outputFor(invalidValue.toBEEF())), fixture.confirmedTracker)).resolves.toEqual([]) + }) + + it.each([ + ['mismatched', '00'.repeat(32)], + ['empty', ''], + ['malformed', 'not-a-txid'] + ])('rejects a %s txid hint', async (_kind, txid) => { + await expect( + parseResults(answer(outputFor(fixture.certificateBEEF, 0, txid)), fixture.confirmedTracker) + ).resolves.toEqual([]) + }) + + it('rejects a validly signed transaction that spends the same confirmed outpoint twice', async () => { + const ancestor = fixture.unconfirmedTransaction.inputs[0].sourceTransaction! + const spendingKey = new PrivateKey(13) + const duplicateSpend = new Transaction() + for (let index = 0; index < 2; index++) { + duplicateSpend.addInput({ + sourceTransaction: ancestor, + sourceOutputIndex: 0, + unlockingScriptTemplate: new P2PKH().unlock(spendingKey) + }) + } + duplicateSpend.addOutput({ satoshis: 19, lockingScript: fixture.certificateTransaction.outputs[0].lockingScript }) + await duplicateSpend.sign() + + // SDK graph/value/script verification alone currently accepts duplicate outpoints. + await expect(duplicateSpend.verify(fixture.confirmedTracker)).resolves.toBe(true) + await expect(parseResults(answer(outputFor(duplicateSpend.toBEEF())), fixture.confirmedTracker)).resolves.toEqual( + [] + ) + }) + + it('rejects a joined graph whose distinct unconfirmed parents double-spend one confirmed outpoint', async () => { + const ancestor = fixture.unconfirmedTransaction.inputs[0].sourceTransaction! + const spendingKey = new PrivateKey(13) + const createParent = async (satoshis: number): Promise => { + const tx = new Transaction() + tx.addInput({ + sourceTransaction: ancestor, + sourceOutputIndex: 0, + unlockingScriptTemplate: new P2PKH().unlock(spendingKey) + }) + tx.addOutput({ satoshis, lockingScript: new P2PKH().lock(spendingKey.toAddress()) }) + await tx.sign() + return tx + } + const first = await createParent(9) + const second = await createParent(8) + expect(first.id('hex')).not.toEqual(second.id('hex')) + const joined = new Transaction() + for (const sourceTransaction of [first, second]) { + joined.addInput({ + sourceTransaction, + sourceOutputIndex: 0, + unlockingScriptTemplate: new P2PKH().unlock(spendingKey) + }) + } + joined.addOutput({ satoshis: 16, lockingScript: fixture.certificateTransaction.outputs[0].lockingScript }) + await joined.sign() + + await expect(joined.verify(fixture.confirmedTracker)).resolves.toBe(true) + await expect(parseResults(answer(outputFor(joined.toBEEF())), fixture.confirmedTracker)).resolves.toEqual([]) + }) + + it('rejects an identity child of an unconfirmed ancestor with duplicate inputs', async () => { + const ancestor = fixture.unconfirmedTransaction.inputs[0].sourceTransaction! + const spendingKey = new PrivateKey(13) + const duplicateAncestor = new Transaction() + for (let index = 0; index < 2; index++) { + duplicateAncestor.addInput({ + sourceTransaction: ancestor, + sourceOutputIndex: 0, + unlockingScriptTemplate: new P2PKH().unlock(spendingKey) + }) + } + duplicateAncestor.addOutput({ satoshis: 19, lockingScript: new P2PKH().lock(spendingKey.toAddress()) }) + await duplicateAncestor.sign() + const identityChild = new Transaction() + identityChild.addInput({ + sourceTransaction: duplicateAncestor, + sourceOutputIndex: 0, + unlockingScriptTemplate: new P2PKH().unlock(spendingKey) + }) + identityChild.addOutput({ satoshis: 9, lockingScript: fixture.certificateTransaction.outputs[0].lockingScript }) + await identityChild.sign() + + await expect(identityChild.verify(fixture.confirmedTracker)).resolves.toBe(true) + await expect(parseResults(answer(outputFor(identityChild.toBEEF())), fixture.confirmedTracker)).resolves.toEqual([]) + }) + + it('accepts a joined graph that spends distinct outputs of a shared unconfirmed ancestor', async () => { + const spendingKey = new PrivateKey(13) + const confirmedSource = new Transaction() + confirmedSource.addInput({ + sourceTXID: '00'.repeat(32), + sourceOutputIndex: 0, + unlockingScript: Script.fromASM('OP_TRUE') + }) + const lockingScript = new P2PKH().lock(spendingKey.toAddress()) + confirmedSource.addOutput({ satoshis: 10, lockingScript }) + confirmIdentityFixtureTransaction(confirmedSource, fixture.confirmedTracker) + const ancestor = new Transaction() + ancestor.addInput({ + sourceTransaction: confirmedSource, + sourceOutputIndex: 0, + unlockingScriptTemplate: new P2PKH().unlock(spendingKey) + }) + ancestor.addOutput({ satoshis: 4, lockingScript }) + ancestor.addOutput({ satoshis: 4, lockingScript }) + await ancestor.sign() + const parents: Transaction[] = [] + for (const [sourceOutputIndex, satoshis] of [ + [0, 3], + [1, 2] + ] as const) { + const parent = new Transaction() + parent.addInput({ + sourceTransaction: ancestor, + sourceOutputIndex, + unlockingScriptTemplate: new P2PKH().unlock(spendingKey) + }) + parent.addOutput({ satoshis, lockingScript }) + await parent.sign() + parents.push(parent) + } + const joined = new Transaction() + for (const sourceTransaction of parents) { + joined.addInput({ + sourceTransaction, + sourceOutputIndex: 0, + unlockingScriptTemplate: new P2PKH().unlock(spendingKey) + }) + } + joined.addOutput({ satoshis: 4, lockingScript: fixture.certificateTransaction.outputs[0].lockingScript }) + await joined.sign() + + await expect(joined.verify(fixture.confirmedTracker)).resolves.toBe(true) + await expect(parseResults(answer(outputFor(joined.toBEEF())), fixture.confirmedTracker)).resolves.toHaveLength(1) + }) + + it.each([ + ['fractional', 0.5], + ['negative', -1], + ['out of range', 1] + ])('rejects a %s output index', async (_kind, outputIndex) => { + await expect( + parseResults(answer(outputFor(fixture.certificateBEEF, outputIndex)), fixture.confirmedTracker) + ).resolves.toEqual([]) + }) + + it('rejects missing and tampered subject envelope signatures and a signature from another subject', async () => { + const certificateJson = Utils.toArray(JSON.stringify(fixture.certificate), 'utf8') + const missingSignature = await new PushDrop(fixture.subjectWallet).lock( + [certificateJson], + IDENTITY_VERIFICATION_PROTOCOL, + IDENTITY_VERIFICATION_KEY_ID, + 'anyone', + true, + false + ) + const tamperedSignature = await new PushDrop(fixture.subjectWallet).lock( + [certificateJson], + IDENTITY_VERIFICATION_PROTOCOL, + IDENTITY_VERIFICATION_KEY_ID, + 'anyone', + true, + true + ) + const signatureChunk = tamperedSignature.chunks[3] + if (signatureChunk?.data == null) throw new Error('fixture envelope signature is missing') + signatureChunk.data = [...signatureChunk.data] + signatureChunk.data[0] ^= 1 + const wrongSubject = new ProtoWallet(new PrivateKey(14)) + const wrongKey = await new PushDrop(wrongSubject).lock( + [certificateJson], + IDENTITY_VERIFICATION_PROTOCOL, + IDENTITY_VERIFICATION_KEY_ID, + 'anyone', + true, + true + ) + + const candidates = [missingSignature, tamperedSignature, wrongKey].map(lockingScript => { + const tx = new Transaction() + tx.addOutput({ satoshis: 9, lockingScript }) + confirmIdentityFixtureTransaction(tx, fixture.confirmedTracker) + return outputFor(tx.toBEEF()) + }) + + await expect(parseResults(answer(...candidates), fixture.confirmedTracker)).resolves.toEqual([]) + }) + + it('rejects an input-free unconfirmed zero-value leaf', async () => { + const tx = new Transaction() + tx.addOutput({ satoshis: 0, lockingScript: fixture.certificateTransaction.outputs[0].lockingScript }) + + await expect(parseResults(answer(outputFor(tx.toBEEF())), fixture.confirmedTracker)).resolves.toEqual([]) + }) + + it('rejects an invalid certificate signature even when transaction and envelope verification pass', async () => { + const tamperedCertificate = { ...fixture.certificate, signature: '00'.repeat(64) } + const lockingScript = await new PushDrop(fixture.subjectWallet).lock( + [Utils.toArray(JSON.stringify(tamperedCertificate), 'utf8')], + IDENTITY_VERIFICATION_PROTOCOL, + IDENTITY_VERIFICATION_KEY_ID, + 'anyone', + true, + true + ) + const tx = new Transaction() + tx.addOutput({ satoshis: 9, lockingScript }) + confirmIdentityFixtureTransaction(tx, fixture.confirmedTracker) + + await expect(parseResults(answer(outputFor(tx.toBEEF())), fixture.confirmedTracker)).resolves.toEqual([]) + }) + + it('fails closed when SDK transaction or certificate verification returns false or throws', async () => { + const txFalse = jest.spyOn(Transaction.prototype, 'verify').mockResolvedValueOnce(false) + await expect(parseResults(answer(outputFor(fixture.certificateBEEF)), fixture.confirmedTracker)).resolves.toEqual( + [] + ) + txFalse.mockRestore() + + const txThrows = jest + .spyOn(Transaction.prototype, 'verify') + .mockRejectedValueOnce(new Error('verification unavailable')) + await expect(parseResults(answer(outputFor(fixture.certificateBEEF)), fixture.confirmedTracker)).resolves.toEqual( + [] + ) + txThrows.mockRestore() + + const certFalse = jest.spyOn(VerifiableCertificate.prototype, 'verify').mockResolvedValueOnce(false) + await expect(parseResults(answer(outputFor(fixture.certificateBEEF)), fixture.confirmedTracker)).resolves.toEqual( + [] + ) + certFalse.mockRestore() + }) + + it('fails closed when the tracker throws or returns a non-boolean verdict', async () => { + const throwingTracker = { + currentHeight: async () => IDENTITY_VERIFICATION_CONFIRMED_HEIGHT + 101, + isValidRootForHeight: async () => { + throw new Error('tracker unavailable') + } + } + const nonBooleanTracker = { + currentHeight: async () => IDENTITY_VERIFICATION_CONFIRMED_HEIGHT + 101, + isValidRootForHeight: async () => 'true' + } + + await expect(parseResults(answer(outputFor(fixture.certificateBEEF)), throwingTracker)).resolves.toEqual([]) + await expect(parseResults(answer(outputFor(fixture.certificateBEEF)), nonBooleanTracker as never)).resolves.toEqual( + [] + ) + }) + + it('fails closed without a tracker and does not query an overlay resolver without one', async () => { + const resolver = { query: jest.fn() } as unknown as LookupResolver + + await expect(parseResults(answer(outputFor(fixture.certificateBEEF)))).resolves.toEqual([]) + await expect(queryOverlay({ identityKey: fixture.certificate.subject }, resolver)).resolves.toEqual([]) + expect(resolver.query).not.toHaveBeenCalled() + }) + + it('uses the same verification path for progressive parsing and preserves a later valid candidate', async () => { + const invalidProof = Transaction.fromBEEF(fixture.certificateBEEF) + invalidProof.merklePath = new MerklePath(IDENTITY_VERIFICATION_CONFIRMED_HEIGHT, [ + [ + { offset: 0, hash: '43'.repeat(32) }, + { offset: 1, hash: invalidProof.id('hex'), txid: true } + ] + ]) + const candidates = answer(outputFor(invalidProof.toBEEF()), outputFor(fixture.certificateBEEF)) + + await expect(collect(parseResults$(candidates, fixture.confirmedTracker))).resolves.toHaveLength(1) + await expect(parseResults(candidates, fixture.confirmedTracker)).resolves.toHaveLength(1) + }) + + it('snapshots untrusted output bytes and index before the tracker can yield', async () => { + let releaseVerification: (() => void) | undefined + const gate = new Promise(resolve => { + releaseVerification = resolve + }) + const tracker = { + currentHeight: async () => IDENTITY_VERIFICATION_CONFIRMED_HEIGHT + 101, + isValidRootForHeight: async (root: string, height: number) => { + await gate + return height === IDENTITY_VERIFICATION_CONFIRMED_HEIGHT && fixture.confirmedTracker.roots.has(root) + } + } + const evidence = outputFor(fixture.certificateBEEF.slice()) + const verification = verifyOverlayOutput(evidence, tracker) + evidence.outputIndex = 1 + evidence.beef.fill(0) + releaseVerification!() + + await expect(verification).resolves.toMatchObject({ + txid: fixture.certificateTransaction.id('hex'), + outputIndex: 0 + }) + }) +}) diff --git a/packages/wallet/wallet-toolbox/src/utility/identityUtils.ts b/packages/wallet/wallet-toolbox/src/utility/identityUtils.ts index 7518ccd22..dd6cf00e3 100644 --- a/packages/wallet/wallet-toolbox/src/utility/identityUtils.ts +++ b/packages/wallet/wallet-toolbox/src/utility/identityUtils.ts @@ -1,6 +1,5 @@ import { LookupAnswer, - Transaction, PushDrop, VerifiableCertificate, Utils, @@ -9,9 +8,11 @@ import { DiscoverCertificatesResult, IdentityCertificate, IdentityCertifier, - Base64String + Base64String, + ChainTracker } from '@bsv/sdk' import { Certifier, TrustSettings } from '../WalletSettingsManager' +import { OverlayOutputEvidence, verifyOverlayOutput } from './verifyOverlayOutput' // Our extended certificate includes certifierInfo. export interface ExtendedVerifiableCertificate extends IdentityCertificate { @@ -98,18 +99,41 @@ export const transformVerifiableCertificatesWithTrust = ( /** * Performs an identity overlay service lookup query and returns the parsed results. + * Requires an independently maintained ChainTracker; missing context returns no identities. * * Identity paths benefit from a larger grace window (more hosts contribute outputs before the * query resolves) — 300 ms is well under the "instant" perception threshold and catches the long * tail of healthy-but-slightly-slow hosts. */ -export const queryOverlay = async (query: unknown, resolver: LookupResolver): Promise => { - const results = await resolver.query({ - service: 'ls_identity', - query - }, undefined, { graceMs: 300 }) +export const queryOverlay = async ( + query: unknown, + resolver: LookupResolver, + chainTracker?: ChainTracker +): Promise => { + if (chainTracker == null) return [] + return await parseResults(await queryOverlayEvidence(query, resolver), chainTracker) +} - return await parseResults(results) +/** Fetch an owned snapshot of UNTRUSTED evidence, suitable only for revalidation. */ +export const queryOverlayEvidence = async (query: unknown, resolver: LookupResolver): Promise => { + const results = await resolver.query( + { + service: 'ls_identity', + query + }, + undefined, + { graceMs: 300 } + ) + + if (results.type !== 'output-list') return results + return { + type: 'output-list', + outputs: results.outputs.map(output => ({ + ...output, + beef: output.beef.slice(), + ...(output.context === undefined ? {} : { context: output.context.slice() }) + })) + } } /** @@ -129,7 +153,7 @@ const isUiRuntime = (): boolean => { } const yieldToUi = async (): Promise => { - await new Promise((resolve) => setTimeout(resolve, 0)) + await new Promise(resolve => setTimeout(resolve, 0)) } /** @@ -137,11 +161,12 @@ const yieldToUi = async (): Promise => { * parse / decrypt / verify failure so a malformed entry can never block the others. */ const parseOne = async ( - output: { beef: number[], outputIndex: number, context?: number[] } + output: OverlayOutputEvidence, + chainTracker: ChainTracker ): Promise => { try { - const tx = Transaction.fromBEEF(output.beef) - const decodedOutput = PushDrop.decode(tx.outputs[output.outputIndex].lockingScript) + const verifiedOutput = await verifyOverlayOutput(output, chainTracker) + const decodedOutput = PushDrop.decode(verifiedOutput.lockingScript) const certificate: VerifiableCertificate = JSON.parse(Utils.toUTF8(decodedOutput.fields[0])) const verifiableCert = new VerifiableCertificate( certificate.type, @@ -153,29 +178,48 @@ const parseOne = async ( certificate.keyring, certificate.signature ) - const decryptedFields = await verifiableCert.decryptFields(new ProtoWallet('anyone')) - await verifiableCert.verify() + // IdentityClient.publiclyRevealAttributes and tm_identity use the subject's + // BRC-42 identity key to sign the certificate/keyring fields in this output. + const anyoneWallet = new ProtoWallet('anyone') + const signature = decodedOutput.fields.pop() + if (decodedOutput.fields.length === 0 || signature == null) return null + const { valid } = await anyoneWallet.verifySignature({ + data: decodedOutput.fields.flat(), + signature, + counterparty: verifiableCert.subject, + protocolID: [1, 'identity'], + keyID: '1' + }) + if (valid !== true) return null + if ((await verifiableCert.verify()) !== true) return null + const decryptedFields = await verifiableCert.decryptFields(anyoneWallet) + if (Object.keys(decryptedFields).length === 0) return null verifiableCert.decryptedFields = decryptedFields return verifiableCert - } catch (error) { - console.error(error) + } catch { + // Untrusted parsing/decryption errors can contain identity data. Do not log it. return null } } /** * Parse the returned UTXOs, decrypting and verifying each certificate. + * An omitted ChainTracker fails closed. Each call revalidates transaction evidence; + * returned certificates carry no reusable chain-verdict or unspentness guarantee. * * On UI runtimes (browser / React Native), yields between iterations so the JS thread does not * own the frame for the full duration. On Node, runs straight through. */ -export const parseResults = async (lookupResult: LookupAnswer): Promise => { - if (lookupResult.type !== 'output-list') return [] +export const parseResults = async ( + lookupResult: LookupAnswer, + chainTracker?: ChainTracker +): Promise => { + if (lookupResult.type !== 'output-list' || chainTracker == null) return [] const parsedResults: VerifiableCertificate[] = [] const shouldYield = isUiRuntime() for (const output of lookupResult.outputs) { if (shouldYield) await yieldToUi() - const cert = await parseOne(output) + const cert = await parseOne(output, chainTracker) if (cert != null) parsedResults.push(cert) } return parsedResults @@ -185,12 +229,15 @@ export const parseResults = async (lookupResult: LookupAnswer): Promise { - if (lookupResult.type !== 'output-list') return +export async function* parseResults$( + lookupResult: LookupAnswer, + chainTracker?: ChainTracker +): AsyncIterable { + if (lookupResult.type !== 'output-list' || chainTracker == null) return const shouldYield = isUiRuntime() for (const output of lookupResult.outputs) { if (shouldYield) await yieldToUi() - const cert = await parseOne(output) + const cert = await parseOne(output, chainTracker) if (cert != null) yield cert } } diff --git a/packages/wallet/wallet-toolbox/src/utility/verifyOverlayOutput.ts b/packages/wallet/wallet-toolbox/src/utility/verifyOverlayOutput.ts new file mode 100644 index 000000000..ea2cac8be --- /dev/null +++ b/packages/wallet/wallet-toolbox/src/utility/verifyOverlayOutput.ts @@ -0,0 +1,79 @@ +import { ChainTracker, LockingScript, Transaction } from '@bsv/sdk' + +/** Untrusted lookup evidence. A host's txid and context are never chain authority. */ +export interface OverlayOutputEvidence { + beef: number[] + outputIndex: number + txid?: string + context?: number[] +} + +/** A per-call verdict, for service validation now; not a reusable chain/cache verdict. */ +export interface VerifiedOverlayOutput { + readonly txid: string + readonly outputIndex: number + readonly lockingScript: LockingScript +} + +/** + * Independently verify a byte-bound output using the caller's canonical chain source. + * No network defaults, scripts-only mode, verdict cache, or identity semantics live here. + * Errors and false verdicts reject this evidence candidate, not its txid permanently. + */ +export async function verifyOverlayOutput( + evidence: OverlayOutputEvidence, + chainTracker: ChainTracker +): Promise { + if (chainTracker == null || typeof chainTracker.isValidRootForHeight !== 'function') { + throw new Error('Overlay verification requires a ChainTracker') + } + const { outputIndex, txid: hint } = evidence + if (!Number.isSafeInteger(outputIndex) || outputIndex < 0) { + throw new Error('Invalid overlay output index') + } + // Own the bytes before the first await: callers cannot change the candidate in flight. + const bytes = evidence.beef.slice() + if (bytes.some(byte => !Number.isInteger(byte) || byte < 0 || byte > 255)) { + throw new Error('Invalid overlay transaction bytes') + } + const tx = Transaction.fromBEEF(bytes) + const txid = tx.id('hex') + if (hint !== undefined && (typeof hint !== 'string' || hint.toLowerCase() !== txid)) { + throw new Error('Overlay txid does not match transaction bytes') + } + if (outputIndex >= tx.outputs.length) throw new Error('Overlay output does not exist') + + // SDK graph verification checks scripts and values. Also require every unconfirmed + // branch to have inputs, so a fabricated zero-value, input-free leaf cannot anchor it. + const pending = [tx] + const visited = new Set() + const spentOutpoints = new Set() + while (pending.length > 0) { + const current = pending.pop()! + const currentTxid = current.id('hex') + if (visited.has(currentTxid)) continue + visited.add(currentTxid) + if (current.merklePath != null) continue + if (current.inputs.length === 0) throw new Error('Unconfirmed transaction has no ancestry') + for (const input of current.inputs) { + if (input.sourceTransaction == null) throw new Error('Overlay transaction ancestry is missing') + const sourceTxid = input.sourceTransaction.id('hex') + if (input.sourceTXID !== undefined && input.sourceTXID !== sourceTxid) { + throw new Error('Overlay input does not match its source transaction') + } + const outpoint = `${sourceTxid}.${input.sourceOutputIndex}` + // Script verification alone can count the same value twice. Reject both + // duplicate inputs and conflicting spends among unconfirmed ancestors. + if (spentOutpoints.has(outpoint)) throw new Error('Overlay transaction graph spends an outpoint twice') + spentOutpoints.add(outpoint) + pending.push(input.sourceTransaction) + } + } + + const canonicalTracker: ChainTracker = { + currentHeight: async () => await chainTracker.currentHeight(), + isValidRootForHeight: async (root, height) => (await chainTracker.isValidRootForHeight(root, height)) === true + } + if ((await tx.verify(canonicalTracker)) !== true) throw new Error('Overlay transaction verification failed') + return { txid, outputIndex, lockingScript: tx.outputs[outputIndex].lockingScript } +} From cbccb6cff94fe99ac0d957843807fbe7ad22ff1c Mon Sep 17 00:00:00 2001 From: Deggen Date: Tue, 8 Sep 2026 14:48:42 -0500 Subject: [PATCH 02/58] feat(sdk): add dynamic overlay lookup discovery and bounded host scheduling Subscribe to SLAP tracker updates during an active query so later advertised hosts can join a fair, concurrency-bounded queue. Read lookup bodies incrementally with honest resource-limit accounting, keep trackers as routing sources, and leave raw query$ unverified while exposing the C02 onEvidence intake seam. Preserves 2s/5s delays, reputation/backoff, query/query$/freeform APIs, and existing CORS/public lookup request headers. --- packages/sdk/CHANGELOG.md | 6 + packages/sdk/docs/reference/overlay-tools.md | 851 ++++++++++++++- .../__tests__/BRC100OverlayTransport.test.ts | 43 +- .../sdk/src/overlay-tools/LookupDiscovery.ts | 101 ++ .../sdk/src/overlay-tools/LookupHostQueue.ts | 74 ++ .../sdk/src/overlay-tools/LookupResolver.ts | 994 +++++++++--------- .../sdk/src/overlay-tools/LookupResources.ts | 83 ++ .../src/overlay-tools/LookupResponseReader.ts | 189 ++++ .../__tests/LookupResolver.additional.test.ts | 55 +- .../__tests/LookupResolver.dynamic.test.ts | 841 +++++++++++++++ .../__tests/LookupResolver.http.test.ts | 272 +++++ .../__tests/LookupResolver.test.ts | 198 ++-- .../__tests/LookupResponseReader.test.ts | 243 +++++ 13 files changed, 3275 insertions(+), 675 deletions(-) create mode 100644 packages/sdk/src/overlay-tools/LookupDiscovery.ts create mode 100644 packages/sdk/src/overlay-tools/LookupHostQueue.ts create mode 100644 packages/sdk/src/overlay-tools/LookupResources.ts create mode 100644 packages/sdk/src/overlay-tools/LookupResponseReader.ts create mode 100644 packages/sdk/src/overlay-tools/__tests/LookupResolver.dynamic.test.ts create mode 100644 packages/sdk/src/overlay-tools/__tests/LookupResolver.http.test.ts create mode 100644 packages/sdk/src/overlay-tools/__tests/LookupResponseReader.test.ts diff --git a/packages/sdk/CHANGELOG.md b/packages/sdk/CHANGELOG.md index d6b61d775..4f913e481 100644 --- a/packages/sdk/CHANGELOG.md +++ b/packages/sdk/CHANGELOG.md @@ -245,6 +245,12 @@ All notable changes to this project will be documented in this file. The format - Add `LookupResolver.queryDetailed()` and per-outcome host-settlement counts so security-sensitive callers can distinguish authoritative empty answers from partial availability. +- Add bounded LookupResolver discovery: later SLAP tracker advertisements can + join an active query, hosts are scheduled fairly under concurrency and byte + limits, and HTTP bodies are read incrementally. Raw `query$` output remains + unverified; `onEvidence` is the C02 intake seam. Existing 2s host / 5s + tracker delays, reputation/backoff, freeform answers, and CORS/public + lookup request headers are unchanged. ### Changed diff --git a/packages/sdk/docs/reference/overlay-tools.md b/packages/sdk/docs/reference/overlay-tools.md index ec167e2bb..7e7608f7a 100644 --- a/packages/sdk/docs/reference/overlay-tools.md +++ b/packages/sdk/docs/reference/overlay-tools.md @@ -4,16 +4,17 @@ Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes), [Functions]( ## Interfaces -| | -| --- | -| [AdmittanceInstructions](#interface-admittanceinstructions) | -| [LookupQuestion](#interface-lookupquestion) | -| [LookupResolverConfig](#interface-lookupresolverconfig) | -| [OverlayBroadcastFacilitator](#interface-overlaybroadcastfacilitator) | -| [OverlayLookupFacilitator](#interface-overlaylookupfacilitator) | -| [RankedHost](#interface-rankedhost) | -| [SHIPBroadcasterConfig](#interface-shipbroadcasterconfig) | -| [TaggedBEEF](#interface-taggedbeef) | +| | | +| --- | --- | +| [AdmittanceInstructions](#interface-admittanceinstructions) | [LookupResolverConfig](#interface-lookupresolverconfig) | +| [LookupAnswerProgress](#interface-lookupanswerprogress) | [LookupResponseReaderOptions](#interface-lookupresponsereaderoptions) | +| [LookupDiscoveryUpdate](#interface-lookupdiscoveryupdate) | [OverlayBroadcastFacilitator](#interface-overlaybroadcastfacilitator) | +| [LookupFreeformAnswer](#interface-lookupfreeformanswer) | [OverlayLookupFacilitator](#interface-overlaylookupfacilitator) | +| [LookupLimits](#interface-lookuplimits) | [RankedHost](#interface-rankedhost) | +| [LookupQueryOptions](#interface-lookupqueryoptions) | [SHIPBroadcasterConfig](#interface-shipbroadcasterconfig) | +| [LookupQuestion](#interface-lookupquestion) | [TaggedBEEF](#interface-taggedbeef) | +| [LookupRequestOptions](#interface-lookuprequestoptions) | [UnreachableHostInfo](#interface-unreachablehostinfo) | +| [LookupResolution](#interface-lookupresolution) | | Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes), [Functions](#functions), [Types](#types), [Enums](#enums), [Variables](#variables) @@ -58,6 +59,352 @@ outputsToAdmit: number[] Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes), [Functions](#functions), [Types](#types), [Enums](#enums), [Variables](#variables) +--- +### Interface: LookupAnswerProgress + +```ts +export interface LookupAnswerProgress { + discoveryComplete?: boolean; + terminalReason?: "settled" | "deadline" | "cancelled" | "resource-limit"; + discoveredHosts?: number; + skippedHosts?: number; + receivedBytes?: number; + retainedBytes?: number; + evidenceBytes?: number; + trackersTotal?: number; + trackersCompleted?: number; + trackersFailed?: number; + limitsHit?: string[]; + type: "output-list"; + outputs: Array<{ + beef: number[]; + outputIndex: number; + context?: number[]; + txid?: string; + }>; + txIds: string[]; + isFinal: boolean; + hostCount: number; + completedHosts: number; + successfulHosts: number; + emptyHosts: number; + failedHosts: number; + rejectedHosts: number; + freeformHosts: number; + correlationId?: string; +} +``` + +#### Property completedHosts + +Number of hosts that have settled (success / fail / timeout). + +```ts +completedHosts: number +``` + +#### Property correlationId + +Correlation id used for privacy-safe distributed diagnostics. + +```ts +correlationId?: string +``` + +#### Property discoveryComplete + +Transport coverage only, never cryptographic validity or global absence. + +```ts +discoveryComplete?: boolean +``` + +#### Property emptyHosts + +Successful hosts whose output list was empty. + +```ts +emptyHosts: number +``` + +#### Property evidenceBytes + +Receipt-copy octets handed to onEvidence, independently bounded. + +```ts +evidenceBytes?: number +``` + +#### Property failedHosts + +Hosts that failed due to availability, timeout, or malformed responses. + +```ts +failedHosts: number +``` + +#### Property freeformHosts + +Hosts that returned a valid but non-aggregatable freeform response. + +```ts +freeformHosts: number +``` + +#### Property hostCount + +Number of ranked hosts that were queried. + +```ts +hostCount: number +``` + +#### Property isFinal + +True only for the final emission, after every in-flight host has settled. + +```ts +isFinal: boolean +``` + +#### Property rejectedHosts + +Hosts that rejected this query semantically (for example, HTTP 400). + +```ts +rejectedHosts: number +``` + +#### Property retainedBytes + +Retained decoded BEEF/context octets; JavaScript arrays have additional heap overhead. + +```ts +retainedBytes?: number +``` + +#### Property successfulHosts + +Hosts that returned a structurally valid output-list response. + +```ts +successfulHosts: number +``` + +#### Property txIds + +Parallel array of resolved tx ids for each output (same index as `outputs`). + +```ts +txIds: string[] +``` + +Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes), [Functions](#functions), [Types](#types), [Enums](#enums), [Variables](#variables) + +--- +### Interface: LookupDiscoveryUpdate + +```ts +export interface LookupDiscoveryUpdate { + sources: Map; + trackersTotal: number; + trackersCompleted: number; + trackersFailed: number; + skippedHosts: number; + receivedBytes: number; + limitsHit: Set; + done: boolean; +} +``` + +Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes), [Functions](#functions), [Types](#types), [Enums](#enums), [Variables](#variables) + +--- +### Interface: LookupFreeformAnswer + +A valid non-aggregatable response returned by a lookup service. + +```ts +export interface LookupFreeformAnswer { + type: "freeform"; + result: unknown; +} +``` + +Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes), [Functions](#functions), [Types](#types), [Enums](#enums), [Variables](#variables) + +--- +### Interface: LookupLimits + +Operational client limits, not BEEF validity or service authority rules. + +```ts +export interface LookupLimits { + maxHosts: number; + maxHostsPerTracker: number; + maxTrackers: number; + hostConcurrency: number; + trackerConcurrency: number; + maxResponseBytes: number; + maxTotalBytes: number; + maxOutputs: number; + maxEvidenceOutputs: number; + maxEvidenceBytes: number; +} +``` + +Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes), [Functions](#functions), [Types](#types), [Enums](#enums), [Variables](#variables) + +--- +### Interface: LookupQueryOptions + +```ts +export interface LookupQueryOptions { + signal?: AbortSignal; + evidenceLimits?: { + maxOutputs?: number; + maxBytes?: number; + }; + deadlineMs?: number; + limits?: Partial; + onEvidence?: (event: LookupEvidenceEvent) => void | Promise; + graceMs?: number; + softTimeoutMs?: number; + onUnreachableHost?: (info: UnreachableHostInfo) => void | Promise; + unreachableHostNotificationCooldownMs?: number; + holdForUnknownHosts?: boolean; + waitForAllHosts?: boolean; + correlationId?: string; +} +``` + +See also: [LookupEvidenceEvent](./overlay-tools.md#type-lookupevidenceevent), [LookupLimits](./overlay-tools.md#interface-lookuplimits), [UnreachableHostInfo](./overlay-tools.md#interface-unreachablehostinfo) + +#### Property correlationId + +Correlates resolver and downstream wallet telemetry without logging the query payload. + +```ts +correlationId?: string +``` + +#### Property deadlineMs + +Whole attempt budget including discovery and queued hosts. Default 10000 ms. + +```ts +deadlineMs?: number +``` + +#### Property evidenceLimits + +Callback intake budget, independent of legacy aggregation. Defaults to 512 +outputs / 16 MiB of BEEF and context bytes. Values must be positive safe +integers. Coordinate these with a downstream verifier's admission limits. + +```ts +evidenceLimits?: { + maxOutputs?: number; + maxBytes?: number; +} +``` + +#### Property graceMs + +Override the grace window (ms) between the first valid response and the resolution of the query. +Late responders arriving within this window are merged into the result. Default 80 ms. +Raise for identity-style paths (e.g. ~300 ms) where divergence between hosts matters. + +```ts +graceMs?: number +``` + +#### Property holdForUnknownHosts + +Compatibility alias for `waitForAllHosts`. Prefer `waitForAllHosts` in new +code. `waitForAllHosts` takes precedence when both are supplied. + +```ts +holdForUnknownHosts?: boolean +``` + +#### Property limits + +Per-query operational resource limits. These do not define evidence validity. + +```ts +limits?: Partial +``` +See also: [LookupLimits](./overlay-tools.md#interface-lookuplimits) + +#### Property onEvidence + +Owned, UNTRUSTED receipts before legacy txid/outpoint deduplication. Enqueue +promptly; callback completion is not awaited and failures are isolated. +Intake stops at the configured evidenceLimits, reporting one limit event. +No callbacks occur after the query iterator closes. Raw `query$` snapshots +remain unverified transport aggregates, not cryptographic proof. + +```ts +onEvidence?: (event: LookupEvidenceEvent) => void | Promise +``` +See also: [LookupEvidenceEvent](./overlay-tools.md#type-lookupevidenceevent) + +#### Property onUnreachableHost + +Fired when a SLAP-advertised host fails (network error, timeout, malformed +response). The resolver itself does not email or escalate — downstream +consumers (e.g. overlay-express) wire this up to the BSVA notification API +to let the originating overlay operator know about a stale advertisement. + +```ts +onUnreachableHost?: (info: UnreachableHostInfo) => void | Promise +``` +See also: [UnreachableHostInfo](./overlay-tools.md#interface-unreachablehostinfo) + +#### Property signal + +Abort this query without cancelling discovery still owned by another query. + +```ts +signal?: AbortSignal +``` + +#### Property softTimeoutMs + +Soft timeout (ms). When set: + - `query()` resolves with whatever has arrived as soon as any host answers, or after this timeout. + - `query$()` emits a (possibly empty) snapshot after this timeout if no host has answered yet, + then continues yielding late-host enrichments until the iterator is broken or final emission. + +```ts +softTimeoutMs?: number +``` + +#### Property unreachableHostNotificationCooldownMs + +Minimum interval between unreachable notifications for the same host and +service. Defaults to 60 seconds to prevent notification storms. Set to 0 +to disable deduplication. + +```ts +unreachableHostNotificationCooldownMs?: number +``` + +#### Property waitForAllHosts + +Wait for every queried host to settle before the first emission. This is +the default for `query()` because generic output cardinality is not proof +of freshness or authority. It defaults to `false` for progressive +`query$()` consumers. `holdForUnknownHosts` remains as a compatibility +alias; `waitForAllHosts` takes precedence when both are supplied. + +```ts +waitForAllHosts?: boolean +``` + +Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes), [Functions](#functions), [Types](#types), [Enums](#enums), [Variables](#variables) + --- ### Interface: LookupQuestion @@ -89,6 +436,37 @@ service: string Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes), [Functions](#functions), [Types](#types), [Enums](#enums), [Variables](#variables) +--- +### Interface: LookupRequestOptions + +Optional bounded transport settings; older custom facilitators may ignore these. + +```ts +export interface LookupRequestOptions { + maxResponseBytes?: number; + maxOutputs?: number; + consumeBytes?: (bytes: number) => void; +} +``` + +Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes), [Functions](#functions), [Types](#types), [Enums](#enums), [Variables](#variables) + +--- +### Interface: LookupResolution + +A lookup answer together with the host settlement evidence behind it. + +```ts +export interface LookupResolution { + answer: LookupAnswer; + progress: LookupAnswerProgress; +} +``` + +See also: [LookupAnswer](./overlay-tools.md#type-lookupanswer), [LookupAnswerProgress](./overlay-tools.md#interface-lookupanswerprogress) + +Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes), [Functions](#functions), [Types](#types), [Enums](#enums), [Variables](#variables) + --- ### Interface: LookupResolverConfig @@ -96,7 +474,8 @@ Configuration options for the Lookup resolver. ```ts export interface LookupResolverConfig { - networkPreset?: "mainnet" | "testnet" | "teratestnet" | "local"; + limits?: Partial; + networkPreset?: LookupNetworkPreset; facilitator?: OverlayLookupFacilitator; slapTrackers?: string[]; hostOverrides?: Record; @@ -106,10 +485,11 @@ export interface LookupResolverConfig { get: (key: string) => string | null | undefined; set: (key: string, value: string) => void; }; + telemetry?: TelemetryConfig; } ``` -See also: [OverlayLookupFacilitator](./overlay-tools.md#interface-overlaylookupfacilitator) +See also: [LookupLimits](./overlay-tools.md#interface-lookuplimits), [LookupNetworkPreset](./overlay-tools.md#type-lookupnetworkpreset), [OverlayLookupFacilitator](./overlay-tools.md#interface-overlaylookupfacilitator) #### Property additionalHosts @@ -144,16 +524,27 @@ Map of lookup service names to arrays of hosts to use in place of resolving via hostOverrides?: Record ``` +#### Property limits + +Defaults for the bounded discovery, scheduler and receipt intake. + +```ts +limits?: Partial +``` +See also: [LookupLimits](./overlay-tools.md#interface-lookuplimits) + #### Property networkPreset The network preset to use, unless other options override it. - mainnet: use mainnet SLAP trackers and HTTPS facilitator - testnet: use testnet SLAP trackers and HTTPS facilitator +- teratestnet: use TerraTestNet SLAP trackers and HTTPS facilitator - local: directly query from localhost:8080 and a facilitator that permits plain HTTP ```ts -networkPreset?: "mainnet" | "testnet" | "teratestnet" | "local" +networkPreset?: LookupNetworkPreset ``` +See also: [LookupNetworkPreset](./overlay-tools.md#type-lookupnetworkpreset) #### Property reputationStorage @@ -174,6 +565,53 @@ The list of SLAP trackers queried to resolve Overlay Services hosts for a given slapTrackers?: string[] ``` +#### Property telemetry + +Optional privacy-bounded telemetry sink. Query payloads are never emitted. + +```ts +telemetry?: TelemetryConfig +``` + +Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes), [Functions](#functions), [Types](#types), [Enums](#enums), [Variables](#variables) + +--- +### Interface: LookupResponseReaderOptions + +Options controlling a bounded lookup response read. + +```ts +export interface LookupResponseReaderOptions { + signal?: AbortSignal; + maxResponseBytes: number; + consumeBytes?: (bytes: number) => void; +} +``` + +#### Property consumeBytes + +Charges accepted bytes to the caller's aggregate response budget. + +```ts +consumeBytes?: (bytes: number) => void +``` + +#### Property maxResponseBytes + +Maximum number of response bytes to retain. + +```ts +maxResponseBytes: number +``` + +#### Property signal + +Cancels a pending stream read when the lookup request is aborted. + +```ts +signal?: AbortSignal +``` + Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes), [Functions](#functions), [Types](#types), [Enums](#enums), [Variables](#variables) --- @@ -198,20 +636,20 @@ Facilitates lookups to URLs that return answers. ```ts export interface OverlayLookupFacilitator { - lookup: (url: string, question: LookupQuestion, timeout?: number) => Promise; + lookup: (url: string, question: LookupQuestion, timeout?: number, signal?: AbortSignal, options?: LookupRequestOptions) => Promise; } ``` -See also: [LookupAnswer](./overlay-tools.md#type-lookupanswer), [LookupQuestion](./overlay-tools.md#interface-lookupquestion) +See also: [LookupFacilitatorAnswer](./overlay-tools.md#type-lookupfacilitatoranswer), [LookupQuestion](./overlay-tools.md#interface-lookupquestion), [LookupRequestOptions](./overlay-tools.md#interface-lookuprequestoptions) #### Property lookup Returns a lookup answer for a lookup question ```ts -lookup: (url: string, question: LookupQuestion, timeout?: number) => Promise +lookup: (url: string, question: LookupQuestion, timeout?: number, signal?: AbortSignal, options?: LookupRequestOptions) => Promise ``` -See also: [LookupAnswer](./overlay-tools.md#type-lookupanswer), [LookupQuestion](./overlay-tools.md#interface-lookupquestion) +See also: [LookupFacilitatorAnswer](./overlay-tools.md#type-lookupfacilitatoranswer), [LookupQuestion](./overlay-tools.md#interface-lookupquestion), [LookupRequestOptions](./overlay-tools.md#interface-lookuprequestoptions) Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes), [Functions](#functions), [Types](#types), [Enums](#enums), [Variables](#variables) @@ -233,16 +671,16 @@ Configuration options for the SHIP broadcaster. ```ts export interface SHIPBroadcasterConfig { - networkPreset?: "mainnet" | "testnet" | "teratestnet" | "local"; + networkPreset?: LookupNetworkPreset; facilitator?: OverlayBroadcastFacilitator; resolver?: LookupResolver; - requireAcknowledgmentFromAllHostsForTopics?: "all" | "any" | string[]; - requireAcknowledgmentFromAnyHostForTopics?: "all" | "any" | string[]; - requireAcknowledgmentFromSpecificHostsForTopics?: Record; + requireAcknowledgmentFromAllHostsForTopics?: TopicAcknowledgmentRequirement; + requireAcknowledgmentFromAnyHostForTopics?: TopicAcknowledgmentRequirement; + requireAcknowledgmentFromSpecificHostsForTopics?: Record; } ``` -See also: [LookupResolver](./overlay-tools.md#class-lookupresolver), [OverlayBroadcastFacilitator](./overlay-tools.md#interface-overlaybroadcastfacilitator) +See also: [LookupNetworkPreset](./overlay-tools.md#type-lookupnetworkpreset), [LookupResolver](./overlay-tools.md#class-lookupresolver), [OverlayBroadcastFacilitator](./overlay-tools.md#interface-overlaybroadcastfacilitator), [TopicAcknowledgmentRequirement](./overlay-tools.md#type-topicacknowledgmentrequirement) #### Property facilitator @@ -258,35 +696,40 @@ See also: [OverlayBroadcastFacilitator](./overlay-tools.md#interface-overlaybroa The network preset to use, unless other options override it. - mainnet: use mainnet resolver and HTTPS facilitator - testnet: use testnet resolver and HTTPS facilitator +- teratestnet: use TerraTestNet resolver and HTTPS facilitator - local: directly send to localhost:8080 and a facilitator that permits plain HTTP ```ts -networkPreset?: "mainnet" | "testnet" | "teratestnet" | "local" +networkPreset?: LookupNetworkPreset ``` +See also: [LookupNetworkPreset](./overlay-tools.md#type-lookupnetworkpreset) #### Property requireAcknowledgmentFromAllHostsForTopics Determines which topics (all, any, or a specific list) must be present within all STEAKs received from every host for the broadcast to be considered a success. By default, all hosts must acknowledge all topics. ```ts -requireAcknowledgmentFromAllHostsForTopics?: "all" | "any" | string[] +requireAcknowledgmentFromAllHostsForTopics?: TopicAcknowledgmentRequirement ``` +See also: [TopicAcknowledgmentRequirement](./overlay-tools.md#type-topicacknowledgmentrequirement) #### Property requireAcknowledgmentFromAnyHostForTopics Determines which topics (all, any, or a specific list) must be present within STEAK received from at least one host for the broadcast to be considered a success. ```ts -requireAcknowledgmentFromAnyHostForTopics?: "all" | "any" | string[] +requireAcknowledgmentFromAnyHostForTopics?: TopicAcknowledgmentRequirement ``` +See also: [TopicAcknowledgmentRequirement](./overlay-tools.md#type-topicacknowledgmentrequirement) #### Property requireAcknowledgmentFromSpecificHostsForTopics Determines a mapping whose keys are specific hosts and whose values are the topics (all, any, or a specific list) that must be present within the STEAK received by the given hosts, in order for the broadcast to be considered a success. ```ts -requireAcknowledgmentFromSpecificHostsForTopics?: Record +requireAcknowledgmentFromSpecificHostsForTopics?: Record ``` +See also: [TopicAcknowledgmentRequirement](./overlay-tools.md#type-topicacknowledgmentrequirement) #### Property resolver @@ -314,6 +757,54 @@ export interface TaggedBEEF { Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes), [Functions](#functions), [Types](#types), [Enums](#enums), [Variables](#variables) +--- +### Interface: UnreachableHostInfo + +Info supplied to onUnreachableHost callbacks. + +```ts +export interface UnreachableHostInfo { + host: string; + service: string; + error: string; + advertisedBy?: string; +} +``` + +#### Property advertisedBy + +SLAP tracker URL that advertised this host, if known. + +```ts +advertisedBy?: string +``` + +#### Property error + +Error message from the facilitator. + +```ts +error: string +``` + +#### Property host + +Host URL that failed. + +```ts +host: string +``` + +#### Property service + +Lookup service that was being queried when the failure occurred. + +```ts +service: string +``` + +Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes), [Functions](#functions), [Types](#types), [Enums](#enums), [Variables](#variables) + --- ## Classes @@ -322,7 +813,11 @@ Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes), [Functions]( | [HTTPSOverlayBroadcastFacilitator](#class-httpsoverlaybroadcastfacilitator) | | [HTTPSOverlayLookupFacilitator](#class-httpsoverlaylookupfacilitator) | | [HostReputationTracker](#class-hostreputationtracker) | +| [LookupDiscovery](#class-lookupdiscovery) | +| [LookupHTTPError](#class-lookuphttperror) | +| [LookupHostQueue](#class-lookuphostqueue) | | [LookupResolver](#class-lookupresolver) | +| [LookupResourceLimitError](#class-lookupresourcelimiterror) | | [OverlayAdminTokenTemplate](#class-overlayadmintokentemplate) | | [TopicBroadcaster](#class-topicbroadcaster) | @@ -353,11 +848,11 @@ export class HTTPSOverlayLookupFacilitator implements OverlayLookupFacilitator { fetchClient: typeof fetch; allowHTTP: boolean; constructor(httpClient = defaultFetch, allowHTTP: boolean = false) - async lookup(url: string, question: LookupQuestion, timeout: number = 5000): Promise + async lookup(url: string, question: LookupQuestion, timeout: number = 2000, signal?: AbortSignal, options?: LookupRequestOptions): Promise } ``` -See also: [LookupAnswer](./overlay-tools.md#type-lookupanswer), [LookupQuestion](./overlay-tools.md#interface-lookupquestion), [OverlayLookupFacilitator](./overlay-tools.md#interface-overlaylookupfacilitator) +See also: [LookupFacilitatorAnswer](./overlay-tools.md#type-lookupfacilitatoranswer), [LookupQuestion](./overlay-tools.md#interface-lookupquestion), [LookupRequestOptions](./overlay-tools.md#interface-lookuprequestoptions), [OverlayLookupFacilitator](./overlay-tools.md#interface-overlaylookupfacilitator) Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes), [Functions](#functions), [Types](#types), [Enums](#enums), [Variables](#variables) @@ -372,11 +867,72 @@ export class HostReputationTracker { recordFailure(host: string, reason?: unknown): void rankHosts(hosts: string[], now: number = Date.now()): RankedHost[] snapshot(host: string): HostReputationEntry | undefined + flush(): void } ``` See also: [RankedHost](./overlay-tools.md#interface-rankedhost) +#### Method flush + +Flushes a pending debounced persistence write immediately. + +```ts +flush(): void +``` + +Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes), [Functions](#functions), [Types](#types), [Enums](#enums), [Variables](#variables) + +--- +### Class: LookupDiscovery + +One bounded refresh shared only by subscribers of the same resolver/configuration. + +```ts +export class LookupDiscovery { + readonly controller = new AbortController(); + readonly state: LookupDiscoveryUpdate; + constructor(private readonly trackers: string[], private readonly limits: LookupLimits, private readonly lookup: (tracker: string, signal: AbortSignal, consume: (bytes: number) => void) => Promise, private readonly finish: (state: LookupDiscoveryUpdate, abandoned: boolean) => void) + subscribe(listener: (state: LookupDiscoveryUpdate) => void): () => void +} +``` + +See also: [LookupDiscoveryUpdate](./overlay-tools.md#interface-lookupdiscoveryupdate), [LookupLimits](./overlay-tools.md#interface-lookuplimits) + +Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes), [Functions](#functions), [Types](#types), [Enums](#enums), [Variables](#variables) + +--- +### Class: LookupHTTPError + +An HTTP failure with enough classification for reputation handling. + +```ts +export class LookupHTTPError extends Error { + readonly status: number; + readonly kind: LookupHTTPErrorKind; + constructor(status: number, kind: LookupHTTPErrorKind, statusText?: string) +} +``` + +See also: [LookupHTTPErrorKind](./overlay-tools.md#type-lookuphttperrorkind) + +Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes), [Functions](#functions), [Types](#types), [Enums](#enums), [Variables](#variables) + +--- +### Class: LookupHostQueue + +A bounded FIFO within each source, round-robin between sources. + +```ts +export class LookupHostQueue { + readonly done = new Promise(resolve => { this.resolveDone = resolve; }); + constructor(private readonly maxHosts: number, private readonly concurrency: number, private readonly run: (host: string) => Promise, private readonly skipped: (count: number, limited: boolean) => void) + add(source: string, hosts: string[]): void + finishSources(): void + cancel(): void +} +``` + Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes), [Functions](#functions), [Types](#types), [Enums](#enums), [Variables](#variables) --- @@ -387,20 +943,59 @@ Represents a Lookup Resolver. ```ts export default class LookupResolver { constructor(config: LookupResolverConfig = {}) - async query(question: LookupQuestion, timeout?: number): Promise + async query(question: LookupQuestion, timeout?: number, options?: LookupQueryOptions): Promise + async queryDetailed(question: LookupQuestion, timeout?: number, options?: LookupQueryOptions): Promise + query$(question: LookupQuestion, timeout?: number, options?: LookupQueryOptions): AsyncIterable } ``` -See also: [LookupAnswer](./overlay-tools.md#type-lookupanswer), [LookupQuestion](./overlay-tools.md#interface-lookupquestion), [LookupResolverConfig](./overlay-tools.md#interface-lookupresolverconfig) +See also: [LookupAnswer](./overlay-tools.md#type-lookupanswer), [LookupAnswerProgress](./overlay-tools.md#interface-lookupanswerprogress), [LookupQueryOptions](./overlay-tools.md#interface-lookupqueryoptions), [LookupQuestion](./overlay-tools.md#interface-lookupquestion), [LookupResolution](./overlay-tools.md#interface-lookupresolution), [LookupResolverConfig](./overlay-tools.md#interface-lookupresolverconfig) #### Method query Given a LookupQuestion, returns a LookupAnswer. Aggregates across multiple services and supports resiliency. +Optional `options.graceMs` overrides the per-call grace window (default 80 ms). +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`). + +```ts +async query(question: LookupQuestion, timeout?: number, options?: LookupQueryOptions): Promise +``` +See also: [LookupAnswer](./overlay-tools.md#type-lookupanswer), [LookupQueryOptions](./overlay-tools.md#interface-lookupqueryoptions), [LookupQuestion](./overlay-tools.md#interface-lookupquestion) + +#### Method query$ + +Cumulative unverified results. Discovery remains subscribed while trackers +settle; each new host enters the bounded queue immediately. Caller abort, +deadline and iterator close release this query's ownership. + ```ts -async query(question: LookupQuestion, timeout?: number): Promise +query$(question: LookupQuestion, timeout?: number, options?: LookupQueryOptions): AsyncIterable +``` +See also: [LookupAnswerProgress](./overlay-tools.md#interface-lookupanswerprogress), [LookupQueryOptions](./overlay-tools.md#interface-lookupqueryoptions), [LookupQuestion](./overlay-tools.md#interface-lookupquestion) + +#### Method queryDetailed + +Performs a lookup and returns both its answer and the host settlement +evidence required by security-sensitive consumers to distinguish an +authoritative empty result from an availability failure. + +```ts +async queryDetailed(question: LookupQuestion, timeout?: number, options?: LookupQueryOptions): Promise +``` +See also: [LookupQueryOptions](./overlay-tools.md#interface-lookupqueryoptions), [LookupQuestion](./overlay-tools.md#interface-lookupquestion), [LookupResolution](./overlay-tools.md#interface-lookupresolution) + +Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes), [Functions](#functions), [Types](#types), [Enums](#enums), [Variables](#variables) + +--- +### Class: LookupResourceLimitError + +```ts +export class LookupResourceLimitError extends Error { + constructor(readonly limit: string) +} ``` -See also: [LookupAnswer](./overlay-tools.md#type-lookupanswer), [LookupQuestion](./overlay-tools.md#interface-lookupquestion) Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes), [Functions](#functions), [Types](#types), [Enums](#enums), [Variables](#variables) @@ -564,6 +1159,66 @@ Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes), [Functions]( --- ## Functions +| | +| --- | +| [lookupAbortError](#function-lookupaborterror) | +| [lookupLimits](#function-lookuplimits) | +| [normalizeLookupHost](#function-normalizelookuphost) | +| [readLookupResponseBytes](#function-readlookupresponsebytes) | +| [withDoubleSpendRetry](#function-withdoublespendretry) | +| [withLookupAbort](#function-withlookupabort) | + +Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes), [Functions](#functions), [Types](#types), [Enums](#enums), [Variables](#variables) + +--- + +### Function: lookupAbortError + +```ts +export function lookupAbortError(): Error +``` + +Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes), [Functions](#functions), [Types](#types), [Enums](#enums), [Variables](#variables) + +--- +### Function: lookupLimits + +```ts +export function lookupLimits(...overrides: Array | undefined>): LookupLimits +``` + +See also: [LookupLimits](./overlay-tools.md#interface-lookuplimits) + +Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes), [Functions](#functions), [Types](#types), [Enums](#enums), [Variables](#variables) + +--- +### Function: normalizeLookupHost + +Preserve distinct paths and ports; remove only a final slash and URL fragments. + +```ts +export function normalizeLookupHost(host: string, allowParameters: boolean = false): string | null +``` + +Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes), [Functions](#functions), [Types](#types), [Enums](#enums), [Variables](#variables) + +--- +### Function: readLookupResponseBytes + +Reads a lookup response incrementally while enforcing a per-response bound. + +This deliberately does not use Response.text(), json(), or arrayBuffer(), +because those APIs buffer the complete body before a limit can be enforced. + +```ts +export async function readLookupResponseBytes(response: Response, options: LookupResponseReaderOptions): Promise +``` + +See also: [LookupResponseReaderOptions](./overlay-tools.md#interface-lookupresponsereaderoptions) + +Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes), [Functions](#functions), [Types](#types), [Enums](#enums), [Variables](#variables) + +--- ### Function: withDoubleSpendRetry Executes an operation with automatic retry logic for double-spend errors. @@ -595,13 +1250,30 @@ If max retries exceeded or non-double-spend error occurs Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes), [Functions](#functions), [Types](#types), [Enums](#enums), [Variables](#variables) +--- +### Function: withLookupAbort + +A non-cooperative transport cannot retain a cancelled waiter. + +```ts +export async function withLookupAbort(work: Promise, signal?: AbortSignal): Promise +``` + +Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes), [Functions](#functions), [Types](#types), [Enums](#enums), [Variables](#variables) + --- ## Types | | | --- | | [LookupAnswer](#type-lookupanswer) | +| [LookupEvidenceEvent](#type-lookupevidenceevent) | +| [LookupFacilitatorAnswer](#type-lookupfacilitatoranswer) | +| [LookupHTTPErrorKind](#type-lookuphttperrorkind) | +| [LookupNetworkPreset](#type-lookupnetworkpreset) | +| [RequireMode](#type-requiremode) | | [STEAK](#type-steak) | +| [TopicAcknowledgmentRequirement](#type-topicacknowledgmentrequirement) | Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes), [Functions](#functions), [Types](#types), [Enums](#enums), [Variables](#variables) @@ -609,8 +1281,7 @@ Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes), [Functions]( ### Type: LookupAnswer -How the Overlay Services Engine responds to a Lookup Question. -It may comprise either an output list or a freeform response from the Lookup Service. +An aggregatable output-list answer returned by the resolver. ```ts export type LookupAnswer = { @@ -619,12 +1290,76 @@ export type LookupAnswer = { beef: number[]; outputIndex: number; context?: number[]; + txid?: string; }>; } ``` Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes), [Functions](#functions), [Types](#types), [Enums](#enums), [Variables](#variables) +--- +### Type: LookupEvidenceEvent + +Additive evidence intake, independent of the legacy aggregated answer. + +```ts +export type LookupEvidenceEvent = { + type: "output"; + host: string; + output: LookupAnswer["outputs"][number]; +} | { + type: "limit"; +} +``` + +See also: [LookupAnswer](./overlay-tools.md#type-lookupanswer) + +Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes), [Functions](#functions), [Types](#types), [Enums](#enums), [Variables](#variables) + +--- +### Type: LookupFacilitatorAnswer + +Responses a facilitator may return before the resolver aggregates them. + +```ts +export type LookupFacilitatorAnswer = LookupAnswer | LookupFreeformAnswer +``` + +See also: [LookupAnswer](./overlay-tools.md#type-lookupanswer), [LookupFreeformAnswer](./overlay-tools.md#interface-lookupfreeformanswer) + +Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes), [Functions](#functions), [Types](#types), [Enums](#enums), [Variables](#variables) + +--- +### Type: LookupHTTPErrorKind + +```ts +export type LookupHTTPErrorKind = "semantic" | "availability" +``` + +Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes), [Functions](#functions), [Types](#types), [Enums](#enums), [Variables](#variables) + +--- +### Type: LookupNetworkPreset + +Public overlay network presets understood by lookup and SHIP routing. + +```ts +export type LookupNetworkPreset = "mainnet" | "testnet" | "teratestnet" | "local" +``` + +Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes), [Functions](#functions), [Types](#types), [Enums](#enums), [Variables](#variables) + +--- +### Type: RequireMode + +The require mode for topic acknowledgment: all topics must be present, or any one suffices. + +```ts +export type RequireMode = "all" | "any" +``` + +Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes), [Functions](#functions), [Types](#types), [Enums](#enums), [Variables](#variables) + --- ### Type: STEAK @@ -638,6 +1373,19 @@ See also: [AdmittanceInstructions](./overlay-tools.md#interface-admittanceinstru Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes), [Functions](#functions), [Types](#types), [Enums](#enums), [Variables](#variables) +--- +### Type: TopicAcknowledgmentRequirement + +Specifies which topics must be acknowledged: all, any, or a specific list. + +```ts +export type TopicAcknowledgmentRequirement = RequireMode | string[] +``` + +See also: [RequireMode](./overlay-tools.md#type-requiremode) + +Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes), [Functions](#functions), [Types](#types), [Enums](#enums), [Variables](#variables) + --- ## Enums @@ -645,14 +1393,38 @@ Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes), [Functions]( | | | --- | +| [DEFAULT_LOOKUP_LIMITS](#variable-default_lookup_limits) | | [DEFAULT_SLAP_TRACKERS](#variable-default_slap_trackers) | | [DEFAULT_TESTNET_SLAP_TRACKERS](#variable-default_testnet_slap_trackers) | +| [DEFAULT_TTN_SLAP_TRACKERS](#variable-default_ttn_slap_trackers) | | [getOverlayHostReputationTracker](#variable-getoverlayhostreputationtracker) | Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes), [Functions](#functions), [Types](#types), [Enums](#enums), [Variables](#variables) --- +### Variable: DEFAULT_LOOKUP_LIMITS + +```ts +DEFAULT_LOOKUP_LIMITS: Readonly = Object.freeze({ + maxHosts: 256, + maxHostsPerTracker: 64, + maxTrackers: 16, + hostConcurrency: 8, + trackerConcurrency: 4, + maxResponseBytes: 32 * 1024 * 1024, + maxTotalBytes: 64 * 1024 * 1024, + maxOutputs: 4096, + maxEvidenceOutputs: 512, + maxEvidenceBytes: 16 * 1024 * 1024 +}) +``` + +See also: [LookupLimits](./overlay-tools.md#interface-lookuplimits) + +Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes), [Functions](#functions), [Types](#types), [Enums](#enums), [Variables](#variables) + +--- ### Variable: DEFAULT_SLAP_TRACKERS ```ts @@ -677,6 +1449,17 @@ DEFAULT_TESTNET_SLAP_TRACKERS: string[] = [ Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes), [Functions](#functions), [Types](#types), [Enums](#enums), [Variables](#variables) +--- +### Variable: DEFAULT_TTN_SLAP_TRACKERS + +```ts +DEFAULT_TTN_SLAP_TRACKERS: string[] = [ + "https://staging-overlay.babbage.systems" +] +``` + +Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes), [Functions](#functions), [Types](#types), [Enums](#enums), [Variables](#variables) + --- ### Variable: getOverlayHostReputationTracker diff --git a/packages/sdk/src/__tests__/BRC100OverlayTransport.test.ts b/packages/sdk/src/__tests__/BRC100OverlayTransport.test.ts index f9f0e1881..9cd8c4511 100644 --- a/packages/sdk/src/__tests__/BRC100OverlayTransport.test.ts +++ b/packages/sdk/src/__tests__/BRC100OverlayTransport.test.ts @@ -2,11 +2,13 @@ import { HTTPSOverlayLookupFacilitator } from '../overlay-tools/LookupResolver' describe('overlay lookup BRC-100 byte compatibility', () => { it('keeps typed query bytes portable across the JSON request boundary', async () => { - const mockFetch = jest.fn().mockResolvedValue({ - ok: true, - headers: { get: () => 'application/json' }, - json: async () => ({ type: 'output-list', outputs: [] }) - }) + const mockFetch = jest + .fn() + .mockResolvedValue( + new Response(JSON.stringify({ type: 'output-list', outputs: [] }), { + headers: { 'content-type': 'application/json' } + }) + ) const facilitator = new HTTPSOverlayLookupFacilitator(mockFetch, true) await facilitator.lookup('http://host', { @@ -21,14 +23,17 @@ describe('overlay lookup BRC-100 byte compatibility', () => { }) it('recovers historical numeric-key BEEF from a JSON response', async () => { - const mockFetch = jest.fn().mockResolvedValue({ - ok: true, - headers: { get: () => 'application/json' }, - json: async () => ({ - type: 'output-list', - outputs: [{ beef: { 0: 1, 1: 2, 2: 255 }, outputIndex: 0 }] - }) - }) + const mockFetch = jest + .fn() + .mockResolvedValue( + new Response( + JSON.stringify({ + type: 'output-list', + outputs: [{ beef: { 0: 1, 1: 2, 2: 255 }, outputIndex: 0 }] + }), + { headers: { 'content-type': 'application/json' } } + ) + ) const facilitator = new HTTPSOverlayLookupFacilitator(mockFetch, true) await expect( @@ -41,11 +46,13 @@ describe('overlay lookup BRC-100 byte compatibility', () => { it('preserves byte-like objects inside freeform lookup results', async () => { const result = { data: { 0: 1, 1: 2 }, tx: {}, payload: { 0: 3 } } - const mockFetch = jest.fn().mockResolvedValue({ - ok: true, - headers: { get: () => 'application/json' }, - json: async () => ({ type: 'freeform', result }) - }) + const mockFetch = jest + .fn() + .mockResolvedValue( + new Response(JSON.stringify({ type: 'freeform', result }), { + headers: { 'content-type': 'application/json' } + }) + ) const facilitator = new HTTPSOverlayLookupFacilitator(mockFetch, true) await expect( diff --git a/packages/sdk/src/overlay-tools/LookupDiscovery.ts b/packages/sdk/src/overlay-tools/LookupDiscovery.ts new file mode 100644 index 000000000..12dbe6641 --- /dev/null +++ b/packages/sdk/src/overlay-tools/LookupDiscovery.ts @@ -0,0 +1,101 @@ +import { LookupLimits, LookupResourceLimitError, normalizeLookupHost } from './LookupResources.js' + +export interface LookupDiscoveryUpdate { + sources: Map + trackersTotal: number + trackersCompleted: number + trackersFailed: number + skippedHosts: number + receivedBytes: number + limitsHit: Set + done: boolean +} + +/** One bounded refresh shared only by subscribers of the same resolver/configuration. */ +export class LookupDiscovery { + readonly controller = new AbortController() + readonly state: LookupDiscoveryUpdate + private readonly listeners = new Set<(state: LookupDiscoveryUpdate) => void>() + private started = false + private abandoned = false + + constructor( + private readonly trackers: string[], + private readonly limits: LookupLimits, + private readonly lookup: (tracker: string, signal: AbortSignal, consume: (bytes: number) => void) => Promise, + private readonly finish: (state: LookupDiscoveryUpdate, abandoned: boolean) => void + ) { + this.state = { + sources: new Map(), trackersTotal: trackers.length, trackersCompleted: 0, + trackersFailed: 0, skippedHosts: 0, receivedBytes: 0, limitsHit: new Set(), done: false + } + } + + subscribe(listener: (state: LookupDiscoveryUpdate) => void): () => void { + this.listeners.add(listener) + listener(this.state) + if (!this.started) { this.started = true; void this.run() } + return () => { + this.listeners.delete(listener) + if (this.listeners.size === 0 && !this.state.done) { + this.abandoned = true + this.controller.abort() + this.finish(this.state, true) + } + } + } + + private emit(): void { + if (this.abandoned) return + for (const listener of this.listeners) listener(this.state) + } + + private consume = (bytes: number): void => { + if (this.abandoned) throw new LookupResourceLimitError('abandoned') + if (bytes > this.limits.maxTotalBytes - this.state.receivedBytes) { + this.state.limitsHit.add('maxTotalBytes') + throw new LookupResourceLimitError('maxTotalBytes') + } + this.state.receivedBytes += bytes + this.emit() + } + + private async run(): Promise { + let cursor = 0 + // Each tracker keeps a reserved share, so an early advertisement flood + // cannot consume the complete candidate budget before later sources reply. + const share = Math.min(this.limits.maxHostsPerTracker, + Math.max(1, Math.floor(this.limits.maxHosts / Math.max(1, this.trackers.length)))) + const worker = async (): Promise => { + while (!this.controller.signal.aborted && cursor < this.trackers.length) { + const tracker = this.trackers[cursor++] + try { + const candidates = await this.lookup(tracker, this.controller.signal, this.consume) + if (this.abandoned) return + const hosts = new Set() + for (const candidate of candidates) { + const host = normalizeLookupHost(candidate) + if (host === null) { this.state.skippedHosts++; continue } + if (hosts.has(host)) continue + if (hosts.size >= share) { + this.state.skippedHosts++ + this.state.limitsHit.add('maxHostsPerTracker') + } else hosts.add(host) + } + this.state.sources.set(tracker, Array.from(hosts)) + } catch (error) { + if (error instanceof LookupResourceLimitError) this.state.limitsHit.add(error.limit) + else if (!this.controller.signal.aborted) this.state.trackersFailed++ + } finally { + this.state.trackersCompleted++ + this.emit() + } + } + } + await Promise.all(Array.from({ length: Math.min(this.limits.trackerConcurrency, this.trackers.length) }, worker)) + this.state.done = true + this.finish(this.state, this.abandoned) + this.emit() + this.listeners.clear() + } +} diff --git a/packages/sdk/src/overlay-tools/LookupHostQueue.ts b/packages/sdk/src/overlay-tools/LookupHostQueue.ts new file mode 100644 index 000000000..6e5d86cf4 --- /dev/null +++ b/packages/sdk/src/overlay-tools/LookupHostQueue.ts @@ -0,0 +1,74 @@ +/** A bounded FIFO within each source, round-robin between sources. */ +export class LookupHostQueue { + private readonly queues = new Map() + private readonly seen = new Set() + private cursor = 0 + private active = 0 + private closed = false + private sourceClosed = false + private resolveDone: () => void = () => {} + readonly done = new Promise(resolve => { this.resolveDone = resolve }) + + constructor( + private readonly maxHosts: number, + private readonly concurrency: number, + private readonly run: (host: string) => Promise, + private readonly skipped: (count: number, limited: boolean) => void + ) {} + + add(source: string, hosts: string[]): void { + if (this.closed || this.sourceClosed) return + const queue = this.queues.get(source) ?? [] + this.queues.set(source, queue) + for (const host of hosts) { + if (this.seen.has(host)) continue + if (this.seen.size >= this.maxHosts) { this.skipped(1, true); continue } + this.seen.add(host) + queue.push(host) + } + this.pump() + } + + finishSources(): void { + this.sourceClosed = true + this.pump() + } + + cancel(): void { + if (this.closed) return + this.closed = true + for (const queue of this.queues.values()) { + this.skipped(queue.length, false) + queue.length = 0 + } + this.settle() + } + + private next(): string | undefined { + const sources = Array.from(this.queues.values()) + for (let i = 0; i < sources.length; i++) { + const index = this.cursor++ % sources.length + const host = sources[index].shift() + if (host !== undefined) return host + } + return undefined + } + + private settle(): void { + if ((this.closed || this.sourceClosed) && this.active === 0 && + Array.from(this.queues.values()).every(queue => queue.length === 0)) this.resolveDone() + } + + private pump(): void { + while (!this.closed && this.active < this.concurrency) { + const host = this.next() + if (host === undefined) break + this.active++ + void this.run(host).catch(() => {}).finally(() => { + this.active-- + this.pump() + }) + } + this.settle() + } +} diff --git a/packages/sdk/src/overlay-tools/LookupResolver.ts b/packages/sdk/src/overlay-tools/LookupResolver.ts index 66f44f1bc..c0e674110 100644 --- a/packages/sdk/src/overlay-tools/LookupResolver.ts +++ b/packages/sdk/src/overlay-tools/LookupResolver.ts @@ -1,7 +1,14 @@ +import { LookupDiscovery, LookupDiscoveryUpdate } from './LookupDiscovery.js' +import { LookupHostQueue } from './LookupHostQueue.js' +import { readLookupResponseBytes } from './LookupResponseReader.js' +import { DEFAULT_LOOKUP_LIMITS, LookupLimits, LookupResourceLimitError, lookupLimits, normalizeLookupHost, lookupAbortError, withLookupAbort } from './LookupResources.js' +export type { LookupLimits } from './LookupResources.js' +export { DEFAULT_LOOKUP_LIMITS, LookupResourceLimitError } from './LookupResources.js' import { Transaction } from '../transaction/index.js' import { Beef } from '../transaction/Beef.js' import OverlayAdminTokenTemplate from './OverlayAdminTokenTemplate.js' import * as Utils from '../primitives/utils.js' +import { sha256 } from '../primitives/Hash.js' import { getOverlayHostReputationTracker, HostReputationTracker } from './HostReputationTracker.js' import { Telemetry, TelemetryConfig } from '../telemetry/Telemetry.js' import { normalizeBRC100ByteFields, stringifyBRC100 } from '../wallet/BRC100ByteEncoding.js' @@ -53,6 +60,26 @@ export type LookupFacilitatorAnswer = LookupAnswer | LookupFreeformAnswer * All optional; defaults preserve prior behavior. */ export interface LookupQueryOptions { + /** Abort this query without cancelling discovery still owned by another query. */ + signal?: AbortSignal + /** + * Callback intake budget, independent of legacy aggregation. Defaults to 512 + * outputs / 16 MiB of BEEF and context bytes. Values must be positive safe + * integers. Coordinate these with a downstream verifier's admission limits. + */ + evidenceLimits?: { maxOutputs?: number; maxBytes?: number } + /** Whole attempt budget including discovery and queued hosts. Default 10000 ms. */ + deadlineMs?: number + /** Per-query operational resource limits. These do not define evidence validity. */ + limits?: Partial + /** + * Owned, UNTRUSTED receipts before legacy txid/outpoint deduplication. Enqueue + * promptly; callback completion is not awaited and failures are isolated. + * Intake stops at the configured evidenceLimits, reporting one limit event. + * No callbacks occur after the query iterator closes. Raw `query$` snapshots + * remain unverified transport aggregates, not cryptographic proof. + */ + onEvidence?: (event: LookupEvidenceEvent) => void | Promise /** * Override the grace window (ms) between the first valid response and the resolution of the query. * Late responders arriving within this window are merged into the result. Default 80 ms. @@ -96,6 +123,11 @@ export interface LookupQueryOptions { correlationId?: string } +/** Additive evidence intake, independent of the legacy aggregated answer. */ +export type LookupEvidenceEvent = + | { type: 'output'; host: string; output: LookupAnswer['outputs'][number] } + | { type: 'limit' } + /** Info supplied to onUnreachableHost callbacks. */ export interface UnreachableHostInfo { /** Host URL that failed. */ @@ -114,6 +146,20 @@ export interface UnreachableHostInfo { * and refine in place as more hosts answer. */ export interface LookupAnswerProgress { + /** Transport coverage only, never cryptographic validity or global absence. */ + discoveryComplete?: boolean + terminalReason?: 'settled' | 'deadline' | 'cancelled' | 'resource-limit' + discoveredHosts?: number + skippedHosts?: number + receivedBytes?: number + /** Retained decoded BEEF/context octets; JavaScript arrays have additional heap overhead. */ + retainedBytes?: number + /** Receipt-copy octets handed to onEvidence, independently bounded. */ + evidenceBytes?: number + trackersTotal?: number + trackersCompleted?: number + trackersFailed?: number + limitsHit?: string[] type: 'output-list' outputs: Array<{ beef: number[]; outputIndex: number; context?: number[]; txid?: string }> /** Parallel array of resolved tx ids for each output (same index as `outputs`). */ @@ -308,6 +354,8 @@ interface CacheOptions { /** Configuration options for the Lookup resolver. */ export interface LookupResolverConfig { + /** Defaults for the bounded discovery, scheduler and receipt intake. */ + limits?: Partial /** * The network preset to use, unless other options override it. * - mainnet: use mainnet SLAP trackers and HTTPS facilitator @@ -346,10 +394,19 @@ export interface OverlayLookupFacilitator { lookup: ( url: string, question: LookupQuestion, - timeout?: number + timeout?: number, + signal?: AbortSignal, + options?: LookupRequestOptions ) => Promise } +/** Optional bounded transport settings; older custom facilitators may ignore these. */ +export interface LookupRequestOptions { + maxResponseBytes?: number + maxOutputs?: number + consumeBytes?: (bytes: number) => void +} + export class HTTPSOverlayLookupFacilitator implements OverlayLookupFacilitator { fetchClient: typeof fetch allowHTTP: boolean @@ -368,13 +425,18 @@ export class HTTPSOverlayLookupFacilitator implements OverlayLookupFacilitator { async lookup( url: string, question: LookupQuestion, - timeout: number = 2000 + timeout: number = 2000, + signal?: AbortSignal, + options?: LookupRequestOptions ): Promise { if (!url.startsWith('https:') && !this.allowHTTP) { throw new Error('HTTPS facilitator can only use URLs that start with "https:"') } const controller = typeof AbortController === 'undefined' ? undefined : new AbortController() + if (signal?.aborted === true) throw lookupAbortError() + const abort = (): void => controller?.abort() + signal?.addEventListener('abort', abort, { once: true }) const deadline = createDeadline(timeout, controller) // Hard wall-clock deadline: in some environments (e.g. browser/Electron CORS @@ -382,25 +444,29 @@ export class HTTPSOverlayLookupFacilitator implements OverlayLookupFacilitator { // AbortController signal alone is insufficient to make the returned promise // resolve or reject. Race the fetch against a setTimeout-backed reject so // the consumer-facing promise always settles within `timeout` ms. - const fetchPromise = this.performLookupRequest(url, question, controller?.signal) + const fetchPromise = this.performLookupRequest(url, question, controller?.signal, options) // Swallow background rejection if the deadline wins first. fetchPromise.catch(() => { /* noop */ }) try { - return await Promise.race([fetchPromise, deadline.promise]) + return await withLookupAbort(Promise.race([fetchPromise, deadline.promise]), signal) } catch (e) { + if (signal?.aborted) throw lookupAbortError() throw normalizeLookupError(e, deadline.didTimeOut()) } finally { deadline.cancel() + signal?.removeEventListener('abort', abort) + controller?.abort() } } private async performLookupRequest( url: string, question: LookupQuestion, - signal: AbortSignal | undefined + signal: AbortSignal | undefined, + options?: LookupRequestOptions ): Promise { const fco: RequestInit = { method: 'POST', @@ -412,7 +478,9 @@ export class HTTPSOverlayLookupFacilitator implements OverlayLookupFacilitator { signal } const response: Response = await this.fetchClient(`${url}/lookup`, fco) - if (!response.ok) { + if (signal?.aborted === true || !response.ok) { + try { void response.body?.cancel().catch(() => {}) } catch { /* best-effort body cleanup */ } + if (signal?.aborted === true) throw lookupAbortError() // 408/429 are availability/backpressure signals. Other 4xx responses // reject this request but do not prove that the host is unavailable, so // they remain distinguishable and neutral for availability reputation. @@ -426,10 +494,15 @@ export class HTTPSOverlayLookupFacilitator implements OverlayLookupFacilitator { : 'semantic' throw new LookupHTTPError(response.status, kind, response.statusText) } + const payload = await readLookupResponseBytes(response, { + signal, + maxResponseBytes: options?.maxResponseBytes ?? DEFAULT_LOOKUP_LIMITS.maxResponseBytes, + consumeBytes: options?.consumeBytes + }) if (isOctetStream(response.headers.get('content-type'))) { - return await this.parseOctetStreamLookup(response) + return await this.parseOctetStreamLookup(payload, signal, options) } - const answer = await response.json() + const answer = JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(payload)) if ( answer != null && typeof answer === 'object' && @@ -437,6 +510,7 @@ export class HTTPSOverlayLookupFacilitator implements OverlayLookupFacilitator { answer.type === 'output-list' && Array.isArray(answer.outputs) ) { + if (answer.outputs.length > (options?.maxOutputs ?? DEFAULT_LOOKUP_LIMITS.maxOutputs)) throw new LookupResourceLimitError('maxOutputs') for (const output of answer.outputs) { normalizeBRC100ByteFields(output, ['beef', 'context']) } @@ -445,10 +519,10 @@ export class HTTPSOverlayLookupFacilitator implements OverlayLookupFacilitator { } /** Parse the aggregated octet-stream lookup response into an output-list LookupAnswer. */ - private async parseOctetStreamLookup(response: Response): Promise { - const payload = await response.arrayBuffer() - const r = new Utils.Reader([...new Uint8Array(payload)]) + private async parseOctetStreamLookup(payload: Uint8Array, signal?: AbortSignal, options?: LookupRequestOptions): Promise { + const r = new Utils.Reader(Array.from(payload)) const nOutpoints = r.readVarIntNum() + if (!Number.isSafeInteger(nOutpoints) || nOutpoints < 0 || nOutpoints > (options?.maxOutputs ?? DEFAULT_LOOKUP_LIMITS.maxOutputs)) throw new LookupResourceLimitError('maxOutputs') const outpoints: Array<{ txid: string; outputIndex: number; context?: number[] }> = [] for (let i = 0; i < nOutpoints; i++) { const txid = Utils.toHex(r.read(32)) @@ -459,14 +533,16 @@ export class HTTPSOverlayLookupFacilitator implements OverlayLookupFacilitator { } const beef = r.read() const beefObj = Beef.fromBinary(beef) - const outputs = await this.extractAtomicOutputs(outpoints, beefObj) + const outputs = await this.extractAtomicOutputs(outpoints, beefObj, signal, options) return { type: 'output-list', outputs } } /** Memoize per-txid atomic BEEF extraction, yielding to the event loop between outputs. */ private async extractAtomicOutputs( outpoints: Array<{ txid: string; outputIndex: number; context?: number[] }>, - beefObj: Beef + beefObj: Beef, + signal?: AbortSignal, + options?: LookupRequestOptions ): Promise> { const beefByTxid = new Map() const outputs: Array<{ @@ -475,13 +551,17 @@ export class HTTPSOverlayLookupFacilitator implements OverlayLookupFacilitator { beef: number[] txid: string }> = Array.from({ length: outpoints.length }) + let extractedBytes = 0 for (let idx = 0; idx < outpoints.length; idx++) { + if (signal?.aborted === true) throw lookupAbortError() const x = outpoints[idx] let beefBytes = beefByTxid.get(x.txid) if (beefBytes === undefined) { beefBytes = beefObj.toBinaryAtomic(x.txid) beefByTxid.set(x.txid, beefBytes) } + extractedBytes += beefBytes.length + (x.context?.length ?? 0) + if (extractedBytes > (options?.maxResponseBytes ?? DEFAULT_LOOKUP_LIMITS.maxResponseBytes)) throw new LookupResourceLimitError('maxResponseBytes') outputs[idx] = { outputIndex: x.outputIndex, context: x.context, @@ -497,28 +577,21 @@ export class HTTPSOverlayLookupFacilitator implements OverlayLookupFacilitator { } } -type LookupQueryEvent = - | { kind: 'answer'; answer: LookupAnswer } - | { kind: 'done' } - | { kind: 'grace' } - | { kind: 'soft' } - interface LookupQuerySessionOptions { - hostCount: number graceMs: number softTimeoutMs?: number waitForAllHosts: boolean correlationId?: string - resolveTxId: ( - output: LookupAnswer['outputs'][number], - now: number - ) => string | null + limits: LookupLimits + onEvidence?: LookupQueryOptions['onEvidence'] + resolveTxId: (output: LookupAnswer['outputs'][number], now: number) => string | null } +/** A single cumulative snapshot plus a wake flag, regardless of listener speed. */ class LookupQuerySession { readonly startedAt = Date.now() - readonly hostCount: number readonly correlationId?: string + hostCount = 0 completedHosts = 0 successfulHosts = 0 emptyHosts = 0 @@ -526,199 +599,158 @@ class LookupQuerySession { rejectedHosts = 0 freeformHosts = 0 emittedFinal = false - - private readonly graceMs: number - private readonly softTimeoutMs?: number - private readonly waitForAllHosts: boolean - private readonly resolveTxId: LookupQuerySessionOptions['resolveTxId'] - private readonly outputsMap = new Map< - string, - { beef: number[]; context?: number[]; outputIndex: number } - >() + closed = false + accepting = true + discoveryComplete = false + discoveredHosts = 0 + skippedHosts = 0 + receivedBytes = 0 + retainedBytes = 0 + trackersTotal = 0 + trackersCompleted = 0 + trackersFailed = 0 + readonly limitsHit = new Set() + terminalReason: NonNullable = 'settled' + private evidenceOutputs = 0 + private evidenceBytes = 0 + private evidenceLimited = false + private limitNotificationSent = false + private readonly outputsMap = new Map() private readonly txIds: string[] = [] - private readonly queue: LookupQueryEvent[] = [] private waiter: (() => void) | null = null + private dirty = false + private finished = false + private failure: unknown private firstResponseAt: number | null = null private graceTimer: ReturnType | null = null private softTimer: ReturnType | null = null private graceFired = false - private emittedOnce = false + private softFired = false - constructor(options: LookupQuerySessionOptions) { - this.hostCount = options.hostCount - this.graceMs = options.graceMs - this.softTimeoutMs = options.softTimeoutMs - this.waitForAllHosts = options.waitForAllHosts + constructor(private readonly options: LookupQuerySessionOptions) { this.correlationId = options.correlationId - this.resolveTxId = options.resolveTxId } - private push(event: LookupQueryEvent): void { - this.queue.push(event) - if (this.waiter === null) return + wake(): void { + this.dirty = true const waiter = this.waiter this.waiter = null - waiter() + waiter?.() } - recordOutputAnswer(answer: LookupAnswer): void { - this.successfulHosts++ - if (answer.outputs.length === 0) { - this.emptyHosts++ - return + limit(name: string): void { + this.limitsHit.add(name) + if (!this.limitNotificationSent && this.accepting) { + this.limitNotificationSent = true + try { void Promise.resolve(this.options.onEvidence?.({ type: 'limit' })).catch(() => {}) } catch { /* consumer isolation */ } } - this.push({ kind: 'answer', answer }) + if (this.terminalReason === 'settled') this.terminalReason = 'resource-limit' + this.wake() } - recordFreeformAnswer(): void { - this.freeformHosts++ - } - - recordRejection(): void { - this.rejectedHosts++ + receiveEvidence(host: string, answer: LookupAnswer, callback: LookupQueryOptions['onEvidence']): void { + if (callback === undefined || this.closed || !this.accepting || this.evidenceLimited) return + const deliver = (event: LookupEvidenceEvent): void => { + try { void Promise.resolve(callback(event)).catch(() => {}) } catch { /* consumer isolation */ } + } + for (const output of answer.outputs) { + if (!this.accepting || this.closed) break + const bytes = output.beef.length + (output.context?.length ?? 0) + if (this.evidenceOutputs >= this.options.limits.maxEvidenceOutputs || + bytes > this.options.limits.maxEvidenceBytes - this.evidenceBytes) { + this.evidenceLimited = true + this.limit(this.evidenceOutputs >= this.options.limits.maxEvidenceOutputs ? 'maxEvidenceOutputs' : 'maxEvidenceBytes') + break + } + this.evidenceOutputs++ + this.evidenceBytes += bytes + deliver({ type: 'output', host, output: { + ...output, beef: output.beef.slice(), + ...(output.context === undefined ? {} : { context: output.context.slice() }) + } }) + } } - recordAvailabilityFailure(): void { - this.failedHosts++ + recordOutputAnswer(answer: LookupAnswer): void { + if (this.closed || !this.accepting) return + this.successfulHosts++ + if (answer.outputs.length === 0) { this.emptyHosts++; return } + this.mergeAnswer(answer) + if (this.firstResponseAt === null) { + this.firstResponseAt = Date.now() + if (this.options.graceMs > 0) this.graceTimer = setTimeout(() => { + this.graceFired = true; this.wake() + }, this.options.graceMs) + else this.graceFired = true + } + this.wake() } - recordDone(): void { - this.completedHosts++ - this.push({ kind: 'done' }) - } + recordFreeformAnswer(): void { if (!this.closed) this.freeformHosts++ } + recordRejection(): void { if (!this.closed) this.rejectedHosts++ } + recordAvailabilityFailure(): void { if (!this.closed) this.failedHosts++ } + recordDone(): void { if (!this.closed) { this.completedHosts++; this.wake() } } - private mergeAnswer(answer: LookupAnswer): boolean { - let added = false + private mergeAnswer(answer: LookupAnswer): void { const now = Date.now() for (const output of answer.outputs) { - const txId = this.resolveTxId(output, now) + const txId = this.options.resolveTxId(output, now) if (txId === null) continue const key = `${txId}.${output.outputIndex}` if (this.outputsMap.has(key)) continue + if (this.outputsMap.size >= this.options.limits.maxOutputs) { this.limit('maxOutputs'); break } this.outputsMap.set(key, output) this.txIds.push(txId) - added = true - } - return added - } - - snapshot(isFinal: boolean): LookupAnswerProgress { - return { - type: 'output-list', - outputs: Array.from(this.outputsMap.values()), - txIds: this.txIds.slice(), - isFinal, - hostCount: this.hostCount, - completedHosts: this.completedHosts, - successfulHosts: this.successfulHosts, - emptyHosts: this.emptyHosts, - failedHosts: this.failedHosts, - rejectedHosts: this.rejectedHosts, - freeformHosts: this.freeformHosts, - ...(this.correlationId !== undefined - ? { correlationId: this.correlationId } - : {}) } } - private handleAnswer(answer: LookupAnswer): LookupAnswerProgress | null { - const added = this.mergeAnswer(answer) - if (this.firstResponseAt === null) { - this.firstResponseAt = Date.now() - if (!this.graceFired && this.graceMs > 0) { - this.graceTimer = setTimeout(() => { - this.graceFired = true - this.push({ kind: 'grace' }) - }, this.graceMs) - } else { - this.graceFired = true - } - } - if ( - this.graceFired && - added && - (this.emittedOnce || !this.waitForAllHosts) - ) { - this.emittedOnce = true - return this.snapshot(false) - } - return null - } + finish(error?: unknown): void { this.failure = error; this.finished = true; this.wake() } - private handleGrace(): LookupAnswerProgress | null { - if (this.emittedOnce || this.waitForAllHosts) return null - this.emittedOnce = true - return this.snapshot(false) - } - - private handleSoft(): { - snapshot: LookupAnswerProgress | null - stop: boolean - } { - let snapshot: LookupAnswerProgress | null = null - if (!this.emittedOnce) { - this.graceFired = true - this.emittedOnce = true - snapshot = this.snapshot(false) - } + snapshot(isFinal: boolean): LookupAnswerProgress { return { - snapshot, - stop: - typeof this.softTimeoutMs === 'number' && - this.firstResponseAt !== null - } - } - - private async nextEvent(): Promise { - if (this.queue.length === 0) { - await new Promise(resolve => { - this.waiter = resolve - }) + type: 'output-list', outputs: Array.from(this.outputsMap.values()), txIds: this.txIds.slice(), + isFinal, hostCount: this.hostCount, completedHosts: this.completedHosts, + successfulHosts: this.successfulHosts, emptyHosts: this.emptyHosts, failedHosts: this.failedHosts, + rejectedHosts: this.rejectedHosts, freeformHosts: this.freeformHosts, + discoveryComplete: this.discoveryComplete, + ...(isFinal ? { terminalReason: this.terminalReason } : {}), + discoveredHosts: this.discoveredHosts, skippedHosts: this.skippedHosts, + receivedBytes: this.receivedBytes, retainedBytes: this.retainedBytes, evidenceBytes: this.evidenceBytes, trackersTotal: this.trackersTotal, + trackersCompleted: this.trackersCompleted, trackersFailed: this.trackersFailed, + limitsHit: Array.from(this.limitsHit), + ...(this.correlationId !== undefined ? { correlationId: this.correlationId } : {}) } - return this.queue.shift() as LookupQueryEvent } - private processEvent(event: LookupQueryEvent): { - snapshot: LookupAnswerProgress | null - stop: boolean - } { - switch (event.kind) { - case 'answer': - return { snapshot: this.handleAnswer(event.answer), stop: false } - case 'grace': - return { snapshot: this.handleGrace(), stop: false } - case 'soft': - return this.handleSoft() - case 'done': - return { snapshot: null, stop: false } - } + close(): void { + this.closed = true + this.accepting = false + if (this.graceTimer !== null) clearTimeout(this.graceTimer) + if (this.softTimer !== null) clearTimeout(this.softTimer) + this.wake() } async *progress(): AsyncIterable { - if ( - typeof this.softTimeoutMs === 'number' && - this.softTimeoutMs >= 0 - ) { - this.softTimer = setTimeout( - () => this.push({ kind: 'soft' }), - this.softTimeoutMs - ) + if (typeof this.options.softTimeoutMs === 'number' && this.options.softTimeoutMs >= 0) { + this.softTimer = setTimeout(() => { this.softFired = true; this.wake() }, this.options.softTimeoutMs) } try { - let stop = false - while (this.completedHosts < this.hostCount && !stop) { - const event = await this.nextEvent() - const outcome = this.processEvent(event) - if (outcome.snapshot != null) yield outcome.snapshot - stop = outcome.stop + while (!this.closed) { + if (this.finished) { + if (this.failure !== undefined) throw this.failure + this.emittedFinal = true + yield this.snapshot(true) + return + } + if (this.dirty && (this.softFired || (this.graceFired && !this.options.waitForAllHosts))) { + this.dirty = false + yield this.snapshot(false) + } else { + this.dirty = false + await new Promise(resolve => { this.waiter = resolve }) + } } - const finalSnapshot = this.snapshot(true) - this.emittedFinal = true - yield finalSnapshot - } finally { - if (this.graceTimer !== null) clearTimeout(this.graceTimer) - if (this.softTimer !== null) clearTimeout(this.softTimer) - } + } finally { this.close() } } } @@ -745,8 +777,11 @@ export default class LookupResolver { private readonly telemetry: Telemetry // ---- Caches / memoization ---- - private readonly hostsCache: Map - private readonly hostsInFlight: Map> + private readonly hostsCache: Map + private readonly hostsInFlight: Map + private readonly limits: LookupLimits + private activeQueries = 0 + private trackerCursor = 0 private readonly hostsTtlMs: number private readonly hostsMaxEntries: number @@ -762,6 +797,7 @@ export default class LookupResolver { private readonly lastUnreachableNotificationAt: Map constructor(config: LookupResolverConfig = {}) { + this.limits = lookupLimits(config.limits) this.networkPreset = config.networkPreset ?? 'mainnet' this.facilitator = config.facilitator ?? @@ -877,77 +913,6 @@ export default class LookupResolver { } } - private appendAdditionalHosts(service: string, hosts: string[]): void { - const additional = this.additionalHosts[service] - if (additional == null || additional.length === 0) return - const seen = new Set(hosts) - for (const host of additional) { - if (!seen.has(host)) hosts.push(host) - } - } - - private async competentHostsFor(question: LookupQuestion): Promise { - let hosts: string[] - if (question.service === 'ls_slap') { - hosts = - this.networkPreset === 'local' - ? ['http://localhost:8080'] - : this.slapTrackers - } else if (this.hostOverrides[question.service] != null) { - hosts = this.hostOverrides[question.service] - } else if (this.networkPreset === 'local') { - hosts = ['http://localhost:8080'] - } else { - hosts = await this.getCompetentHostsCached(question.service) - } - this.appendAdditionalHosts(question.service, hosts) - if (hosts.length < 1) { - throw new Error( - `No competent ${this.networkPreset} hosts found by the SLAP trackers for lookup service: ${question.service}` - ) - } - return hosts - } - - private isSlapRecoveryEligible(service: string): boolean { - return ( - service !== 'ls_slap' && - this.hostOverrides[service] == null && - this.networkPreset !== 'local' - ) - } - - private async rankedHostsFor(question: LookupQuestion): Promise { - const competentHosts = await this.competentHostsFor(question) - let rankedHosts: string[] - try { - rankedHosts = this.prepareHostsForQuery( - competentHosts, - `lookup service ${question.service}` - ) - } catch (error) { - if (!this.isSlapRecoveryEligible(question.service)) throw error - this.hostsCache.delete(question.service) - const fresh = await this.refreshHosts(question.service, true) - this.appendAdditionalHosts(question.service, fresh) - if (fresh.length < 1) { - throw new Error( - `No competent ${this.networkPreset} hosts found by the SLAP trackers for 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.` - ) - } - return rankedHosts - } - private unreachableNotificationCooldown( options: LookupQueryOptions | undefined ): number { @@ -1059,192 +1024,292 @@ export default class LookupResolver { } } - private startLookupHostQueries( - hosts: string[], - question: LookupQuestion, - timeout: number | undefined, - session: LookupQuerySession, - options: LookupQueryOptions | undefined - ): void { - const correlationId = session.correlationId - const notificationCooldownMs = - this.unreachableNotificationCooldown(options) - for (const host of hosts) { - const hostStartedAt = Date.now() - void this.lookupHostWithTracking(host, question, timeout) - .then(answer => { - this.recordLookupHostAnswer( - session, - question.service, - host, - answer, - hostStartedAt, - correlationId - ) - }) - .catch(error => { - this.recordLookupHostFailure({ - session, - service: question.service, - host, - hostStartedAt, - correlationId, - onUnreachableHost: options?.onUnreachableHost, - notificationCooldownMs - }, error) - }) - .finally(() => { - session.recordDone() - }) + /** + * Cumulative unverified results. Discovery remains subscribed while trackers + * settle; each new host enters the bounded queue immediately. Caller abort, + * deadline and iterator close release this query's ownership. + */ + query$(question: LookupQuestion, timeout?: number, options?: LookupQueryOptions): AsyncIterable { + const cancellation = new AbortController() + const iterator = this.queryProgress(question, timeout, options, cancellation.signal)[Symbol.asyncIterator]() + return { + [Symbol.asyncIterator]: () => ({ + next: async () => await iterator.next(), + return: async () => { + cancellation.abort() + return await iterator.return?.() ?? { done: true, value: undefined } + }, + throw: async (error?: unknown) => { + cancellation.abort() + if (iterator.throw !== undefined) return await iterator.throw(error) + throw error + } + }) } } - /** - * Iterable form of {@link query}. Emits partial results as hosts answer. - * - * Emission order: - * - First emission: after the grace window expires (or as soon as the soft timeout elapses), containing - * every output gathered from hosts that answered by then. - * - Subsequent emissions: re-emitted whenever a late host returns extra outputs that weren't in earlier - * emissions. Each emission contains the cumulative `outputs` set. - * - Final emission: `isFinal: true` once all in-flight hosts have settled (success / fail / timeout). The - * caller can `break` early; outstanding work is bounded by the per-host timeout. - * - * No host work runs past its per-host `timeout` — there is no leak risk on early break. - */ - async *query$( - question: LookupQuestion, - timeout?: number, - options?: LookupQueryOptions - ): AsyncIterable { - const rankedHosts = await this.rankedHostsFor(question) - const hostCount = rankedHosts.length - const correlationId = - options?.correlationId ?? - (this.telemetry.enabled ? this.telemetry.createCorrelationId() : undefined) - const session = new LookupQuerySession({ - hostCount, - graceMs: options?.graceMs ?? 80, - softTimeoutMs: options?.softTimeoutMs, - waitForAllHosts: - options?.waitForAllHosts ?? options?.holdForUnknownHosts ?? false, - correlationId, - resolveTxId: (output, now) => this.resolveTxIdForOutput(output, now) + private async *queryProgress(inputQuestion: LookupQuestion, timeout: number | undefined, + options: LookupQueryOptions | undefined, iteratorSignal: AbortSignal): AsyncIterable { + // Capture JSON wire values once, before any discovery or queued host can + // observe a caller's later mutation. Custom non-JSON questions retain their + // historical facilitator-defined semantics when they cannot be cloned. + let question: LookupQuestion + try { question = structuredClone(inputQuestion) } + catch { + if (this.facilitator instanceof HTTPSOverlayLookupFacilitator) question = JSON.parse(stringifyBRC100(inputQuestion)) as LookupQuestion + else question = { ...inputQuestion } + } + const limits = lookupLimits(this.limits, options?.limits, { + ...(options?.evidenceLimits?.maxOutputs === undefined ? {} : { maxEvidenceOutputs: options.evidenceLimits.maxOutputs }), + ...(options?.evidenceLimits?.maxBytes === undefined ? {} : { maxEvidenceBytes: options.evidenceLimits.maxBytes }) }) - - this.telemetry.capture({ - name: 'sdk.overlay.lookup.started', - component: 'sdk.lookup-resolver', - severity: 'debug', - correlationId, - attributes: { - service: question.service, - network: this.networkPreset, - hostCount - } + const deadlineMs = options?.deadlineMs ?? 10_000 + if (!Number.isFinite(deadlineMs) || deadlineMs < 0 || deadlineMs > 2_147_483_647) { + throw new RangeError('Lookup deadlineMs must be between 0 and 2147483647') + } + if (this.activeQueries >= 128) throw new LookupResourceLimitError('activeQueries') + this.activeQueries++ + const controller = new AbortController() + const session = new LookupQuerySession({ + graceMs: options?.graceMs ?? 80, softTimeoutMs: options?.softTimeoutMs, + waitForAllHosts: options?.waitForAllHosts ?? options?.holdForUnknownHosts ?? false, + correlationId: options?.correlationId ?? (this.telemetry.enabled ? this.telemetry.createCorrelationId() : undefined), + limits, onEvidence: options?.onEvidence, resolveTxId: (output, now) => this.resolveTxIdForOutput(output, now) }) - - this.startLookupHostQueries( - rankedHosts, - question, - timeout, - session, - options - ) - - try { - for await (const progress of session.progress()) { - if (progress.isFinal) { - this.captureLookupCompletedTelemetry( - question.service, - progress, - Date.now() - session.startedAt - ) - } - yield progress + let releaseDiscovery: (() => void) | undefined + let discoveryFinished = false + let noHostsError: Error | undefined + const seen = new Set() + let sourceQuota = limits.maxHosts + let discoveryBytes = 0 + let discoverySkipped = 0 + const processedSources = new Set() + const consume = (bytes: number): void => { + if (controller.signal.aborted) throw lookupAbortError() + if (bytes > limits.maxTotalBytes - session.receivedBytes) { + session.limit('maxTotalBytes') + throw new LookupResourceLimitError('maxTotalBytes') } - } finally { - if (!session.emittedFinal) { - this.telemetry.capture({ - name: 'sdk.overlay.lookup.cancelled', - component: 'sdk.lookup-resolver', - severity: 'debug', - correlationId, - attributes: { - service: question.service, - hostCount, - completedHosts: session.completedHosts, - durationMs: Date.now() - session.startedAt - } + session.receivedBytes += bytes + } + const queue = new LookupHostQueue(limits.maxHosts, limits.hostConcurrency, async host => { + if (controller.signal.aborted) return + session.hostCount++ + const startedAt = Date.now() + try { + const answer = await this.lookupHostWithTracking(host, question, timeout, controller.signal, { + maxResponseBytes: limits.maxResponseBytes, maxOutputs: limits.maxOutputs, consumeBytes: consume }) - } + if (controller.signal.aborted || session.closed) return + let ownedAnswer = answer + if (isOutputListAnswer(answer)) { + let retained = 0 + for (const output of answer.outputs) retained += output.beef.length + (output.context?.length ?? 0) + if (retained > limits.maxTotalBytes - session.retainedBytes) throw new LookupResourceLimitError('maxTotalBytes') + session.retainedBytes += retained + ownedAnswer = { type: 'output-list', outputs: answer.outputs.map(output => ({ + ...output, beef: output.beef.slice(), ...(output.context === undefined ? {} : { context: output.context.slice() }) + })) } + session.receiveEvidence(host, ownedAnswer, options?.onEvidence) + } + if (controller.signal.aborted || session.closed) return + this.recordLookupHostAnswer(session, question.service, host, ownedAnswer, startedAt, session.correlationId) + } catch (error) { + if (controller.signal.aborted || session.closed) return + if (error instanceof LookupResourceLimitError) session.limit(error.limit) + else this.recordLookupHostFailure({ + session, service: question.service, host, hostStartedAt: startedAt, + correlationId: session.correlationId, onUnreachableHost: options?.onUnreachableHost, + notificationCooldownMs: this.unreachableNotificationCooldown(options) + }, error) + } finally { session.recordDone() } + }, (count, limited) => { if (count > 0) { session.skippedHosts += count; if (limited) session.limit('maxHosts') } }) + + const stop = (reason: 'deadline' | 'cancelled'): void => { + if (controller.signal.aborted) return + session.limit(reason) + session.terminalReason = reason + session.accepting = false + session.discoveryComplete = false + controller.abort() + releaseDiscovery?.() + discoveryFinished = true + queue.cancel() } - } - - /** - * Cached wrapper for competent host discovery with stale-while-revalidate. - */ - private async getCompetentHostsCached(service: string): Promise { - const now = Date.now() - const cached = this.hostsCache.get(service) - - // if fresh, return immediately - if (typeof cached === 'object' && cached.expiresAt > now) { - return cached.hosts.slice() + const abort = (): void => stop('cancelled') + options?.signal?.addEventListener('abort', abort, { once: true }) + iteratorSignal.addEventListener('abort', abort, { once: true }) + const timer = setTimeout(() => stop('deadline'), deadlineMs) + + let cleaned = false + const cleanup = (): void => { + if (cleaned) return + cleaned = true + session.close() + clearTimeout(timer) + options?.signal?.removeEventListener('abort', abort) + iteratorSignal.removeEventListener('abort', abort) + controller.abort() + releaseDiscovery?.() + queue.cancel() + this.activeQueries-- } - // 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) - }) - ) + const admit = (source: string, candidates: string[]): void => { + if (controller.signal.aborted) return + const hosts: string[] = [] + const scanLimit = Math.min(candidates.length, limits.maxHosts * 4) + if (candidates.length > scanLimit) { session.skippedHosts += candidates.length - scanLimit; session.limit('maxHosts') } + for (let candidateIndex = 0; candidateIndex < scanLimit; candidateIndex++) { + const candidate = candidates[candidateIndex] + const host = normalizeLookupHost(candidate, source === 'configured' || source === 'additional') + if (host === null) { session.skippedHosts++; continue } + if (seen.has(host)) continue + // Keep this dedup set finite, too. Source-level discovery reservations + // protect later trackers before candidates reach this queue. + if (seen.size >= limits.maxHosts) { session.skippedHosts++; session.limit('maxHosts'); continue } + seen.add(host) + session.discoveredHosts++ + hosts.push(host) } - return cached.hosts.slice() - } - - // no cache: coalesce concurrent requests - if (this.hostsInFlight.has(service)) { + if (hosts.length === 0) return try { - const hosts = await this.hostsInFlight.get(service) - if (typeof hosts !== 'object') { - throw new TypeError('Hosts is not defined.') + const available = this.prepareHostsForQuery(hosts, `lookup service ${question.service}`) + session.skippedHosts += hosts.length - available.length + queue.add(source, available) + } catch (error) { + session.skippedHosts += hosts.length + noHostsError = error instanceof Error ? error : new Error(lookupErrorMessage(error)) + } + } + const sourcesDone = (): void => { + discoveryFinished = true + queue.finishSources() + } + const acceptDiscovery = (state: LookupDiscoveryUpdate): void => { + if (controller.signal.aborted) return + session.trackersTotal = state.trackersTotal + session.trackersCompleted = state.trackersCompleted + session.trackersFailed = state.trackersFailed + session.skippedHosts += state.skippedHosts - discoverySkipped + discoverySkipped = state.skippedHosts + for (const name of state.limitsHit) session.limit(name) + try { consume(state.receivedBytes - discoveryBytes) } catch (error) { + if (error instanceof LookupResourceLimitError) session.limit(error.limit) + controller.abort() + releaseDiscovery?.() + queue.cancel() + discoveryFinished = true + return + } + discoveryBytes = state.receivedBytes + for (const [source, hosts] of state.sources) { + if (processedSources.has(source)) continue + processedSources.add(source) + admit(source, hosts.slice(0, sourceQuota)) + if (hosts.length > sourceQuota) { + session.skippedHosts += hosts.length - sourceQuota + session.limit('maxHostsPerTracker') } - return hosts.slice() - } catch { - // fall through to a fresh attempt below } + session.discoveryComplete = state.done && state.trackersFailed === 0 && state.limitsHit.size === 0 && state.skippedHosts === 0 + session.wake() + if (state.done) sourcesDone() } - - const promise = this.refreshHosts(service).finally(() => { - this.hostsInFlight.delete(service) - }) - this.hostsInFlight.set(service, promise) - const hosts = await promise - return hosts.slice() + try { + this.telemetry.capture({ name: 'sdk.overlay.lookup.started', component: 'sdk.lookup-resolver', + severity: 'debug', correlationId: session.correlationId, + attributes: { service: question.service, network: this.networkPreset, hostCount: 0 } }) + if (options?.signal?.aborted === true || iteratorSignal.aborted) abort() + if (!controller.signal.aborted) { + if (question.service === 'ls_slap' || this.hostOverrides[question.service] != null || this.networkPreset === 'local') { + const direct = question.service === 'ls_slap' + ? (this.networkPreset === 'local' ? ['http://localhost:8080'] : this.slapTrackers) + : this.hostOverrides[question.service] ?? ['http://localhost:8080'] + admit('configured', direct) + admit('additional', this.additionalHosts[question.service] ?? []) + session.discoveryComplete = true + sourcesDone() + } else { + const cached = this.hostsCache.get(question.service) + const configuredAdditional = this.additionalHosts[question.service] ?? [] + const cacheAvailable = cached?.hosts.some(host => (this.hostReputation.snapshot(host)?.backoffUntil ?? 0) <= Date.now()) ?? false + const key = JSON.stringify([question.service, limits.maxHosts, limits.maxHostsPerTracker, + limits.maxTrackers, limits.trackerConcurrency, limits.maxResponseBytes, limits.maxTotalBytes, limits.maxOutputs]) + let discovery = this.hostsInFlight.get(key) + const refresh = discovery !== undefined || cached === undefined || cached.expiresAt <= Date.now() || !cacheAvailable + const initialSources = Number(cached !== undefined && cacheAvailable) + Number(configuredAdditional.length > 0) + const initialQuota = refresh ? Math.max(1, Math.floor(limits.maxHosts / (initialSources + Math.max(1, Math.min(this.slapTrackers.length, limits.maxTrackers))))) : limits.maxHosts + if (cached !== undefined && cacheAvailable) { + // Reserve a source share for cached membership and each late tracker. + const cachedLimit = initialQuota + admit('cache', cached.hosts.slice(0, cachedLimit)) + if (cached.hosts.length > cachedLimit) { session.skippedHosts += cached.hosts.length - cachedLimit; session.limit('maxHosts') } + } + if (configuredAdditional.length > 0) { + admit('additional', configuredAdditional.slice(0, initialQuota)) + if (configuredAdditional.length > initialQuota) { session.skippedHosts += configuredAdditional.length - initialQuota; session.limit('maxHosts') } + } + if (refresh) { + sourceQuota = Math.max(1, Math.floor((limits.maxHosts - seen.size) / Math.max(1, Math.min(this.slapTrackers.length, limits.maxTrackers)))) + if (discovery === undefined) { + let trackers: string[] = [] + const selected: string[] = [] + const scan = Math.min(this.slapTrackers.length, limits.maxTrackers) + for (let i = 0; i < scan; i++) selected.push(this.slapTrackers[(this.trackerCursor + i) % this.slapTrackers.length]) + this.trackerCursor = (this.trackerCursor + scan) % Math.max(1, this.slapTrackers.length) + const normalized = Array.from(new Set(selected.map(host => normalizeLookupHost(host)).filter((host): host is string => host !== null))) + try { trackers = this.prepareHostsForQuery(normalized.slice(0, limits.maxTrackers), 'SLAP trackers') } + catch (error) { noHostsError = error instanceof Error ? error : new Error(lookupErrorMessage(error)) } + discovery = new LookupDiscovery(trackers, limits, async (tracker, signal, charge) => { + const answer = await this.lookupHostWithTracking(tracker, { service: 'ls_slap', query: { service: question.service } }, MAX_TRACKER_WAIT_TIME, signal, + { maxResponseBytes: limits.maxResponseBytes, maxOutputs: limits.maxOutputs, consumeBytes: charge }) + const hosts = isOutputListAnswer(answer) ? this.extractHostsFromAnswer(answer, question.service) : [] + for (const host of hosts) { + if (this.advertisedBy.size >= this.hostsMaxEntries * limits.maxHosts) this.evictOldest(this.advertisedBy) + this.advertisedBy.set(host, tracker) + } + return hosts + }, (state, abandoned) => { + if (this.hostsInFlight.get(key) !== discovery) return + this.hostsInFlight.delete(key) + if (abandoned) return + const hosts = Array.from(new Set(Array.from(state.sources.values()).flat())).slice(0, limits.maxHosts) + if (!this.hostsCache.has(question.service) && this.hostsCache.size >= this.hostsMaxEntries) this.evictOldest(this.hostsCache) + this.hostsCache.set(question.service, { hosts, expiresAt: Date.now() + this.hostsTtlMs, discoveryComplete: state.trackersFailed === 0 && state.limitsHit.size === 0 && state.skippedHosts === 0, trackersFailed: state.trackersFailed, limitsHit: Array.from(state.limitsHit) }) + }) + if (this.slapTrackers.length > limits.maxTrackers) discovery.state.limitsHit.add('maxTrackers') + if (normalized.length !== this.slapTrackers.length || trackers.length < Math.min(normalized.length, limits.maxTrackers)) { + discovery.state.skippedHosts += this.slapTrackers.length - trackers.length + } + this.hostsInFlight.set(key, discovery) + } + releaseDiscovery = discovery.subscribe(acceptDiscovery) + if (controller.signal.aborted) releaseDiscovery() + } else { + session.discoveryComplete = cached?.discoveryComplete ?? true + session.trackersFailed = cached?.trackersFailed ?? 0 + for (const name of cached?.limitsHit ?? []) session.limit(name) + sourcesDone() + } + } + } else queue.cancel() + void queue.done.then(() => { + const error = session.hostCount === 0 && session.terminalReason === 'settled' + ? noHostsError ?? new Error(`No competent ${this.networkPreset} hosts found by the SLAP trackers for lookup service: ${question.service}`) + : undefined + if (discoveryFinished) session.finish(error) + }) + for await (const progress of session.progress()) { + if (progress.isFinal) { + this.captureLookupCompletedTelemetry(question.service, progress, Date.now() - session.startedAt) + cleanup() + } + yield progress + } + } finally { cleanup() } } - /** - * Actually resolves competent hosts from SLAP trackers and updates cache. - */ - private async refreshHosts( - service: string, - requireAvailable: boolean = false - ): Promise { - const hosts = await this.findCompetentHosts(service, requireAvailable) - const expiresAt = Date.now() + this.hostsTtlMs - - // bounded cache with simple FIFO eviction - if (!this.hostsCache.has(service) && this.hostsCache.size >= this.hostsMaxEntries) { - const oldestKey = this.hostsCache.keys().next().value - if (oldestKey !== undefined) this.hostsCache.delete(oldestKey) - } - this.hostsCache.set(service, { hosts, expiresAt }) - return hosts - } /** * Extracts competent host domains from a SLAP tracker response. @@ -1269,70 +1334,6 @@ export default class LookupResolver { return hosts } - /** - * Returns a list of competent hosts for a given lookup service. - * Resolves as soon as the first SLAP tracker responds with valid hosts. - * Remaining trackers continue in the background for reputation tracking. - * @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 { - const query: LookupQuestion = { - service: 'ls_slap', - query: { service } - } - - 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 => { - 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) - : [] - for (const h of hosts) { - if (!allHosts.has(h)) { - allHosts.add(h) - // First-seen attribution: the tracker that surfaced this host - // gets credit, used by onUnreachableHost callbacks. - this.advertisedBy.set(h, tracker) - } - } - const now = Date.now() - const foundAvailable = [...allHosts].some(host => { - const backoffUntil = this.hostReputation.snapshot(host)?.backoffUntil ?? 0 - return backoffUntil <= now - }) - if (!resolved && allHosts.size > 0 && (!requireAvailable || foundAvailable)) { - resolved = true - resolve([...allHosts]) - } - }) - .catch(() => { - /* tracker failure tracked in reputation */ - }) - .finally(() => { - pending-- - if (pending === 0 && !resolved) { - resolved = true - resolve([...allHosts]) - } - }) - } - }) - } - /** * Resolve a txid for an aggregated lookup output. Uses the threaded-through `output.txid` * fast path when present; otherwise memoizes Transaction.fromBEEF(beef).id('hex') keyed by @@ -1345,7 +1346,7 @@ export default class LookupResolver { if (typeof output.txid === 'string' && output.txid.length > 0) { return output.txid } - const keyForBeef = Array.isArray(output.beef) ? output.beef.join(',') : '' + const keyForBeef = Utils.toHex(sha256(output.beef)) const memo = this.txMemo.get(keyForBeef) if (typeof memo === 'object' && memo !== null && memo.expiresAt > now) { return memo.txId @@ -1391,18 +1392,27 @@ export default class LookupResolver { private async lookupHostWithTracking( host: string, question: LookupQuestion, - timeout?: number + timeout?: number, + signal?: AbortSignal, + options?: LookupRequestOptions ): Promise { const startedAt = Date.now() const effectiveTimeout = typeof timeout === 'number' && Number.isFinite(timeout) && timeout >= 0 ? timeout : DEFAULT_LOOKUP_TIMEOUT - const deadline = createDeadline(effectiveTimeout) + const controller = new AbortController() + const abort = (): void => controller.abort() + signal?.addEventListener('abort', abort, { once: true }) + const deadline = createDeadline(effectiveTimeout, controller) + let reportedBytes = 0 + const requestOptions = { ...options, consumeBytes: (bytes: number): void => { + options?.consumeBytes?.(bytes); reportedBytes += bytes + } } // Start the custom facilitator in a promise chain so synchronous throws // become rejections governed by the same wall-clock deadline. const lookupPromise = Promise.resolve().then(() => - this.facilitator.lookup(host, question, timeout) + signal?.aborted === true ? Promise.reject(lookupAbortError()) : this.facilitator.lookup(host, question, timeout, controller.signal, requestOptions) ) lookupPromise.catch(() => { /* deadline may win while custom facilitator settles later */ @@ -1410,16 +1420,28 @@ export default class LookupResolver { let answer: LookupFacilitatorAnswer try { - answer = await Promise.race([lookupPromise, deadline.promise]) + answer = await withLookupAbort(Promise.race([lookupPromise, deadline.promise]), signal) } catch (err) { + if (signal?.aborted === true) throw lookupAbortError() + if (err instanceof LookupResourceLimitError) throw err const normalized = normalizeLookupError(err, deadline.didTimeOut()) if (!isSemanticLookupRejection(err)) this.hostReputation.recordFailure(host, normalized) throw isSemanticLookupRejection(err) ? err : normalized } finally { deadline.cancel() + signal?.removeEventListener('abort', abort) + controller.abort() } + if (signal?.aborted === true) throw lookupAbortError() + if (answer != null && answer.type === 'output-list' && Array.isArray(answer.outputs) && answer.outputs.length > (options?.maxOutputs ?? DEFAULT_LOOKUP_LIMITS.maxOutputs)) throw new LookupResourceLimitError('maxOutputs') if (isOutputListAnswer(answer)) { + let bytes = 0 + for (const output of answer.outputs) { + bytes += output.beef.length + (output.context?.length ?? 0) + if (bytes > (options?.maxResponseBytes ?? DEFAULT_LOOKUP_LIMITS.maxResponseBytes)) throw new LookupResourceLimitError('maxResponseBytes') + } + if (reportedBytes === 0) options?.consumeBytes?.(bytes) this.hostReputation.recordSuccess(host, Date.now() - startedAt) return answer } diff --git a/packages/sdk/src/overlay-tools/LookupResources.ts b/packages/sdk/src/overlay-tools/LookupResources.ts new file mode 100644 index 000000000..8fee3a773 --- /dev/null +++ b/packages/sdk/src/overlay-tools/LookupResources.ts @@ -0,0 +1,83 @@ +/** Operational client limits, not BEEF validity or service authority rules. */ +export interface LookupLimits { + maxHosts: number + maxHostsPerTracker: number + maxTrackers: number + hostConcurrency: number + trackerConcurrency: number + maxResponseBytes: number + maxTotalBytes: number + maxOutputs: number + maxEvidenceOutputs: number + maxEvidenceBytes: number +} + +/** Finite defaults; applications with larger proofs can raise these explicitly. */ +export const DEFAULT_LOOKUP_LIMITS: Readonly = Object.freeze({ + maxHosts: 256, + maxHostsPerTracker: 64, + maxTrackers: 16, + hostConcurrency: 8, + trackerConcurrency: 4, + maxResponseBytes: 32 * 1024 * 1024, + maxTotalBytes: 64 * 1024 * 1024, + maxOutputs: 4096, + maxEvidenceOutputs: 512, + maxEvidenceBytes: 16 * 1024 * 1024 +}) + +export class LookupResourceLimitError extends Error { + constructor(readonly limit: string) { + super(`Lookup resource limit reached: ${limit}`) + this.name = 'LookupResourceLimitError' + } +} + +export function lookupLimits(...overrides: Array | undefined>): LookupLimits { + const limits = Object.assign({}, DEFAULT_LOOKUP_LIMITS, ...overrides) + for (const [name, value] of Object.entries(limits)) { + if (typeof value !== 'number' || !Number.isSafeInteger(value) || value <= 0) { + throw new RangeError(`Lookup limit ${name} must be a positive safe integer`) + } + } + return limits +} + +/** Preserve distinct paths and ports; remove only a final slash and URL fragments. */ +export function normalizeLookupHost(host: string, allowParameters: boolean = false): string | null { + if (typeof host !== 'string' || host.length > 2048) return null + try { + const url = new URL(host) + if (!['https:', 'http:'].includes(url.protocol) || url.username !== '' || url.password !== '') return null + // A query/fragment has no defined meaning before the /lookup route suffix. + if (!allowParameters && (url.search !== '' || url.hash !== '')) return null + return url.href.replace(/\/$/, '') + } catch { + return null + } +} + +export function lookupAbortError(): Error { + const error = new Error('Lookup cancelled') + error.name = 'AbortError' + return error +} + +/** A non-cooperative transport cannot retain a cancelled waiter. */ +export async function withLookupAbort(work: Promise, signal?: AbortSignal): Promise { + if (signal === undefined) return await work + if (signal.aborted) { + void work.catch(() => {}) + throw lookupAbortError() + } + let abort = (): void => {} + const cancelled = new Promise((_resolve, reject) => { + abort = () => reject(lookupAbortError()) + signal.addEventListener('abort', abort, { once: true }) + }) + try { + return await Promise.race([work, cancelled]) + } finally { + signal.removeEventListener('abort', abort) + } +} diff --git a/packages/sdk/src/overlay-tools/LookupResponseReader.ts b/packages/sdk/src/overlay-tools/LookupResponseReader.ts new file mode 100644 index 000000000..fe0ed7b4b --- /dev/null +++ b/packages/sdk/src/overlay-tools/LookupResponseReader.ts @@ -0,0 +1,189 @@ +import { LookupResourceLimitError } from './LookupResources.js' + +/** Options controlling a bounded lookup response read. */ +export interface LookupResponseReaderOptions { + /** Cancels a pending stream read when the lookup request is aborted. */ + signal?: AbortSignal + /** Maximum number of response bytes to retain. */ + maxResponseBytes: number + /** Charges accepted bytes to the caller's aggregate response budget. */ + consumeBytes?: (bytes: number) => void +} + +function abortReason(signal: AbortSignal): unknown { + return signal.reason ?? new DOMException('The operation was aborted.', 'AbortError') +} + +function assertValidMaximum(maxResponseBytes: number): void { + if (!Number.isSafeInteger(maxResponseBytes) || maxResponseBytes < 0) { + throw new RangeError('maxResponseBytes must be a non-negative safe integer') + } +} + +function assertDeclaredLengthIsWithinLimit(response: Response, maxResponseBytes: number): void { + const contentLength = response.headers.get('content-length') + if (contentLength === null) return + + const normalized = contentLength.trim() + // Content-Length is decimal bytes. Treat malformed fields as unknown rather + // than accidentally accepting a notation such as "1e6". + if (!/^[0-9]+$/.test(normalized)) return + + const declaredLength = Number(normalized) + if (!Number.isSafeInteger(declaredLength) || declaredLength > maxResponseBytes) { + throw new LookupResourceLimitError('maxResponseBytes') + } +} + +async function readWithAbort( + reader: ReadableStreamDefaultReader, + signal: AbortSignal | undefined +): Promise> { + if (signal === undefined) return await reader.read() + if (signal.aborted) throw abortReason(signal) + + return await new Promise>((resolve, reject) => { + let settled = false + const finish = (callback: () => void): void => { + if (settled) return + settled = true + signal.removeEventListener('abort', onAbort) + callback() + } + const onAbort = (): void => finish(() => reject(abortReason(signal))) + + signal.addEventListener('abort', onAbort, { once: true }) + try { + Promise.resolve(reader.read()).then( + result => finish(() => resolve(result)), + error => finish(() => reject(error)) + ) + } catch (error) { + finish(() => reject(error)) + } + + // Do not miss an abort that happened while registering the listener. + if (signal.aborted) onAbort() + }) +} + +function cleanUpFailedRead(reader: ReadableStreamDefaultReader, reason: unknown): void { + try { + void Promise.resolve(reader.cancel(reason)).catch(() => undefined) + } catch { + // A broken stream implementation must not replace the response read error. + } + + try { + reader.releaseLock() + } catch { + // The lock may already have been released by a nonstandard stream. + } +} + +function expandedBuffer( + buffer: Uint8Array, + requiredLength: number, + maxResponseBytes: number +): Uint8Array { + if (requiredLength <= buffer.byteLength) return buffer + + const initialCapacity = Math.min(maxResponseBytes, 1024) + const doubledCapacity = Math.min(maxResponseBytes, buffer.byteLength * 2) + const capacity = Math.max( + requiredLength, + buffer.byteLength === 0 ? initialCapacity : doubledCapacity + ) + const expanded = new Uint8Array(capacity) + expanded.set(buffer) + return expanded +} + +function yieldToEventLoop(): Promise { + return new Promise(resolve => setTimeout(resolve, 0)) +} + +async function yieldAfterReadIfNeeded( + readOperations: number, + signal: AbortSignal | undefined +): Promise { + if (readOperations % 64 !== 0) return + await yieldToEventLoop() + if (signal?.aborted) throw abortReason(signal) +} + +/** + * Reads a lookup response incrementally while enforcing a per-response bound. + * + * This deliberately does not use Response.text(), json(), or arrayBuffer(), + * because those APIs buffer the complete body before a limit can be enforced. + */ +export async function readLookupResponseBytes( + response: Response, + options: LookupResponseReaderOptions +): Promise { + const { signal, maxResponseBytes, consumeBytes } = options + assertValidMaximum(maxResponseBytes) + + const body = response.body + if (body === null) { + assertDeclaredLengthIsWithinLimit(response, maxResponseBytes) + if (signal?.aborted === true) throw abortReason(signal) + return new Uint8Array(0) + } + + const reader = body.getReader() + let succeeded = false + let failure: unknown + try { + assertDeclaredLengthIsWithinLimit(response, maxResponseBytes) + if (signal?.aborted === true) throw abortReason(signal) + + let bytes: Uint8Array = new Uint8Array(0) + let totalLength = 0 + let readOperations = 0 + while (true) { + const { done, value } = await readWithAbort(reader, signal) + readOperations++ + if (done) break + + if (value.byteLength === 0) { + // An eagerly fulfilled read() still schedules only microtasks. Yielding + // periodically lets timers deliver cancellation for endless empty input. + await yieldAfterReadIfNeeded(readOperations, signal) + continue + } + + if (value.byteLength > maxResponseBytes - totalLength) { + throw new LookupResourceLimitError('maxResponseBytes') + } + + consumeBytes?.(value.byteLength) + const nextLength = totalLength + value.byteLength + bytes = expandedBuffer(bytes, nextLength, maxResponseBytes) + // Streams are allowed to reuse a producer-owned Uint8Array. Copy each + // accepted chunk now instead of retaining a mutable producer reference. + bytes.set(value, totalLength) + totalLength = nextLength + // Copy before yielding: a producer may reuse or mutate its buffer while + // the task queue runs. + await yieldAfterReadIfNeeded(readOperations, signal) + } + + succeeded = true + return bytes.subarray(0, totalLength) + } catch (error) { + failure = error + throw error + } finally { + if (succeeded) { + try { + reader.releaseLock() + } catch { + // A nonstandard stream may have released its lock itself. + } + } else { + cleanUpFailedRead(reader, failure) + } + } +} diff --git a/packages/sdk/src/overlay-tools/__tests/LookupResolver.additional.test.ts b/packages/sdk/src/overlay-tools/__tests/LookupResolver.additional.test.ts index 02aff25a4..358dbba5f 100644 --- a/packages/sdk/src/overlay-tools/__tests/LookupResolver.additional.test.ts +++ b/packages/sdk/src/overlay-tools/__tests/LookupResolver.additional.test.ts @@ -15,6 +15,15 @@ const mockFacilitator = { lookup: jest.fn() } +const jsonResponse = (body: unknown, status = 200): Response => + new Response(JSON.stringify(body), { + status, + headers: { 'content-type': 'application/json' } + }) + +const octetResponse = (payload: Uint8Array): Response => + new Response(payload, { headers: { 'content-type': 'application/octet-stream' } }) + // -------------------------------------------------------------------------- // Sample BEEFs for use in tests // -------------------------------------------------------------------------- @@ -469,11 +478,9 @@ describe('LookupResolver – additional coverage', () => { }) it('allows HTTP URLs when allowHTTP is true', async () => { - const mockFetch = jest.fn().mockResolvedValue({ - ok: true, - headers: { get: () => 'application/json' }, - json: async () => ({ type: 'output-list', outputs: [] }) - }) + const mockFetch = jest + .fn() + .mockResolvedValue(jsonResponse({ type: 'output-list', outputs: [] })) const facilitator = new HTTPSOverlayLookupFacilitator(mockFetch, true) const result = await facilitator.lookup('http://localhost:8080', { service: 'ls_test', @@ -483,12 +490,7 @@ describe('LookupResolver – additional coverage', () => { }) it('handles HTTP error responses by throwing', async () => { - const mockFetch = jest.fn().mockResolvedValue({ - ok: false, - status: 503, - headers: { get: () => 'application/json' }, - json: async () => ({}) - }) + const mockFetch = jest.fn().mockResolvedValue(jsonResponse({}, 503)) const facilitator = new HTTPSOverlayLookupFacilitator(mockFetch, true) await expect( facilitator.lookup('http://host', { service: 'ls_test', query: {} }) @@ -544,12 +546,7 @@ describe('LookupResolver – additional coverage', () => { const beefBuf = Buffer.from(beef) const payload = Buffer.concat([nOutpoints, txid, outputIndex, contextLen, beefBuf]) - const mockFetch = jest.fn().mockResolvedValue({ - ok: true, - headers: { get: () => 'application/octet-stream' }, - arrayBuffer: async () => - payload.buffer.slice(payload.byteOffset, payload.byteOffset + payload.byteLength) - }) + const mockFetch = jest.fn().mockResolvedValue(octetResponse(payload)) const facilitator = new HTTPSOverlayLookupFacilitator(mockFetch, true) const result = await facilitator.lookup('http://host', { service: 'ls_test', query: {} }) @@ -581,12 +578,9 @@ describe('LookupResolver – additional coverage', () => { 'Application/Octet-Stream', ' application/octet-stream ' ]) { - const mockFetch = jest.fn().mockResolvedValue({ - ok: true, - headers: { get: () => header }, - arrayBuffer: async () => - payload.buffer.slice(payload.byteOffset, payload.byteOffset + payload.byteLength) - }) + const mockFetch = jest + .fn() + .mockResolvedValue(new Response(payload, { headers: { 'content-type': header } })) const facilitator = new HTTPSOverlayLookupFacilitator(mockFetch, true) const result = await facilitator.lookup('https://host', { service: 'ls_test', query: {} }) expect(result.type).toBe('output-list') @@ -620,12 +614,7 @@ describe('LookupResolver – additional coverage', () => { beefBuf ]) - const mockFetch = jest.fn().mockResolvedValue({ - ok: true, - headers: { get: () => 'application/octet-stream' }, - arrayBuffer: async () => - payload.buffer.slice(payload.byteOffset, payload.byteOffset + payload.byteLength) - }) + const mockFetch = jest.fn().mockResolvedValue(octetResponse(payload)) const facilitator = new HTTPSOverlayLookupFacilitator(mockFetch, true) const result = await facilitator.lookup('http://host', { service: 'ls_test', query: {} }) @@ -688,11 +677,9 @@ describe('LookupResolver – additional coverage', () => { }) it('sends correct request body to /lookup endpoint', async () => { - const mockFetch = jest.fn().mockResolvedValue({ - ok: true, - headers: { get: () => 'application/json' }, - json: async () => ({ type: 'output-list', outputs: [] }) - }) + const mockFetch = jest + .fn() + .mockResolvedValue(jsonResponse({ type: 'output-list', outputs: [] })) const facilitator = new HTTPSOverlayLookupFacilitator(mockFetch, true) const question = { service: 'ls_test', query: { filter: 'abc' } } await facilitator.lookup('http://host', question) diff --git a/packages/sdk/src/overlay-tools/__tests/LookupResolver.dynamic.test.ts b/packages/sdk/src/overlay-tools/__tests/LookupResolver.dynamic.test.ts new file mode 100644 index 000000000..a8a78f552 --- /dev/null +++ b/packages/sdk/src/overlay-tools/__tests/LookupResolver.dynamic.test.ts @@ -0,0 +1,841 @@ +import LookupResolver, { LookupAnswerProgress } from '../LookupResolver' +import { getOverlayHostReputationTracker } from '../HostReputationTracker' +import OverlayAdminTokenTemplate from '../OverlayAdminTokenTemplate' +import { CompletedProtoWallet } from '../../auth/certificates/__tests/CompletedProtoWallet' +import { PrivateKey } from '../../primitives/index' +import { LockingScript } from '../../script/index' +import { Transaction } from '../../transaction/index' + +const makeBeef = (satoshis: number): number[] => + new Transaction(1, [], [{ lockingScript: LockingScript.fromHex('88'), satoshis }], 0).toBEEF() + +const later = async (ms: number): Promise => { + await new Promise(resolve => setTimeout(resolve, ms)) +} + +async function overlayReceipt( + protocol: 'SHIP' | 'SLAP', + scalar: number, + domain: string, + topicOrService: string +): Promise<{ beef: number[]; outputIndex: number }> { + const wallet = new CompletedProtoWallet(new PrivateKey(scalar)) + const token = new OverlayAdminTokenTemplate(wallet) + const lockingScript = await token.lock(protocol, domain, topicOrService) + const transaction = new Transaction(1, [], [{ lockingScript, satoshis: 1 }], 0) + return { beef: transaction.toBEEF(), outputIndex: 0 } +} + +async function slapReceipt( + scalar: number, + domain: string, + service: string +): Promise<{ beef: number[]; outputIndex: number }> { + return await overlayReceipt('SLAP', scalar, domain, service) +} + +describe('LookupResolver dynamic discovery', () => { + beforeEach(() => { + getOverlayHostReputationTracker().reset() + jest.useFakeTimers() + }) + + afterEach(() => { + jest.useRealTimers() + }) + + it('starts a discovered host without waiting for a slower tracker and merges only the late host contribution', async () => { + const fastTracker = 'https://fast-tracker.example' + const slowTracker = 'https://slow-tracker.example' + const fastHost = 'https://fast-host.example' + const lateHost = 'https://late-host.example' + const service = 'ls_dynamic' + const fastReceipt = await slapReceipt(101, fastHost, service) + const lateReceipt = await slapReceipt(102, lateHost, service) + const fastBeef = makeBeef(1) + const lateBeef = makeBeef(2) + const calls: Array<{ url: string; service: string }> = [] + const evidence: Array<{ type: string; host?: string }> = [] + + const lookup = jest.fn(async (url: string, question: { service: string }) => { + calls.push({ url, service: question.service }) + if (url === fastTracker) { + await later(10) + return { type: 'output-list' as const, outputs: [fastReceipt] } + } + if (url === slowTracker) { + await later(100) + return { type: 'output-list' as const, outputs: [lateReceipt] } + } + if (url === fastHost) { + await later(1) + return { type: 'output-list' as const, outputs: [{ beef: fastBeef, outputIndex: 0 }] } + } + if (url === lateHost) { + await later(1) + return { type: 'output-list' as const, outputs: [{ beef: lateBeef, outputIndex: 1 }] } + } + throw new Error(`unexpected host ${url}`) + }) + const resolver = new LookupResolver({ + facilitator: { lookup }, + slapTrackers: [fastTracker, slowTracker] + }) + const progress: LookupAnswerProgress[] = [] + const pending = (async () => { + for await (const item of resolver.query$({ service, query: { q: 1 } }, undefined, { + graceMs: 0, + onEvidence: event => evidence.push(event) + })) { + progress.push({ ...item, outputs: item.outputs.slice() }) + } + })() + + await jest.advanceTimersByTimeAsync(20) + expect(calls).toContainEqual({ url: fastHost, service }) + expect(calls).not.toContainEqual({ url: lateHost, service }) + expect(calls.filter(call => call.url === fastTracker || call.url === slowTracker)).toEqual([ + { url: fastTracker, service: 'ls_slap' }, + { url: slowTracker, service: 'ls_slap' } + ]) + expect(evidence).toEqual( + expect.arrayContaining([expect.objectContaining({ type: 'output', host: fastHost })]) + ) + + await jest.advanceTimersByTimeAsync(200) + await pending + + const final = progress.at(-1) + expect(final?.isFinal).toBe(true) + expect(final?.outputs).toEqual([ + { beef: fastBeef, outputIndex: 0 }, + { beef: lateBeef, outputIndex: 1 } + ]) + expect(final).toMatchObject({ discoveryComplete: true, trackersTotal: 2, trackersCompleted: 2 }) + // The SLAP receipt is discovery evidence only; it never becomes a topic result. + expect( + final?.outputs.some( + output => output.beef === fastReceipt.beef || output.beef === lateReceipt.beef + ) + ).toBe(false) + }) + + it('queries only eligible advertised hosts for the requested service', async () => { + const fooTracker = 'https://foo-tracker.example' + const barTracker = 'https://bar-tracker.example' + const shipTracker = 'https://ship-tracker.example' + const offlineTracker = 'https://offline-tracker.example' + const fooHost = 'https://foo-host.example' + const barHost = 'https://bar-host.example' + const shipHost = 'https://ship-host.example' + const service = 'ls_foo' + const fooReceipt = await slapReceipt(201, fooHost, service) + const barReceipt = await slapReceipt(202, barHost, 'ls_bar') + const shipReceipt = await overlayReceipt('SHIP', 203, shipHost, service) + const hostCalls: string[] = [] + const lookup = jest.fn(async (url: string, question: { service: string }) => { + if (url === fooTracker) return { type: 'output-list' as const, outputs: [fooReceipt] } + if (url === barTracker) return { type: 'output-list' as const, outputs: [barReceipt] } + if (url === shipTracker) return { type: 'output-list' as const, outputs: [shipReceipt] } + if (url === offlineTracker) throw new Error('tracker offline') + hostCalls.push(url) + expect(question.service).toBe(service) + return { type: 'output-list' as const, outputs: [{ beef: makeBeef(9), outputIndex: 0 }] } + }) + const resolver = new LookupResolver({ + facilitator: { lookup }, + slapTrackers: [fooTracker, barTracker, shipTracker, offlineTracker] + }) + const pending = resolver.query({ service, query: {} }) + await jest.runAllTimersAsync() + await expect(pending).resolves.toEqual({ + type: 'output-list', + outputs: [{ beef: makeBeef(9), outputIndex: 0 }] + }) + + expect(hostCalls).toEqual([fooHost]) + expect(lookup.mock.calls.map(([url]) => url)).toEqual( + expect.arrayContaining([fooTracker, barTracker, shipTracker, offlineTracker]) + ) + }) + + it('delivers a useful host while another peer never finishes, then settles after the 2s host bound', async () => { + const usefulHost = 'https://useful-hang.example' + const hangingHost = 'https://hanging-peer.example' + const usefulBeef = makeBeef(11) + const lookup = jest.fn(async (url: string, _question: unknown, timeout?: number) => { + expect(timeout).toBeUndefined() + if (url === hangingHost) await new Promise(() => {}) + await later(20) + return { type: 'output-list' as const, outputs: [{ beef: usefulBeef, outputIndex: 0 }] } + }) + const resolver = new LookupResolver({ + facilitator: { lookup } as any, + hostOverrides: { ls_hang: [usefulHost, hangingHost] } + }) + const progress: LookupAnswerProgress[] = [] + const pending = (async () => { + for await (const item of resolver.query$({ service: 'ls_hang', query: {} }, undefined, { + graceMs: 0 + })) { + progress.push({ ...item, outputs: item.outputs.slice() }) + } + })() + + await jest.advanceTimersByTimeAsync(100) + expect(progress.some(item => !item.isFinal && item.outputs.length > 0)).toBe(true) + expect(progress.find(item => item.outputs.length > 0)?.outputs).toEqual([ + { beef: usefulBeef, outputIndex: 0 } + ]) + expect(progress.at(-1)?.isFinal).toBe(false) + + await jest.advanceTimersByTimeAsync(2000) + await pending + expect(progress.at(-1)).toMatchObject({ + isFinal: true, + terminalReason: 'settled', + successfulHosts: 1, + failedHosts: 1, + outputs: [{ beef: usefulBeef, outputIndex: 0 }] + }) + }) + + it('unblocks a pending iterator once on abort and reports a cancelled terminal snapshot', async () => { + const host = 'https://pending.example' + const controller = new AbortController() + let requestSignal: AbortSignal | undefined + const lookup = jest.fn( + async (_url: string, _question: unknown, _timeout: unknown, signal?: AbortSignal) => + await new Promise((_resolve, reject) => { + requestSignal = signal + signal?.addEventListener('abort', () => reject(signal.reason), { once: true }) + }) + ) + const resolver = new LookupResolver({ + facilitator: { lookup } as any, + hostOverrides: { ls_abort: [host] } + }) + const iterator = resolver + .query$({ service: 'ls_abort', query: {} }, undefined, { + signal: controller.signal + } as any) + [Symbol.asyncIterator]() + + const first = iterator.next() + await Promise.resolve() + controller.abort(new Error('caller stopped lookup')) + + const terminal = await first + expect(requestSignal?.aborted).toBe(true) + expect(terminal.done).toBe(false) + expect(terminal.value).toMatchObject({ + isFinal: true, + terminalReason: 'cancelled' + }) + await expect(iterator.next()).resolves.toEqual({ done: true, value: undefined }) + }) + + it('emits a deadline terminal snapshot when no host receipt arrives', async () => { + const host = 'https://deadline.example' + const lookup = jest.fn( + async (_url: string, _question: unknown, _timeout: unknown, signal?: AbortSignal) => + await new Promise((_resolve, reject) => { + signal?.addEventListener('abort', () => reject(signal.reason), { once: true }) + }) + ) + const resolver = new LookupResolver({ + facilitator: { lookup } as any, + hostOverrides: { ls_deadline: [host] } + }) + const received: LookupAnswerProgress[] = [] + const pending = (async () => { + for await (const item of resolver.query$({ service: 'ls_deadline', query: {} }, undefined, { + deadlineMs: 25 + } as any)) { + received.push(item) + } + })() + + await jest.advanceTimersByTimeAsync(25) + await pending + + expect(received).toHaveLength(1) + expect(received[0]).toMatchObject({ + isFinal: true, + terminalReason: 'deadline', + outputs: [] + }) + }) + + it('keeps empty, failed, and freeform host receipts distinct at a settled terminal', async () => { + const emptyHost = 'https://empty.example' + const freeformHost = 'https://freeform.example' + const failedHost = 'https://failed.example' + const lookup = jest.fn(async (url: string) => { + if (url === emptyHost) return { type: 'output-list' as const, outputs: [] } + if (url === freeformHost) return { type: 'freeform' as const, result: { supported: false } } + throw new Error('offline') + }) + const resolver = new LookupResolver({ + facilitator: { lookup }, + hostOverrides: { ls_outcomes: [emptyHost, freeformHost, failedHost] } + }) + const values: LookupAnswerProgress[] = [] + const pending = (async () => { + for await (const item of resolver.query$({ service: 'ls_outcomes', query: {} })) + values.push(item) + })() + await jest.runAllTimersAsync() + await pending + + expect(values).toHaveLength(1) + expect(values[0]).toMatchObject({ + isFinal: true, + terminalReason: 'settled', + emptyHosts: 1, + freeformHosts: 1, + failedHosts: 1, + rejectedHosts: 0 + }) + }) + + it('caps evidence delivery independently and reports the limit evidence', async () => { + const host = 'https://bounded.example' + const first = makeBeef(31) + const second = makeBeef(32) + const evidence: Array<{ type: string }> = [] + const resolver = new LookupResolver({ + facilitator: { + lookup: async () => ({ + type: 'output-list', + outputs: [ + { beef: first, outputIndex: 0 }, + { beef: second, outputIndex: 1 } + ] + }) + }, + hostOverrides: { ls_bounded: [host] } + }) + const values: LookupAnswerProgress[] = [] + const pending = (async () => { + for await (const item of resolver.query$({ service: 'ls_bounded', query: {} }, undefined, { + limits: { maxEvidenceOutputs: 1 }, + onEvidence: event => evidence.push(event) + } as any)) { + values.push(item) + } + })() + await jest.runAllTimersAsync() + await pending + + expect(values.at(-1)).toMatchObject({ + isFinal: true, + terminalReason: 'resource-limit', + limitsHit: expect.arrayContaining(['maxEvidenceOutputs']) + }) + expect(values.at(-1)?.outputs).toHaveLength(2) + expect(evidence).toEqual(expect.arrayContaining([expect.objectContaining({ type: 'limit' })])) + }) + + it('shares one tracker discovery between queries and keeps it alive when one subscriber cancels', async () => { + const tracker = 'https://shared-tracker.example' + const host = 'https://shared-host.example' + const receipt = await slapReceipt(103, host, 'ls_shared') + let completeTracker: (() => void) | undefined + let trackerSignal: AbortSignal | undefined + const lookup = jest.fn( + async (url: string, _question: unknown, _timeout: unknown, signal?: AbortSignal) => { + if (url === tracker) { + trackerSignal = signal + await new Promise(resolve => { + completeTracker = resolve + }) + return { type: 'output-list' as const, outputs: [receipt] } + } + return { type: 'output-list' as const, outputs: [{ beef: makeBeef(41), outputIndex: 0 }] } + } + ) + const resolver = new LookupResolver({ facilitator: { lookup } as any, slapTrackers: [tracker] }) + const firstAbort = new AbortController() + const first = resolver + .query$({ service: 'ls_shared', query: { caller: 1 } }, undefined, { + signal: firstAbort.signal + } as any) + [Symbol.asyncIterator]() + const secondProgress: LookupAnswerProgress[] = [] + const second = (async () => { + for await (const progress of resolver.query$({ + service: 'ls_shared', + query: { caller: 2 } + })) { + secondProgress.push(progress) + } + })() + + const firstPending = first.next() + await Promise.resolve() + firstAbort.abort() + await expect(firstPending).resolves.toMatchObject({ value: { terminalReason: 'cancelled' } }) + expect(trackerSignal?.aborted).toBe(false) + completeTracker?.() + await jest.runAllTimersAsync() + await second + expect(secondProgress.at(-1)).toMatchObject({ + isFinal: true, + terminalReason: 'settled', + outputs: [{ beef: makeBeef(41), outputIndex: 0 }] + }) + expect(lookup.mock.calls.filter(([url]) => url === tracker)).toHaveLength(1) + }) + + it('does not let an abandoned custom-facilitator completion leak into a later query or its evidence', async () => { + const host = 'https://late-custom.example' + const staleBeef = makeBeef(51) + const freshBeef = makeBeef(52) + let resolveStale: + | ((value: { + type: 'output-list' + outputs: Array<{ beef: number[]; outputIndex: number }> + }) => void) + | undefined + let calls = 0 + const lookup = jest.fn(() => { + calls++ + if (calls === 1) { + return new Promise<{ + type: 'output-list' + outputs: Array<{ beef: number[]; outputIndex: number }> + }>(resolve => { + resolveStale = resolve + }) + } + return Promise.resolve({ + type: 'output-list' as const, + outputs: [{ beef: freshBeef, outputIndex: 0 }] + }) + }) + const resolver = new LookupResolver({ + facilitator: { lookup } as any, + hostOverrides: { ls_late_custom: [host] } + }) + const abort = new AbortController() + const abandonedEvidence: Array<{ type: string }> = [] + const abandoned = resolver + .query$({ service: 'ls_late_custom', query: { generation: 1 } }, undefined, { + signal: abort.signal, + onEvidence: event => abandonedEvidence.push(event) + } as any) + [Symbol.asyncIterator]() + const abandonedPending = abandoned.next() + await Promise.resolve() + abort.abort() + await abandonedPending + + const next = resolver.query({ service: 'ls_late_custom', query: { generation: 2 } }) + resolveStale?.({ type: 'output-list', outputs: [{ beef: staleBeef, outputIndex: 0 }] }) + await jest.runAllTimersAsync() + await expect(next).resolves.toEqual({ + type: 'output-list', + outputs: [{ beef: freshBeef, outputIndex: 0 }] + }) + expect(abandonedEvidence).toEqual([{ type: 'limit' }]) + }) + + it('stops evidence and aggregate intake when the first evidence callback aborts', async () => { + const host = 'https://callback-abort.example' + const controller = new AbortController() + const events: Array<{ type: string; output?: unknown }> = [] + const resolver = new LookupResolver({ + facilitator: { + lookup: async () => ({ + type: 'output-list' as const, + outputs: [ + { beef: makeBeef(201), outputIndex: 0 }, + { beef: makeBeef(202), outputIndex: 1 }, + { beef: makeBeef(203), outputIndex: 2 } + ] + }) + }, + hostOverrides: { ls_callback_abort: [host] } + }) + const iterator = resolver + .query$({ service: 'ls_callback_abort', query: {} }, undefined, { + signal: controller.signal, + onEvidence: event => { + events.push(event) + if (event.type === 'output') controller.abort() + } + }) + [Symbol.asyncIterator]() + + const terminal = await iterator.next() + expect(terminal.value).toMatchObject({ + isFinal: true, + terminalReason: 'cancelled', + outputs: [] + }) + expect(events.filter(event => event.type === 'output')).toHaveLength(1) + expect(events.filter(event => event.type === 'limit')).toHaveLength(1) + await expect(iterator.next()).resolves.toEqual({ done: true, value: undefined }) + }) + + it('aborts and settles a pending iterator when return() is called', async () => { + const host = 'https://return-pending.example' + let requestSignal: AbortSignal | undefined + const resolver = new LookupResolver({ + facilitator: { + lookup: async (_url, _question, _timeout, signal) => + await new Promise((_resolve, reject) => { + requestSignal = signal + signal?.addEventListener('abort', () => reject(signal.reason), { once: true }) + }) + }, + hostOverrides: { ls_return_pending: [host] } + }) + const iterator = resolver + .query$({ service: 'ls_return_pending', query: {} }) + [Symbol.asyncIterator]() + const pending = iterator.next() + await Promise.resolve() + await expect(iterator.return?.()).resolves.toEqual({ done: true, value: undefined }) + expect(requestSignal?.aborted).toBe(true) + await expect(pending).resolves.toMatchObject({ + done: false, + value: { isFinal: true, terminalReason: 'cancelled' } + }) + }) + + it('cleans terminal queries before yielding so 130 final-only consumers do not exhaust slots', async () => { + const resolver = new LookupResolver({ + facilitator: { lookup: async () => ({ type: 'output-list' as const, outputs: [] }) }, + hostOverrides: { ls_final_only: ['https://final-only.example'] } + }) + for (let index = 0; index < 130; index++) { + const first = await resolver + .query$({ service: 'ls_final_only', query: { index } }) + [Symbol.asyncIterator]() + .next() + expect(first.value?.isFinal).toBe(true) + } + expect((resolver as any).activeQueries).toBe(0) + }) + + it('enforces decoded BEEF and context retention across hosts independently of reported wire bytes', async () => { + const firstHost = 'https://decoded-one.example' + const secondHost = 'https://decoded-two.example' + const firstOutput = { beef: makeBeef(211), outputIndex: 0, context: Array(10).fill(7) } + const secondOutput = { beef: makeBeef(212), outputIndex: 1, context: Array(10).fill(8) } + const resolver = new LookupResolver({ + facilitator: { + lookup: async (host, _question, _timeout, _signal, requestOptions) => { + requestOptions?.consumeBytes?.(1) + return { + type: 'output-list' as const, + outputs: [host === firstHost ? firstOutput : secondOutput] + } + } + }, + hostOverrides: { ls_decoded_budget: [firstHost, secondHost] } + }) + const progress: LookupAnswerProgress[] = [] + const pending = (async () => { + for await (const item of resolver.query$( + { service: 'ls_decoded_budget', query: {} }, + undefined, + { + limits: { + hostConcurrency: 1, + maxTotalBytes: firstOutput.beef.length + firstOutput.context.length + } + } + )) + progress.push(item) + })() + await jest.runAllTimersAsync() + await pending + + expect(progress.at(-1)).toMatchObject({ + isFinal: true, + terminalReason: 'resource-limit', + limitsHit: expect.arrayContaining(['maxTotalBytes']), + receivedBytes: 2, + retainedBytes: firstOutput.beef.length + firstOutput.context.length, + outputs: [firstOutput] + }) + }) + + it('normalizes duplicate configured endpoints and never exceeds the host concurrency budget', async () => { + const one = 'https://one.example' + const two = 'https://two.example' + const three = 'https://three.example' + const calls: string[] = [] + let active = 0 + let peak = 0 + const resolver = new LookupResolver({ + facilitator: { + lookup: async (host: string) => { + calls.push(host) + active++ + peak = Math.max(peak, active) + await later(20) + active-- + return { type: 'output-list' as const, outputs: [] } + } + }, + hostOverrides: { ls_concurrency: [`${one}/`, one, two, three] } + }) + const pending = resolver.query({ service: 'ls_concurrency', query: {} }, undefined, { + limits: { hostConcurrency: 2 } + }) + await jest.runAllTimersAsync() + await pending + + expect(calls).toEqual(expect.arrayContaining([one, two, three])) + expect(calls.filter(host => host === one)).toHaveLength(1) + expect(peak).toBeLessThanOrEqual(2) + }) + + it('gives a later tracker source a turn before draining an earlier tracker flood', async () => { + const firstTracker = 'https://first-tracker.example' + const lateTracker = 'https://late-tracker.example' + const floodHosts = [ + 'https://flood-1.example', + 'https://flood-2.example', + 'https://flood-3.example' + ] + const lateHost = 'https://late-fair.example' + const service = 'ls_fair' + const receipts = await Promise.all([ + ...floodHosts.map((host, index) => slapReceipt(110 + index, host, service)), + slapReceipt(120, lateHost, service) + ]) + const hostCalls: string[] = [] + const resolver = new LookupResolver({ + facilitator: { + lookup: async (host: string) => { + if (host === firstTracker) + return { type: 'output-list' as const, outputs: receipts.slice(0, 3) } + if (host === lateTracker) { + await later(1) + return { type: 'output-list' as const, outputs: [receipts[3]] } + } + hostCalls.push(host) + await later(20) + return { type: 'output-list' as const, outputs: [] } + } + }, + slapTrackers: [firstTracker, lateTracker] + }) + const pending = resolver.query({ service, query: {} }, undefined, { + limits: { hostConcurrency: 1 } + }) + await jest.runAllTimersAsync() + await pending + + expect(hostCalls).toContain(lateHost) + expect(hostCalls.indexOf(lateHost)).toBeLessThan(hostCalls.indexOf(floodHosts[1])) + }) + + it('joins an in-flight refresh even after a stale cached host becomes fresh for a later query', async () => { + const tracker = 'https://refresh-tracker.example' + const cachedHost = 'https://cached-refresh.example' + const lateHost = 'https://late-refresh.example' + const service = 'ls_refresh' + const receipt = await slapReceipt(130, lateHost, service) + const cachedBeef = makeBeef(61) + const lateBeef = makeBeef(62) + let finishRefresh: (() => void) | undefined + const lookup = jest.fn(async (host: string) => { + if (host === tracker) { + await new Promise(resolve => { + finishRefresh = resolve + }) + return { type: 'output-list' as const, outputs: [receipt] } + } + return { + type: 'output-list' as const, + outputs: [{ beef: host === cachedHost ? cachedBeef : lateBeef, outputIndex: 0 }] + } + }) + const resolver = new LookupResolver({ facilitator: { lookup }, slapTrackers: [tracker] }) + ;(resolver as any).hostsCache.set(service, { hosts: [cachedHost], expiresAt: 0 }) + + const first = resolver.query$({ service, query: { caller: 1 } })[Symbol.asyncIterator]() + const firstPending = first.next() + await Promise.resolve() + ;(resolver as any).hostsCache.set(service, { + hosts: [cachedHost], + expiresAt: Date.now() + 60_000 + }) + const secondProgress: LookupAnswerProgress[] = [] + const second = (async () => { + for await (const progress of resolver.query$({ service, query: { caller: 2 } })) { + secondProgress.push(progress) + } + })() + + finishRefresh?.() + await jest.runAllTimersAsync() + await firstPending + await first.return?.() + await second + + expect(lookup.mock.calls.filter(([host]) => host === tracker)).toHaveLength(1) + expect(secondProgress.at(-1)?.outputs).toEqual( + expect.arrayContaining([ + { beef: cachedBeef, outputIndex: 0 }, + { beef: lateBeef, outputIndex: 0 } + ]) + ) + }) + + it('bounds tracker work by trackerConcurrency', async () => { + const trackers = [ + 'https://tracker-one.example', + 'https://tracker-two.example', + 'https://tracker-three.example' + ] + const service = 'ls_tracker_bound' + const host = 'https://tracker-bound-host.example' + const receipt = await slapReceipt(140, host, service) + let active = 0 + let peak = 0 + const resolver = new LookupResolver({ + facilitator: { + lookup: async (url: string) => { + if (trackers.includes(url)) { + active++ + peak = Math.max(peak, active) + await later(20) + active-- + return { type: 'output-list' as const, outputs: url === trackers[0] ? [receipt] : [] } + } + return { type: 'output-list' as const, outputs: [] } + } + }, + slapTrackers: trackers + }) + const pending = resolver.query({ service, query: {} }, undefined, { + limits: { trackerConcurrency: 1 } + }) + await jest.runAllTimersAsync() + await pending + expect(peak).toBe(1) + }) + + it('isolates a slow or throwing evidence listener so the terminal snapshot still arrives', async () => { + const host = 'https://listener.example' + let evidenceCalls = 0 + const resolver = new LookupResolver({ + facilitator: { + lookup: async () => ({ + type: 'output-list' as const, + outputs: [ + { beef: makeBeef(71), outputIndex: 0 }, + { beef: makeBeef(72), outputIndex: 1 } + ] + }) + }, + hostOverrides: { ls_listener: [host] } + }) + const progress: LookupAnswerProgress[] = [] + const pending = (async () => { + for await (const item of resolver.query$({ service: 'ls_listener', query: {} }, undefined, { + onEvidence: () => { + evidenceCalls++ + if (evidenceCalls === 1) return new Promise(() => {}) + throw new Error('consumer failed') + } + })) + progress.push(item) + })() + await jest.runAllTimersAsync() + await pending + expect(progress.at(-1)).toMatchObject({ + isFinal: true, + terminalReason: 'settled', + successfulHosts: 1 + }) + expect(evidenceCalls).toBe(2) + }) + + it('does not reuse a cancelled last-subscriber discovery when its old tracker completes late', async () => { + const tracker = 'https://abandoned-tracker.example' + const staleHost = 'https://abandoned-stale.example' + const freshHost = 'https://abandoned-fresh.example' + const service = 'ls_abandoned' + const staleReceipt = await slapReceipt(150, staleHost, service) + const freshReceipt = await slapReceipt(151, freshHost, service) + let resolveOld: (() => void) | undefined + let trackerCalls = 0 + const lookup = jest.fn(async (url: string) => { + if (url === tracker) { + trackerCalls++ + if (trackerCalls === 1) { + await new Promise(resolve => { + resolveOld = resolve + }) + return { type: 'output-list' as const, outputs: [staleReceipt] } + } + return { type: 'output-list' as const, outputs: [freshReceipt] } + } + return { + type: 'output-list' as const, + outputs: [{ beef: makeBeef(url === freshHost ? 81 : 80), outputIndex: 0 }] + } + }) + const resolver = new LookupResolver({ facilitator: { lookup }, slapTrackers: [tracker] }) + const abort = new AbortController() + const abandoned = resolver + .query$({ service, query: { attempt: 1 } }, undefined, { + signal: abort.signal + }) + [Symbol.asyncIterator]() + const terminal = abandoned.next() + await Promise.resolve() + abort.abort() + await terminal + resolveOld?.() + await Promise.resolve() + + const fresh = resolver.query({ service, query: { attempt: 2 } }) + await jest.runAllTimersAsync() + await expect(fresh).resolves.toEqual({ + type: 'output-list', + outputs: [{ beef: makeBeef(81), outputIndex: 0 }] + }) + expect(trackerCalls).toBe(2) + }) + + it('keeps the ordinary 2s host and 5s tracker attempts within the 10s query deadline', async () => { + const usefulTracker = 'https://useful-4500.example' + const hangingTracker = 'https://hanging-tracker.example' + const discoveredHost = 'https://discovered-1500.example' + const service = 'ls_default_deadline' + const receipt = await slapReceipt(160, discoveredHost, service) + const lookup = jest.fn(async (url: string, _question: unknown, timeout?: number) => { + if (url === usefulTracker) { + expect(timeout).toBe(5000) + await later(4500) + return { type: 'output-list' as const, outputs: [receipt] } + } + if (url === hangingTracker) { + expect(timeout).toBe(5000) + await new Promise(() => {}) + } + expect(timeout).toBeUndefined() + await later(1500) + return { type: 'output-list' as const, outputs: [{ beef: makeBeef(91), outputIndex: 0 }] } + }) + const resolver = new LookupResolver({ + facilitator: { lookup } as any, + slapTrackers: [usefulTracker, hangingTracker] + }) + const pending = resolver.query({ service, query: {} }) + await jest.advanceTimersByTimeAsync(6_100) + await expect(pending).resolves.toEqual({ + type: 'output-list', + outputs: [{ beef: makeBeef(91), outputIndex: 0 }] + }) + }) +}) diff --git a/packages/sdk/src/overlay-tools/__tests/LookupResolver.http.test.ts b/packages/sdk/src/overlay-tools/__tests/LookupResolver.http.test.ts new file mode 100644 index 000000000..d90721793 --- /dev/null +++ b/packages/sdk/src/overlay-tools/__tests/LookupResolver.http.test.ts @@ -0,0 +1,272 @@ +import { createServer } from 'node:http' +import type { Server } from 'node:http' +import type { Socket } from 'node:net' +import LookupResolver, { HTTPSOverlayLookupFacilitator } from '../LookupResolver.js' +import { getOverlayHostReputationTracker } from '../HostReputationTracker.js' +import { Beef, Transaction } from '../../transaction/index.js' +import { LockingScript } from '../../script/index.js' + +interface LookupServer { + url: string + close: () => Promise +} + +/** + * This uses SDK serialization to make a structurally parseable receipt. It is + * deliberately synthetic: parsing BEEF here does not make a cryptographic or + * chain-validity claim. + */ +function structuralOutputListFixture(scriptBytes = 48 * 1024): { + answer: { + type: 'output-list' + outputs: Array<{ beef: number[]; outputIndex: number; context: number[] }> + } + evidenceBytes: number + wire: Buffer +} { + const transaction = new Transaction( + 1, + [], + [{ lockingScript: LockingScript.fromHex('00'.repeat(scriptBytes)), satoshis: 1 }], + 0 + ) + const beef = Beef.fromBinary(transaction.toBEEF()).toBinary() + const answer = { type: 'output-list' as const, outputs: [{ beef, outputIndex: 0 }] } + return { + answer, + evidenceBytes: beef.length, + wire: Buffer.from(JSON.stringify(answer)) + } +} + +function configuredResolver(url: string): LookupResolver { + return new LookupResolver({ + facilitator: new HTTPSOverlayLookupFacilitator(fetch, true), + hostOverrides: { ls_http: [url] } + }) +} + +async function startLookupServer( + handler: Parameters[0] +): Promise { + const server = createServer(handler) + const sockets = new Set() + server.on('connection', socket => { + sockets.add(socket) + socket.once('close', () => sockets.delete(socket)) + }) + + await new Promise((resolve, reject) => { + server.once('error', reject) + server.listen(0, '127.0.0.1', resolve) + }) + const address = server.address() + if (address === null || typeof address === 'string') + throw new Error('Expected a TCP server address') + + return { + url: `http://127.0.0.1:${address.port}`, + close: async () => { + for (const socket of sockets) socket.destroy() + await new Promise((resolve, reject) => { + ;(server as Server).close(error => (error === undefined ? resolve() : reject(error))) + }) + } + } +} + +async function waitForClose(close: Promise): Promise { + let timer: ReturnType | undefined + try { + await Promise.race([ + close, + new Promise((_resolve, reject) => { + timer = setTimeout(() => reject(new Error('server did not observe client close')), 1500) + }) + ]) + } finally { + if (timer !== undefined) clearTimeout(timer) + } +} + +describe('HTTPSOverlayLookupFacilitator HTTP transport', () => { + let server: LookupServer | undefined + + afterEach(async () => { + await server?.close() + server = undefined + getOverlayHostReputationTracker().reset() + }) + + it('accepts a slowly streamed structural BEEF receipt within a configured budget', async () => { + const fixture = structuralOutputListFixture() + server = await startLookupServer(async (_request, response) => { + response.writeHead(200, { 'content-type': 'application/json' }) + for (let offset = 0; offset < fixture.wire.length; offset += 1024) { + response.write(fixture.wire.subarray(offset, offset + 1024)) + await new Promise(resolve => setTimeout(resolve, 2)) + } + response.end() + }) + const resolver = configuredResolver(server.url) + const startedAt = Date.now() + const result = await resolver.queryDetailed({ service: 'ls_http', query: {} }, 2000, { + limits: { maxResponseBytes: fixture.wire.length, maxTotalBytes: fixture.wire.length } + }) + const elapsedMs = Date.now() - startedAt + + expect(result.answer.outputs).toHaveLength(1) + expect(result.answer.outputs[0].beef).toEqual(fixture.answer.outputs[0].beef) + expect(result.progress.receivedBytes).toBe(fixture.wire.length) + expect(fixture.evidenceBytes).toBeGreaterThanOrEqual(48 * 1024) + expect(fixture.wire.length).toBeGreaterThan(fixture.evidenceBytes) + expect({ evidenceBytes: fixture.evidenceBytes, jsonWireBytes: fixture.wire.length }).toEqual({ + evidenceBytes: 49_180, + jsonWireBytes: 98_429 + }) + expect(elapsedMs).toBeGreaterThanOrEqual(20) + expect(elapsedMs).toBeLessThan(2000) + }) + + it('limits evidence callbacks for the structural receipt, then admits it through evidenceLimits', async () => { + const fixture = structuralOutputListFixture() + server = await startLookupServer((_request, response) => { + response.writeHead(200, { 'content-type': 'application/json' }) + response.end(fixture.wire) + }) + const limitedEvidence: string[] = [] + const limited = await configuredResolver(server.url).queryDetailed( + { service: 'ls_http', query: {} }, + 1000, + { + limits: { + maxResponseBytes: fixture.wire.length, + maxTotalBytes: fixture.wire.length, + maxEvidenceBytes: fixture.evidenceBytes - 1 + }, + onEvidence: event => limitedEvidence.push(event.type) + } + ) + const admittedEvidence: string[] = [] + const admitted = await configuredResolver(server.url).queryDetailed( + { service: 'ls_http', query: {} }, + 1000, + { + limits: { maxResponseBytes: fixture.wire.length, maxTotalBytes: fixture.wire.length }, + evidenceLimits: { maxBytes: fixture.evidenceBytes }, + onEvidence: event => admittedEvidence.push(event.type) + } + ) + + expect(limitedEvidence).toEqual(['limit']) + expect(limited.progress.terminalReason).toBe('resource-limit') + expect(limited.progress.limitsHit).toContain('maxEvidenceBytes') + expect(admittedEvidence).toEqual(['output']) + expect(admitted.progress.terminalReason).toBe('settled') + expect(admitted.progress.receivedBytes).toBe(fixture.wire.length) + }) + + it.each(['maxResponseBytes', 'maxTotalBytes'] as const)( + 'reports %s exhaustion without recording an availability failure', + async limitName => { + const fixture = structuralOutputListFixture() + server = await startLookupServer((_request, response) => { + response.writeHead(200, { 'content-type': 'application/json' }) + response.end(fixture.wire) + }) + const smallLimit = fixture.wire.length - 1 + const result = await configuredResolver(server.url).queryDetailed( + { service: 'ls_http', query: {} }, + 1000, + { + limits: + limitName === 'maxResponseBytes' + ? { maxResponseBytes: smallLimit, maxTotalBytes: fixture.wire.length } + : { maxResponseBytes: fixture.wire.length, maxTotalBytes: smallLimit } + } + ) + + expect(result.answer.outputs).toEqual([]) + expect(result.progress.terminalReason).toBe('resource-limit') + expect(result.progress.limitsHit).toContain(limitName) + expect(result.progress.completedHosts).toBe(1) + expect(getOverlayHostReputationTracker().snapshot(server.url)).toMatchObject({ + totalFailures: 0, + consecutiveFailures: 0, + totalSuccesses: 0 + }) + } + ) + + it('times out a response whose body never finishes and closes the server-side response', async () => { + let resolveClosed: () => void = () => undefined + const responseClosed = new Promise(resolve => { + resolveClosed = resolve + }) + server = await startLookupServer((_request, response) => { + response.once('close', resolveClosed) + response.writeHead(200, { 'content-type': 'application/json' }) + response.write('{"type":"freeform","result":"') + }) + const facilitator = new HTTPSOverlayLookupFacilitator(fetch, true) + + await expect( + facilitator.lookup(server.url, { service: 'ls_http', query: {} }, 50, undefined, { + maxResponseBytes: 1024, + maxOutputs: 1 + }) + ).rejects.toThrow('Request timed out') + + await waitForClose(responseClosed) + }) + + it.each([ + ['invalid UTF-8', Buffer.from([0xff, 0xfe])], + ['malformed JSON', Buffer.from('{')] + ])('rejects %s without treating it as a successful answer', async (_name, payload) => { + server = await startLookupServer((_request, response) => { + response.writeHead(200, { 'content-type': 'application/json' }) + response.end(payload) + }) + const facilitator = new HTTPSOverlayLookupFacilitator(fetch, true) + + await expect( + facilitator.lookup(server.url, { service: 'ls_http', query: {} }, 1000, undefined, { + maxResponseBytes: 1024, + maxOutputs: 1 + }) + ).rejects.toBeInstanceOf(Error) + }) + + it('honors an early caller abort and closes the server-side response', async () => { + let resolveStarted: () => void = () => undefined + const started = new Promise(resolve => { + resolveStarted = resolve + }) + let resolveClosed: () => void = () => undefined + const responseClosed = new Promise(resolve => { + resolveClosed = resolve + }) + server = await startLookupServer((_request, response) => { + response.once('close', resolveClosed) + response.writeHead(200, { 'content-type': 'application/json' }) + response.write('{"type":"freeform","result":"') + resolveStarted() + }) + const facilitator = new HTTPSOverlayLookupFacilitator(fetch, true) + const controller = new AbortController() + const lookup = facilitator.lookup( + server.url, + { service: 'ls_http', query: {} }, + 2000, + controller.signal, + { maxResponseBytes: 1024, maxOutputs: 1 } + ) + + await started + controller.abort() + + await expect(lookup).rejects.toMatchObject({ name: 'AbortError', message: 'Lookup cancelled' }) + await waitForClose(responseClosed) + }) +}) diff --git a/packages/sdk/src/overlay-tools/__tests/LookupResolver.test.ts b/packages/sdk/src/overlay-tools/__tests/LookupResolver.test.ts index b0072979e..88cd1f916 100644 --- a/packages/sdk/src/overlay-tools/__tests/LookupResolver.test.ts +++ b/packages/sdk/src/overlay-tools/__tests/LookupResolver.test.ts @@ -10,6 +10,23 @@ const mockFacilitator = { lookup: jest.fn() } +const expectLookupCalls = (actual: unknown[][], expected: unknown[][]): void => { + expect(actual.map(call => call.slice(0, 3))).toEqual(expected) + for (const call of actual) { + expect(call[3]).toEqual(expect.any(AbortSignal)) + expect(call[4]).toEqual( + expect.objectContaining({ + maxResponseBytes: 32 * 1024 * 1024, + consumeBytes: expect.any(Function) + }) + ) + } +} + +const expectLookupCall = (actual: unknown[], expected: unknown[]): void => { + expectLookupCalls([actual], [expected]) +} + const sampleBeef1 = new Transaction( 1, [], @@ -96,7 +113,7 @@ describe('LookupResolver', () => { } ] }) - expect(mockFacilitator.lookup.mock.calls).toEqual([ + expectLookupCalls(mockFacilitator.lookup.mock.calls, [ [ 'https://mock.slap', { @@ -137,40 +154,24 @@ describe('LookupResolver', () => { 0 ) - mockFacilitator.lookup - .mockReturnValueOnce({ - type: 'output-list', - outputs: [ - { - outputIndex: 0, - beef: slapTx.toBEEF() - } - ] - }) - .mockReturnValueOnce({ - type: 'output-list', - outputs: [ - { - beef: sampleBeef1, - outputIndex: 0 - } - ] - }) - .mockReturnValueOnce({ + mockFacilitator.lookup.mockImplementation((url: string, question: { service: string }) => { + if (question.service === 'ls_slap') { + return { + type: 'output-list', + outputs: [{ outputIndex: 0, beef: slapTx.toBEEF() }] + } + } + if (url === 'https://slaphost.com') { + return { type: 'output-list', outputs: [{ beef: sampleBeef1, outputIndex: 0 }] } + } + return { type: 'output-list', outputs: [ - { - // duplicate the output the other host knows about - beef: sampleBeef1, - outputIndex: 0 - }, - { - // the additional host also knows about a second output - beef: sampleBeef2, - outputIndex: 1033 - } + { beef: sampleBeef1, outputIndex: 0 }, + { beef: sampleBeef2, outputIndex: 1033 } ] - }) + } + }) const r = new LookupResolver({ facilitator: mockFacilitator, @@ -198,30 +199,30 @@ describe('LookupResolver', () => { } ] }) - expect(mockFacilitator.lookup.mock.calls).toEqual([ + expectLookupCalls(mockFacilitator.lookup.mock.calls, [ [ - 'https://mock.slap', + // additional host should also have been queried first + 'https://additional.host', { - service: 'ls_slap', + service: 'ls_foo', query: { - service: 'ls_foo' + test: 1 } }, - 5000 + undefined ], [ - 'https://slaphost.com', + 'https://mock.slap', { - service: 'ls_foo', + service: 'ls_slap', query: { - test: 1 + service: 'ls_foo' } }, - undefined + 5000 ], [ - // additional host should also have been queried - 'https://additional.host', + 'https://slaphost.com', { service: 'ls_foo', query: { @@ -264,7 +265,7 @@ describe('LookupResolver', () => { } ] }) - expect(mockFacilitator.lookup.mock.calls).toEqual([ + expectLookupCalls(mockFacilitator.lookup.mock.calls, [ [ 'https://override.host', { @@ -332,7 +333,7 @@ describe('LookupResolver', () => { } ] }) - expect(mockFacilitator.lookup.mock.calls).toEqual([ + expectLookupCalls(mockFacilitator.lookup.mock.calls, [ [ 'https://override.host', { @@ -357,7 +358,7 @@ describe('LookupResolver', () => { ]) }) - it('should handle multiple SLAP trackers and resolve with first responder hosts', async () => { + it('queries every eligible host advertised during the active attempt, including a later tracker', async () => { const slapHostKey1 = new PrivateKey(42) const slapWallet1 = new CompletedProtoWallet(slapHostKey1) const slapLib1 = new OverlayAdminTokenTemplate(slapWallet1) @@ -390,36 +391,28 @@ describe('LookupResolver', () => { 0 ) - // SLAP trackers return hosts — first responder wins - mockFacilitator.lookup - .mockReturnValueOnce({ - type: 'output-list', - outputs: [ - { - outputIndex: 0, - beef: slapTx1.toBEEF() + mockFacilitator.lookup.mockImplementation((url: string, question: { service: string }) => { + if (question.service === 'ls_slap') { + if (url === 'https://mock.slap1') { + return { + type: 'output-list', + outputs: [{ outputIndex: 0, beef: slapTx1.toBEEF() }] } - ] - }) - .mockReturnValueOnce({ - type: 'output-list', - outputs: [ - { - outputIndex: 0, - beef: slapTx2.toBEEF() + } + if (url === 'https://mock.slap2') { + return { + type: 'output-list', + outputs: [{ outputIndex: 0, beef: slapTx2.toBEEF() }] } - ] - }) - - // Only the first-resolved tracker's host gets queried - mockFacilitator.lookup.mockReturnValueOnce({ - type: 'output-list', - outputs: [ - { - beef: sampleBeef3, - outputIndex: 0 } - ] + } + if (url === 'https://slaphost1.com') { + return { type: 'output-list', outputs: [{ beef: sampleBeef3, outputIndex: 0 }] } + } + if (url === 'https://slaphost2.com') { + return { type: 'output-list', outputs: [{ beef: sampleBeef2, outputIndex: 1 }] } + } + throw new Error(`unexpected host ${url}`) }) const r = new LookupResolver({ @@ -432,15 +425,24 @@ describe('LookupResolver', () => { query: { test: 1 } }) - // Only the first tracker's host results are returned - expect(res).toEqual({ - type: 'output-list', - outputs: [{ beef: sampleBeef3, outputIndex: 0 }] - }) + expect(res.outputs).toEqual( + expect.arrayContaining([ + { beef: sampleBeef3, outputIndex: 0 }, + { beef: sampleBeef2, outputIndex: 1 } + ]) + ) + expect(res.outputs).toHaveLength(2) - // Both SLAP trackers are queried, but only the first host is used for the actual query - expect(mockFacilitator.lookup.mock.calls.length).toBeGreaterThanOrEqual(3) - expect(mockFacilitator.lookup.mock.calls[0]).toEqual([ + const calledUrls = mockFacilitator.lookup.mock.calls.map((call: unknown[]) => call[0]) + expect(calledUrls).toEqual( + expect.arrayContaining([ + 'https://mock.slap1', + 'https://mock.slap2', + 'https://slaphost1.com', + 'https://slaphost2.com' + ]) + ) + expectLookupCall(mockFacilitator.lookup.mock.calls[0], [ 'https://mock.slap1', { service: 'ls_slap', @@ -450,7 +452,7 @@ describe('LookupResolver', () => { }, 5000 ]) - expect(mockFacilitator.lookup.mock.calls[1]).toEqual([ + expectLookupCall(mockFacilitator.lookup.mock.calls[1], [ 'https://mock.slap2', { service: 'ls_slap', @@ -460,16 +462,6 @@ describe('LookupResolver', () => { }, 5000 ]) - expect(mockFacilitator.lookup.mock.calls[2]).toEqual([ - 'https://slaphost1.com', - { - service: 'ls_foo', - query: { - test: 1 - } - }, - undefined - ]) }) it('should de-duplicate outputs from multiple hosts', async () => { @@ -541,7 +533,7 @@ describe('LookupResolver', () => { outputs: [duplicateOutput] }) - expect(mockFacilitator.lookup.mock.calls).toEqual([ + expectLookupCalls(mockFacilitator.lookup.mock.calls, [ [ 'https://mock.slap', { @@ -644,7 +636,7 @@ describe('LookupResolver', () => { outputs: [{ beef: sampleBeef3, outputIndex: 0 }] }) - expect(mockFacilitator.lookup.mock.calls).toEqual([ + expectLookupCalls(mockFacilitator.lookup.mock.calls, [ [ 'https://mock.slap', { @@ -699,7 +691,7 @@ describe('LookupResolver', () => { 'No competent mainnet hosts found by the SLAP trackers for lookup service: ls_foo' ) - expect(mockFacilitator.lookup.mock.calls).toEqual([ + expectLookupCalls(mockFacilitator.lookup.mock.calls, [ [ 'https://mock.slap', { @@ -786,7 +778,7 @@ describe('LookupResolver', () => { ] }) - expect(mockFacilitator.lookup.mock.calls).toEqual([ + expectLookupCalls(mockFacilitator.lookup.mock.calls, [ [ 'https://mock.slap', { @@ -848,7 +840,7 @@ describe('LookupResolver', () => { } ] }) - expect(mockFacilitator.lookup.mock.calls).toEqual([ + expectLookupCalls(mockFacilitator.lookup.mock.calls, [ [ 'https://mock.slap', { @@ -884,7 +876,7 @@ describe('LookupResolver', () => { 'No competent mainnet hosts found by the SLAP trackers for lookup service: ls_foo' ) - expect(mockFacilitator.lookup.mock.calls).toEqual([ + expectLookupCalls(mockFacilitator.lookup.mock.calls, [ [ 'https://mock.slap', { @@ -1115,7 +1107,7 @@ describe('LookupResolver', () => { ] }) - expect(mockFacilitator.lookup.mock.calls).toEqual([ + expectLookupCalls(mockFacilitator.lookup.mock.calls, [ [ 'https://mock.slap1', { @@ -1238,7 +1230,7 @@ describe('LookupResolver', () => { outputs: [{ beef: sampleBeef3, outputIndex: 0 }] }) - expect(mockFacilitator.lookup.mock.calls).toEqual([ + expectLookupCalls(mockFacilitator.lookup.mock.calls, [ [ 'https://mock.slap', { @@ -1341,7 +1333,7 @@ describe('LookupResolver', () => { outputs: [{ beef: sampleBeef3, outputIndex: 0 }] }) - expect(mockFacilitator.lookup.mock.calls).toEqual([ + expectLookupCalls(mockFacilitator.lookup.mock.calls, [ [ 'https://mock.slap', { @@ -1443,7 +1435,7 @@ describe('LookupResolver', () => { outputs: [{ beef: sampleBeef3, outputIndex: 0 }] }) - expect(mockFacilitator.lookup.mock.calls).toEqual([ + expectLookupCalls(mockFacilitator.lookup.mock.calls, [ [ 'https://mock.slap', { @@ -1522,7 +1514,7 @@ describe('LookupResolver', () => { outputs: [] }) - expect(mockFacilitator.lookup.mock.calls).toEqual([ + expectLookupCalls(mockFacilitator.lookup.mock.calls, [ [ 'https://mock.slap', { diff --git a/packages/sdk/src/overlay-tools/__tests/LookupResponseReader.test.ts b/packages/sdk/src/overlay-tools/__tests/LookupResponseReader.test.ts new file mode 100644 index 000000000..2cdf8d075 --- /dev/null +++ b/packages/sdk/src/overlay-tools/__tests/LookupResponseReader.test.ts @@ -0,0 +1,243 @@ +import { readLookupResponseBytes } from '../LookupResponseReader.js' +import { LookupResourceLimitError } from '../LookupResources.js' + +function responseForReader( + reader: ReadableStreamDefaultReader, + contentLength?: string +): Response { + const headers = new Headers() + if (contentLength !== undefined) headers.set('content-length', contentLength) + return { + body: { getReader: () => reader }, + headers + } as unknown as Response +} + +function readerForChunks(chunks: Uint8Array[]): ReadableStreamDefaultReader { + let index = 0 + return { + read: async () => + index < chunks.length + ? { done: false, value: chunks[index++] } + : { done: true, value: undefined }, + cancel: async () => undefined, + releaseLock: () => undefined + } as unknown as ReadableStreamDefaultReader +} + +describe('readLookupResponseBytes', () => { + it('joins normal streamed chunks and charges each accepted chunk', async () => { + const consumed: number[] = [] + const bytes = await readLookupResponseBytes( + responseForReader(readerForChunks([new Uint8Array([1, 2]), new Uint8Array([3, 4, 5])])), + { maxResponseBytes: 5, consumeBytes: byteCount => consumed.push(byteCount) } + ) + + expect(bytes).toEqual(new Uint8Array([1, 2, 3, 4, 5])) + expect(consumed).toEqual([2, 3]) + }) + + it('allows a body exactly at the configured boundary', async () => { + const bytes = await readLookupResponseBytes( + responseForReader(readerForChunks([new Uint8Array([1, 2]), new Uint8Array([3])]), '3'), + { maxResponseBytes: 3 } + ) + + expect(bytes).toEqual(new Uint8Array([1, 2, 3])) + }) + + it('rejects a known oversized Content-Length before reading chunks', async () => { + const read = jest.fn() + const cancel = jest.fn(async () => undefined) + const releaseLock = jest.fn() + const reader = { + read, + cancel, + releaseLock + } as unknown as ReadableStreamDefaultReader + + await expect( + readLookupResponseBytes(responseForReader(reader, '6'), { maxResponseBytes: 5 }) + ).rejects.toMatchObject({ name: 'LookupResourceLimitError', limit: 'maxResponseBytes' }) + + expect(read).not.toHaveBeenCalled() + expect(cancel).toHaveBeenCalledTimes(1) + expect(releaseLock).toHaveBeenCalledTimes(1) + }) + + it('rejects an overflowing chunk before it is charged or accumulated', async () => { + const cancel = jest.fn(async () => undefined) + const releaseLock = jest.fn() + const reader = { + read: jest + .fn() + .mockResolvedValueOnce({ done: false, value: new Uint8Array([1, 2, 3]) }) + .mockResolvedValueOnce({ done: false, value: new Uint8Array([4, 5, 6]) }), + cancel, + releaseLock + } as unknown as ReadableStreamDefaultReader + const consumed: number[] = [] + + await expect( + readLookupResponseBytes(responseForReader(reader), { + maxResponseBytes: 5, + consumeBytes: byteCount => consumed.push(byteCount) + }) + ).rejects.toBeInstanceOf(LookupResourceLimitError) + + expect(consumed).toEqual([3]) + expect(cancel).toHaveBeenCalledTimes(1) + expect(releaseLock).toHaveBeenCalledTimes(1) + }) + + it('copies producer-owned chunk buffers before the producer reuses them', async () => { + const producerBuffer = new Uint8Array([1, 2]) + let readCount = 0 + const reader = { + read: async () => { + readCount += 1 + if (readCount === 1) return { done: false, value: producerBuffer } + if (readCount === 2) { + producerBuffer.set([3, 4]) + return { done: false, value: producerBuffer } + } + return { done: true, value: undefined } + }, + cancel: async () => undefined, + releaseLock: () => undefined + } as unknown as ReadableStreamDefaultReader + + await expect( + readLookupResponseBytes(responseForReader(reader), { maxResponseBytes: 4 }) + ).resolves.toEqual(new Uint8Array([1, 2, 3, 4])) + }) + + it('ignores many empty chunks while retaining tiny chunks in bounded storage', async () => { + const emptyChunks = Array.from({ length: 2048 }, () => new Uint8Array(0)) + const tinyChunks = Array.from({ length: 32 }, (_unused, index) => new Uint8Array([index])) + const consumed: number[] = [] + + const bytes = await readLookupResponseBytes( + responseForReader(readerForChunks([...emptyChunks, ...tinyChunks])), + { maxResponseBytes: 32, consumeBytes: byteCount => consumed.push(byteCount) } + ) + + expect(bytes).toEqual(new Uint8Array(Array.from({ length: 32 }, (_unused, index) => index))) + expect(consumed).toEqual(Array.from({ length: 32 }, () => 1)) + }) + + it('yields so a timer abort can stop an endless eager empty stream', async () => { + const controller = new AbortController() + const cancel = jest.fn(async () => undefined) + const reader = { + read: jest.fn(async () => ({ done: false, value: new Uint8Array(0) })), + cancel, + releaseLock: jest.fn() + } as unknown as ReadableStreamDefaultReader + const timer = setTimeout(() => controller.abort(new Error('empty stream aborted')), 1) + + try { + await expect( + readLookupResponseBytes(responseForReader(reader), { + maxResponseBytes: 1, + signal: controller.signal + }) + ).rejects.toThrow('empty stream aborted') + } finally { + clearTimeout(timer) + } + + expect(cancel).toHaveBeenCalledTimes(1) + expect((reader.read as jest.Mock).mock.calls.length).toBeGreaterThanOrEqual(64) + expect((reader.read as jest.Mock).mock.calls.length).toBeLessThan(256) + }) + + it('copies the sixty-fourth chunk before yielding to timer-driven producer mutation', async () => { + const producerBuffer = new Uint8Array([1]) + let reads = 0 + const reader = { + read: async () => { + reads += 1 + if (reads <= 64) { + if (reads === 64) setTimeout(() => producerBuffer.fill(9), 0) + return { done: false, value: producerBuffer } + } + return { done: true, value: undefined } + }, + cancel: async () => undefined, + releaseLock: () => undefined + } as unknown as ReadableStreamDefaultReader + + await expect( + readLookupResponseBytes(responseForReader(reader), { maxResponseBytes: 64 }) + ).resolves.toEqual(new Uint8Array(64).fill(1)) + }) + + it('cleans up when the aggregate byte budget rejects an accepted chunk', async () => { + const cancel = jest.fn(async () => undefined) + const releaseLock = jest.fn() + const reader = { + read: jest.fn().mockResolvedValue({ done: false, value: new Uint8Array([1, 2]) }), + cancel, + releaseLock + } as unknown as ReadableStreamDefaultReader + const budgetFailure = new Error('aggregate response budget exhausted') + + await expect( + readLookupResponseBytes(responseForReader(reader), { + maxResponseBytes: 10, + consumeBytes: () => { + throw budgetFailure + } + }) + ).rejects.toBe(budgetFailure) + + expect(cancel).toHaveBeenCalledWith(budgetFailure) + expect(releaseLock).toHaveBeenCalledTimes(1) + }) + + it('rejects an abort while a reader read remains pending and starts cleanup', async () => { + let rejectRead: (reason?: unknown) => void = () => undefined + const pendingRead = new Promise>((_, reject) => { + rejectRead = reject + }) + const cancel = jest.fn(() => { + rejectRead(new Error('cancelled pending read')) + return Promise.resolve() + }) + const releaseLock = jest.fn() + const reader = { + read: jest.fn(() => pendingRead), + cancel, + releaseLock + } as unknown as ReadableStreamDefaultReader + const controller = new AbortController() + const aborted = readLookupResponseBytes(responseForReader(reader), { + maxResponseBytes: 10, + signal: controller.signal + }) + + controller.abort(new Error('lookup aborted')) + + await expect(aborted).rejects.toThrow('lookup aborted') + expect(cancel).toHaveBeenCalledTimes(1) + expect(releaseLock).toHaveBeenCalledTimes(1) + }) + + it('preserves the read failure when cancellation and lock release throw', async () => { + const readFailure = new Error('stream failed') + const reader = { + read: jest.fn().mockRejectedValue(readFailure), + cancel: jest.fn(() => { + throw new Error('cancel failed') + }), + releaseLock: jest.fn(() => { + throw new Error('release failed') + }) + } as unknown as ReadableStreamDefaultReader + + await expect( + readLookupResponseBytes(responseForReader(reader), { maxResponseBytes: 10 }) + ).rejects.toBe(readFailure) + }) +}) From b25a0192119614f5e4b704c6f9840d3dfd5a7e48 Mon Sep 17 00:00:00 2001 From: Deggen Date: Tue, 8 Sep 2026 14:57:38 -0500 Subject: [PATCH 03/58] fix(sdk): close C02/C03 evidence coordinator review findings Preserve per-entry script results so a rejected sibling cannot poison a shared ancestor. LocalChainTracker tokens use participating sources only and fail closed on missing identity. Reset ownership is rechecked after dispose before destructive hooks. Built-in remote ChainTracks clients advertise reorg-event capability explicitly. Services.getChainTracker publishes one coalesced wrapper before yielding. --- docs/packages/sdk/bsv-sdk.md | 2 +- docs/reference/package-api-migrations.md | 88 +- docs/reference/stack-facts.md | 2 +- governance/package-release-notes.json | 18 +- governance/repository-health/baselines.json | 2 +- packages/sdk/README.md | 6 + packages/sdk/docs/reference/overlay-tools.md | 819 +++++-- packages/sdk/docs/reference/transaction.md | 1963 ++++++++++++----- packages/sdk/docs/transaction-evidence.md | 160 ++ packages/sdk/package.json | 2 +- .../sdk/src/overlay-tools/LookupResolver.ts | 198 +- .../__tests/LookupResolver.evidence.test.ts | 232 ++ packages/sdk/src/transaction/ChainTracker.ts | 19 +- .../sdk/src/transaction/EvidenceScriptWork.ts | 291 +++ packages/sdk/src/transaction/Transaction.ts | 76 +- .../src/transaction/TransactionEvidence.ts | 163 ++ .../TransactionEvidenceCoordinator.ts | 583 +++++ .../TransactionEvidenceCoordinator.test.ts | 1085 +++++++++ packages/sdk/src/transaction/index.ts | 26 +- packages/wallet/wallet-toolbox/CHANGELOG.md | 21 +- packages/wallet/wallet-toolbox/README.md | 21 +- .../wallet/wallet-toolbox/client/README.md | 14 +- .../docs/identity-verification.md | 252 ++- .../wallet/wallet-toolbox/mobile/README.md | 14 +- packages/wallet/wallet-toolbox/src/Wallet.ts | 80 +- .../Wallet.identityVerification.test.ts | 72 +- .../wallet-toolbox/src/services/Services.ts | 40 +- .../src/services/__tests/verifyBeef.test.ts | 29 + .../services/chaintracker/BHServiceClient.ts | 2 + .../chaintracker/ChaintracksChainTracker.ts | 189 +- .../chaintracker/LocalChainTracker.ts | 210 +- .../__tests/ChaintracksChainTracker.test.ts | 130 +- .../__tests/LocalChainTracker.test.ts | 267 +++ .../chaintracks/Api/ChaintracksClientApi.ts | 7 + .../chaintracks/ChaintracksServiceClient.ts | 2 + .../chaintracks/GoChaintracksServiceClient.ts | 2 + .../__tests__/identityVerification.test.ts | 32 +- .../src/utility/identityUtils.ts | 233 +- .../src/utility/verifyOverlayOutput.ts | 79 +- 39 files changed, 6252 insertions(+), 1179 deletions(-) create mode 100644 packages/sdk/docs/transaction-evidence.md create mode 100644 packages/sdk/src/overlay-tools/__tests/LookupResolver.evidence.test.ts create mode 100644 packages/sdk/src/transaction/EvidenceScriptWork.ts create mode 100644 packages/sdk/src/transaction/TransactionEvidence.ts create mode 100644 packages/sdk/src/transaction/TransactionEvidenceCoordinator.ts create mode 100644 packages/sdk/src/transaction/__tests/TransactionEvidenceCoordinator.test.ts diff --git a/docs/packages/sdk/bsv-sdk.md b/docs/packages/sdk/bsv-sdk.md index 14ef20ca4..88203bf6c 100644 --- a/docs/packages/sdk/bsv-sdk.md +++ b/docs/packages/sdk/bsv-sdk.md @@ -3,7 +3,7 @@ id: bsv-sdk title: '@bsv/sdk' kind: package domain: sdk -version: '2.4.2' +version: '2.5.0' npm: '@bsv/sdk' last_updated: '2026-08-26' last_verified: '2026-08-26' diff --git a/docs/reference/package-api-migrations.md b/docs/reference/package-api-migrations.md index 7c20e6db3..09730c768 100644 --- a/docs/reference/package-api-migrations.md +++ b/docs/reference/package-api-migrations.md @@ -23,42 +23,42 @@ and clean-consumer tests remain the executable type authority. ## Current release boundary -| Package | npm baseline | Source | Candidate | API | Migration | -| --------------------------------- | ------------ | -------- | --------- | --------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| `@bsv/402-pay` | `0.2.1` | `0.2.5` | patch | [API and usage](../packages/middleware/402-pay.md) | No consumer migration is required; client and server exports, payment protocol behavior, and runtime defaults are unchanged. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/ with the package. | -| `@bsv/air-gap` | `0.0.0` | `0.1.2` | minor | [API and usage](../packages/helpers/air-gap.md) | No consumer migration is required; this is the first published release of a new package with no prior public API. The experimental pre-release framing that circulated on the unmerged feature branch is not accepted by the v1 decoder. | -| `@bsv/amountinator` | `2.1.1` | `2.1.5` | patch | [API and usage](../packages/helpers/amountinator.md) | No consumer migration is required; this is a backward-compatible patch candidate. | -| `@bsv/auth` | `0.1.1` | `0.1.4` | patch | [API and usage](../packages/middleware/auth.md) | No consumer migration is required; authentication APIs, wire behavior, and runtime defaults are unchanged. | -| `@bsv/auth-express-middleware` | `2.2.0` | `2.2.3` | patch | [API and usage](../packages/middleware/auth-express-middleware.md) | No API migration is required. Upgrade to @bsv/sdk 2.4.1 or later for the shared byte-boundary contract. Generic signed application-body canonicalization remains unchanged so old and new peers verify identical bytes. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/ with the package. | -| `@bsv/authsocket` | `2.1.1` | `2.1.7` | patch | [API and usage](../packages/messaging/authsocket.md) | No API migration is required. Existing event data, including numeric-key objects under byte-like names, is unchanged; typed payment protocols recover historical byte objects at their explicit fields. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/ with the package. | -| `@bsv/authsocket-client` | `2.1.1` | `2.1.6` | patch | [API and usage](../packages/messaging/authsocket-client.md) | No API migration is required. Existing event data, including numeric-key objects under byte-like names, is unchanged; typed payment protocols recover historical byte objects at their explicit fields. Distributors who copy the UMD file must keep THIRD_PARTY_NOTICES.md and LICENSES/ with it. | -| `@bsv/btms` | `1.1.1` | `1.2.2` | minor | [API and usage](../packages/wallet/btms.md) | Existing local, mainnet, testnet, and number-array behavior is unchanged. TTN consumers select networkPreset teratestnet; all consumers should upgrade to @bsv/sdk 2.4.1 or later for byte-boundary compatibility. | -| `@bsv/btms-permission-module` | `1.1.1` | `1.1.4` | patch | [API and usage](../packages/wallet/btms-permission-module.md) | No consumer migration is required; permission-module APIs and token semantics are unchanged. | -| `@bsv/chirp` | `0.0.0` | `0.1.1` | minor | [API and usage](../packages/network/chirp.md) | No consumer migration is required; this is the first release of a new additive package. Existing @bsv/sdk StorageUploader, StorageDownloader, StorageUtils, UHRP identifiers, overlays, and server routes remain unchanged. BRC-167 remains authoritative if the implementation and standard differ. | -| `@bsv/did` | `0.2.1` | `0.2.5` | patch | [API and usage](../packages/helpers/did.md) | No consumer migration is required; DID APIs, encodings, credential behavior, and supported import forms are unchanged. | -| `@bsv/did-client` | `1.2.1` | `1.3.1` | minor | [API and usage](../packages/helpers/did-client.md) | Existing local, mainnet, and testnet behavior is unchanged. TTN consumers select networkPreset teratestnet and use @bsv/sdk 2.4 or later. Distributors who copy the UMD file must keep THIRD_PARTY_NOTICES.md and LICENSES/ with it. | -| `@bsv/ecpm-permission-module` | `0.0.0` | `0.1.0` | minor | [API and usage](../packages/wallet/ecpm-permission-module.md) | No existing consumer migration is required; this is the first release. Wallet hosts register the module under the ecpm scheme and supply their ordinary key deriver, authorization handler, and optional privileged key provider. | -| `@bsv/fund-wallet` | `1.4.1` | `1.4.4` | patch | [API and usage](../packages/helpers/fund-wallet.md) | No consumer migration is required; wallet funding APIs and transaction behavior are unchanged. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/ with the package. | -| `@bsv/gasp` | `1.3.1` | `1.3.6` | patch | [API and usage](../packages/overlays/gasp.md) | No consumer migration is required; existing constructor calls, imports, synchronization behavior, and wire semantics are unchanged. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/ with the package. | -| `@bsv/lch` | `0.0.0` | `0.1.0` | minor | [API and usage](../packages/content/lch.md) | No consumer migration is required; this is the first release of a new additive package. Applications must give quote() the verified Asset encryption descriptor and selected Offer key-delivery mechanism, keep createPayment behind explicit wallet authorization, persist the funded transaction and every partial settlement proof through recovery, retry with that same transaction, distinguish finalized from accepted evidence, fail closed on unknown settlement or evidence profiles, and supply an address-pinning connector for DNS endpoints. Payees should select receipt-complete unless they explicitly accept authorized-output provider, privacy, and pre-internalization key-release tradeoffs. Distributors must retain THIRD_PARTY_NOTICES.md with the package; the new profile adds no dependency. Published BRC-170 remains authoritative if the implementation and standard differ. | -| `@bsv/message-box-client` | `2.4.0` | `2.5.0` | minor | [API and usage](../packages/messaging/message-box-client.md) | No API migration is required. socketOptions is optional and nothing is forwarded by default, so socket transport negotiation and every HTTP code path are unchanged for existing callers; wallet and originator stay client-owned and cannot be overridden through it. managerOptions excludes autoConnect and retries: JavaScript callers receive an immediate error for autoConnect: false or nonzero message retries because the live socket starts immediately and AuthSocket does not send Socket.IO acknowledgements. Connection reconnection options remain supported. Upgrade @bsv/sdk and @bsv/message-box-client together; historical number-array wallets, current Uint8Array substrates, and already-pending numeric-key messages interoperate through the same portable transaction form. Distributors who copy the UMD file must keep THIRD_PARTY_NOTICES.md and LICENSES/ with it. | -| `@bsv/overlay` | `2.2.1` | `2.3.1` | minor | [API and usage](../packages/overlays/overlay.md) | Existing Engine and TopicManager implementations remain valid. Lookup results default to 1,000 formulas; pass -1 only when an equivalent deployment bound exists. Topic managers whose validation creates provisional external state should implement abortAdmissibleOutputs, while read-only managers require no change. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/ with the package. | -| `@bsv/overlay-discovery-services` | `2.1.1` | `2.2.1` | minor | [API and usage](../packages/overlays/overlay-discovery-services.md) | Existing mainnet and testnet advertisers are unchanged. TTN operators pass chain ttn and provision the staging storage and overlay endpoints before advertising. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/ with the package. | -| `@bsv/overlay-express` | `2.5.0` | `2.6.1` | minor | [API and usage](../packages/overlays/overlay-express.md) | Existing mainnet and testnet servers are unchanged. TTN servers call configureNetwork('ttn'), configureArcade with the TTN endpoint, and configureChaintracks or configureChainTracker before engine initialization. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/ with the package. | -| `@bsv/overlay-topics` | `1.6.10` | `1.7.1` | minor | [API and usage](../packages/overlays/overlay-topics.md) | Existing topic and lookup identifiers remain unchanged. Production UMP overlays must give UMPTopicManager and the UMP lookup service Mongo-backed stores that use the same database, then roll out before updated wallet clients; the no-argument manager is bounded but intended only for isolated single-process use. The reservation and bootstrap-marker collections are additive and initialize from currently indexed UMP UTXOs; take a MongoDB backup before rollout. Legacy ambiguous rows remain visible and can be resolved with WAB pinning rather than deleted. | -| `@bsv/paymail` | `2.4.2` | `2.4.7` | patch | [API and usage](../packages/messaging/paymail.md) | Existing Paymail client APIs and protocol semantics are retained. Consumers provide one Express 4.18 or 5 runtime and matching type graph; browser bundles continue to exclude the server router implementation. Consumers of the former bundled Money Button or Tokenized specification documents must follow the authoritative links in docs/specs/README.md. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/ with the package. | -| `@bsv/payment-express-middleware` | `2.1.1` | `2.1.6` | patch | [API and usage](../packages/middleware/payment-express-middleware.md) | No consumer migration is required; legacy x-bsv-payment JSON behavior remains supported, and Express 4 and 5 applications use their own peer-provided Express installation. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/ with the package. | -| `@bsv/sdk` | `2.4.0` | `2.4.2` | patch | [API and usage](../packages/sdk/bsv-sdk.md) | No API migration is required. Historical number-array fast paths and React Native behavior remain compatible. Documentation users should load docs/swagger/swagger.yaml into their preferred viewer instead of using the removed static Swagger UI scaffold. Distributors must keep THIRD_PARTY_NOTICES.md and LICENSES/ with source and browser bundles. | -| `@bsv/simple` | `0.4.1` | `0.5.3` | minor | [API and usage](../packages/helpers/simple.md) | Existing overlay configurations and number-array behavior are unchanged. TTN consumers select network teratestnet; all consumers should upgrade to @bsv/sdk 2.4.2 or later. The Message Box Client dependency advance requires no helper API migration and preserves the default HTTP and live-socket behavior. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/. | -| `@bsv/templates` | `1.9.1` | `1.10.1` | minor | [API and usage](../packages/helpers/templates.md) | No existing consumer migration is required; existing template APIs and generated scripts are unchanged. New R1K1Wallet consumers await lock(), retain each private 32-byte salt, and provide a PIV signer that signs the supplied digest directly without hashing it again. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/ with the package. | -| `@bsv/teranode-listener` | `1.1.1` | `1.1.5` | patch | [API and usage](../packages/network/teranode-listener.md) | No consumer migration is required; listener APIs, topics, and network configuration are unchanged. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/ with the package. | -| `@bsv/verifast` | `0.3.0` | `0.3.5` | patch | [API and usage](../packages/sdk/verifast.md) | No consumer migration is required; exports, verification behavior, worker protocols, package paths, and runtime defaults are unchanged. Keep THIRD_PARTY_NOTICES.md and LICENSES/ with every JavaScript and WebAssembly distribution. | -| `@bsv/wallet-helper` | `0.1.1` | `0.1.7` | patch | [API and usage](../packages/helpers/wallet-helper.md) | No consumer migration is required; fluent builder APIs and transaction semantics are unchanged. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/ with the package. | -| `@bsv/wallet-relay` | `0.2.2` | `0.3.6` | minor | [API and usage](../packages/wallet/wallet-relay.md) | No wallet RPC migration is required; upgrade to @bsv/sdk 2.4.1 or later. Existing relay sessions and number arrays remain valid, and host applications continue to provide their matching Express runtime and type graph. | -| `@bsv/wallet-toolbox` | `2.10.4` | `2.11.1` | minor | [API and usage](../packages/wallet/wallet-toolbox.md) | Existing actions, ordinary noSend calls, permission modules, and UMP v3 tokens require no migration. To use BRC-177, migrate every active Knex store before serving requests and run the default Wallet Toolbox monitor; IndexedDB upgrades automatically to schema version 5. Upgrade signer, active storage service, and remote monitor together to 2.11.0 or later; older remote storage is rejected before prefunding. The Knex migration also adds rebuildable prepared-BEEF and proof-epoch tables with every COOK control disabled. Validate the migration on MySQL before release and the cross-process epoch fence on non-production PXC before enabling writes. Roll out writes before reads, use backfill only after database review, and disable all three flags to roll back. Delete derived prepared rows before downgrading to code that cannot advance the epoch. Semantic modules may add handleRequest; hosts installing @bsv/ecpm-permission-module register it under the ecpm scheme. Argon2id tokens keep the same parameters and derived bytes across WebAssembly and JavaScript runtimes. Host registration is available from each package root; concurrent cold calls share a preload attempt, and hosts must make readiness/preload reentrant and cache permanent failures or back off retries. Native and JavaScript results share byte-type and exact-length validation; unrelated hash-wasm errors still propagate without WebAssembly. Canonical AtomicBEEF and number-array behavior are unchanged; use @bsv/sdk 2.4.2 or later, use docs/storage.md instead of the removed JSight export, and retain THIRD_PARTY_NOTICES.md and LICENSES/. Identity discovery requires the wallet Services ChainTracker to provide canonical chain evidence. No wallet RPC or storage migration is required for this security correction. Direct identityUtils callers must pass their independently maintained ChainTracker; missing verification context and unverifiable overlay candidates no longer produce identities. Local contacts retain separate provenance; inclusion is not proof of freshness or unspentness. | -| `@bsv/wallet-toolbox-client` | `2.10.4` | `2.11.1` | minor | [API and usage](../packages/wallet/wallet-toolbox-client.md) | Existing browser actions, permission modules, and UMP v3 tokens require no migration; IndexedDB upgrades automatically. To use BRC-177 with remote storage, upgrade the active storage service and its default monitor to Wallet Toolbox 2.11.0 or later before upgrading clients; an older server is rejected before prefunding. Prepared BEEF persistence and rollout controls apply only to the full package's Knex provider, so IndexedDB and remote clients require no COOK configuration. Semantic modules may add handleRequest; installing @bsv/ecpm-permission-module requires registration under the ecpm scheme. Argon2id tokens keep the same parameters and derived bytes across WebAssembly and JavaScript runtimes. Host registration is available from each package root; concurrent cold calls share a preload attempt, and hosts must make readiness/preload reentrant and cache permanent failures or back off retries. Native and JavaScript results share byte-type and exact-length validation; unrelated hash-wasm errors still propagate without WebAssembly. Browser exports, wire types, canonical AtomicBEEF behavior, and pagination contracts are unchanged; use @bsv/sdk 2.4.2 or later and retain THIRD_PARTY_NOTICES.md and LICENSES/. Identity discovery requires the wallet Services ChainTracker to provide canonical chain evidence. No wallet RPC or storage migration is required for this security correction. Direct identityUtils callers must pass their independently maintained ChainTracker; missing verification context and unverifiable overlay candidates no longer produce identities. Local contacts retain separate provenance; inclusion is not proof of freshness or unspentness. | -| `@bsv/wallet-toolbox-mobile` | `2.10.4` | `2.11.1` | minor | [API and usage](../packages/wallet/wallet-toolbox-mobile.md) | Existing mobile actions, permission modules, and UMP v3 tokens require no migration. To use BRC-177, migrate and upgrade the active remote storage service and its default monitor to Wallet Toolbox 2.11.0 or later before upgrading clients; an older server is rejected before prefunding. Prepared BEEF persistence and rollout controls apply only to the full package's Knex provider, so mobile remote clients require no COOK configuration. Semantic modules may add handleRequest without changing the Wallet interface. Argon2id tokens keep the same parameters and derived bytes across WebAssembly and JavaScript runtimes; no user device setting is required. Host registration is available from the mobile root; concurrent cold calls share a preload attempt, and hosts must make readiness/preload reentrant and cache permanent failures or back off retries. Native and JavaScript results share byte-type and exact-length validation; unrelated hash-wasm errors still propagate without WebAssembly. React Native exports, wire types, and canonical AtomicBEEF behavior are unchanged; use @bsv/sdk 2.4.2 or later and retain THIRD_PARTY_NOTICES.md and LICENSES/. Identity discovery requires the wallet Services ChainTracker to provide canonical chain evidence. No wallet RPC or storage migration is required for this security correction. Direct identityUtils callers must pass their independently maintained ChainTracker; missing verification context and unverifiable overlay candidates no longer produce identities. Local contacts retain separate provenance; inclusion is not proof of freshness or unspentness. | -| `create-bsv-app` | `1.0.2` | `1.1.1` | minor | [API and usage](../packages/helpers/create-bsv-app.md) | Existing mainnet and testnet scaffolds are unchanged. New TTN projects pass --network ttn or select TerraTestNet in the configurator. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/ with the package. | +| Package | npm baseline | Source | Candidate | API | Migration | +| --------------------------------- | ------------ | -------- | --------- | --------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `@bsv/402-pay` | `0.2.1` | `0.2.5` | patch | [API and usage](../packages/middleware/402-pay.md) | No consumer migration is required; client and server exports, payment protocol behavior, and runtime defaults are unchanged. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/ with the package. | +| `@bsv/air-gap` | `0.0.0` | `0.1.2` | minor | [API and usage](../packages/helpers/air-gap.md) | No consumer migration is required; this is the first published release of a new package with no prior public API. The experimental pre-release framing that circulated on the unmerged feature branch is not accepted by the v1 decoder. | +| `@bsv/amountinator` | `2.1.1` | `2.1.5` | patch | [API and usage](../packages/helpers/amountinator.md) | No consumer migration is required; this is a backward-compatible patch candidate. | +| `@bsv/auth` | `0.1.1` | `0.1.4` | patch | [API and usage](../packages/middleware/auth.md) | No consumer migration is required; authentication APIs, wire behavior, and runtime defaults are unchanged. | +| `@bsv/auth-express-middleware` | `2.2.0` | `2.2.3` | patch | [API and usage](../packages/middleware/auth-express-middleware.md) | No API migration is required. Upgrade to @bsv/sdk 2.4.1 or later for the shared byte-boundary contract. Generic signed application-body canonicalization remains unchanged so old and new peers verify identical bytes. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/ with the package. | +| `@bsv/authsocket` | `2.1.1` | `2.1.7` | patch | [API and usage](../packages/messaging/authsocket.md) | No API migration is required. Existing event data, including numeric-key objects under byte-like names, is unchanged; typed payment protocols recover historical byte objects at their explicit fields. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/ with the package. | +| `@bsv/authsocket-client` | `2.1.1` | `2.1.6` | patch | [API and usage](../packages/messaging/authsocket-client.md) | No API migration is required. Existing event data, including numeric-key objects under byte-like names, is unchanged; typed payment protocols recover historical byte objects at their explicit fields. Distributors who copy the UMD file must keep THIRD_PARTY_NOTICES.md and LICENSES/ with it. | +| `@bsv/btms` | `1.1.1` | `1.2.2` | minor | [API and usage](../packages/wallet/btms.md) | Existing local, mainnet, testnet, and number-array behavior is unchanged. TTN consumers select networkPreset teratestnet; all consumers should upgrade to @bsv/sdk 2.4.1 or later for byte-boundary compatibility. | +| `@bsv/btms-permission-module` | `1.1.1` | `1.1.4` | patch | [API and usage](../packages/wallet/btms-permission-module.md) | No consumer migration is required; permission-module APIs and token semantics are unchanged. | +| `@bsv/chirp` | `0.0.0` | `0.1.1` | minor | [API and usage](../packages/network/chirp.md) | No consumer migration is required; this is the first release of a new additive package. Existing @bsv/sdk StorageUploader, StorageDownloader, StorageUtils, UHRP identifiers, overlays, and server routes remain unchanged. BRC-167 remains authoritative if the implementation and standard differ. | +| `@bsv/did` | `0.2.1` | `0.2.5` | patch | [API and usage](../packages/helpers/did.md) | No consumer migration is required; DID APIs, encodings, credential behavior, and supported import forms are unchanged. | +| `@bsv/did-client` | `1.2.1` | `1.3.1` | minor | [API and usage](../packages/helpers/did-client.md) | Existing local, mainnet, and testnet behavior is unchanged. TTN consumers select networkPreset teratestnet and use @bsv/sdk 2.4 or later. Distributors who copy the UMD file must keep THIRD_PARTY_NOTICES.md and LICENSES/ with it. | +| `@bsv/ecpm-permission-module` | `0.0.0` | `0.1.0` | minor | [API and usage](../packages/wallet/ecpm-permission-module.md) | No existing consumer migration is required; this is the first release. Wallet hosts register the module under the ecpm scheme and supply their ordinary key deriver, authorization handler, and optional privileged key provider. | +| `@bsv/fund-wallet` | `1.4.1` | `1.4.4` | patch | [API and usage](../packages/helpers/fund-wallet.md) | No consumer migration is required; wallet funding APIs and transaction behavior are unchanged. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/ with the package. | +| `@bsv/gasp` | `1.3.1` | `1.3.6` | patch | [API and usage](../packages/overlays/gasp.md) | No consumer migration is required; existing constructor calls, imports, synchronization behavior, and wire semantics are unchanged. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/ with the package. | +| `@bsv/lch` | `0.0.0` | `0.1.0` | minor | [API and usage](../packages/content/lch.md) | No consumer migration is required; this is the first release of a new additive package. Applications must give quote() the verified Asset encryption descriptor and selected Offer key-delivery mechanism, keep createPayment behind explicit wallet authorization, persist the funded transaction and every partial settlement proof through recovery, retry with that same transaction, distinguish finalized from accepted evidence, fail closed on unknown settlement or evidence profiles, and supply an address-pinning connector for DNS endpoints. Payees should select receipt-complete unless they explicitly accept authorized-output provider, privacy, and pre-internalization key-release tradeoffs. Distributors must retain THIRD_PARTY_NOTICES.md with the package; the new profile adds no dependency. Published BRC-170 remains authoritative if the implementation and standard differ. | +| `@bsv/message-box-client` | `2.4.0` | `2.5.0` | minor | [API and usage](../packages/messaging/message-box-client.md) | No API migration is required. socketOptions is optional and nothing is forwarded by default, so socket transport negotiation and every HTTP code path are unchanged for existing callers; wallet and originator stay client-owned and cannot be overridden through it. managerOptions excludes autoConnect and retries: JavaScript callers receive an immediate error for autoConnect: false or nonzero message retries because the live socket starts immediately and AuthSocket does not send Socket.IO acknowledgements. Connection reconnection options remain supported. Upgrade @bsv/sdk and @bsv/message-box-client together; historical number-array wallets, current Uint8Array substrates, and already-pending numeric-key messages interoperate through the same portable transaction form. Distributors who copy the UMD file must keep THIRD_PARTY_NOTICES.md and LICENSES/ with it. | +| `@bsv/overlay` | `2.2.1` | `2.3.1` | minor | [API and usage](../packages/overlays/overlay.md) | Existing Engine and TopicManager implementations remain valid. Lookup results default to 1,000 formulas; pass -1 only when an equivalent deployment bound exists. Topic managers whose validation creates provisional external state should implement abortAdmissibleOutputs, while read-only managers require no change. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/ with the package. | +| `@bsv/overlay-discovery-services` | `2.1.1` | `2.2.1` | minor | [API and usage](../packages/overlays/overlay-discovery-services.md) | Existing mainnet and testnet advertisers are unchanged. TTN operators pass chain ttn and provision the staging storage and overlay endpoints before advertising. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/ with the package. | +| `@bsv/overlay-express` | `2.5.0` | `2.6.1` | minor | [API and usage](../packages/overlays/overlay-express.md) | Existing mainnet and testnet servers are unchanged. TTN servers call configureNetwork('ttn'), configureArcade with the TTN endpoint, and configureChaintracks or configureChainTracker before engine initialization. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/ with the package. | +| `@bsv/overlay-topics` | `1.6.10` | `1.7.1` | minor | [API and usage](../packages/overlays/overlay-topics.md) | Existing topic and lookup identifiers remain unchanged. Production UMP overlays must give UMPTopicManager and the UMP lookup service Mongo-backed stores that use the same database, then roll out before updated wallet clients; the no-argument manager is bounded but intended only for isolated single-process use. The reservation and bootstrap-marker collections are additive and initialize from currently indexed UMP UTXOs; take a MongoDB backup before rollout. Legacy ambiguous rows remain visible and can be resolved with WAB pinning rather than deleted. | +| `@bsv/paymail` | `2.4.2` | `2.4.7` | patch | [API and usage](../packages/messaging/paymail.md) | Existing Paymail client APIs and protocol semantics are retained. Consumers provide one Express 4.18 or 5 runtime and matching type graph; browser bundles continue to exclude the server router implementation. Consumers of the former bundled Money Button or Tokenized specification documents must follow the authoritative links in docs/specs/README.md. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/ with the package. | +| `@bsv/payment-express-middleware` | `2.1.1` | `2.1.6` | patch | [API and usage](../packages/middleware/payment-express-middleware.md) | No consumer migration is required; legacy x-bsv-payment JSON behavior remains supported, and Express 4 and 5 applications use their own peer-provided Express installation. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/ with the package. | +| `@bsv/sdk` | `2.4.0` | `2.5.0` | minor | [API and usage](../packages/sdk/bsv-sdk.md) | No API migration is required. Historical number-array fast paths and React Native behavior remain compatible. Documentation users should load docs/swagger/swagger.yaml into their preferred viewer instead of using the removed static Swagger UI scaffold. Distributors must keep THIRD_PARTY_NOTICES.md and LICENSES/ with source and browser bundles. Existing Transaction.verify and resolver call shapes remain unchanged. New coordinator callers supply an independently maintained ChainTracker, explicit chain namespace and semantic policy id, handle bounded limit/timeout outcomes, and dispose sessions when finished. Optional ChainTracker context markers and cancellation signals are additive. No wire or storage migration is required. Coordinate resolver evidenceLimits with downstream evidence admission budgets when increasing them; limit events mean partial bounded intake, not invalid transaction evidence. | +| `@bsv/simple` | `0.4.1` | `0.5.3` | minor | [API and usage](../packages/helpers/simple.md) | Existing overlay configurations and number-array behavior are unchanged. TTN consumers select network teratestnet; all consumers should upgrade to @bsv/sdk 2.4.2 or later. The Message Box Client dependency advance requires no helper API migration and preserves the default HTTP and live-socket behavior. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/. | +| `@bsv/templates` | `1.9.1` | `1.10.1` | minor | [API and usage](../packages/helpers/templates.md) | No existing consumer migration is required; existing template APIs and generated scripts are unchanged. New R1K1Wallet consumers await lock(), retain each private 32-byte salt, and provide a PIV signer that signs the supplied digest directly without hashing it again. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/ with the package. | +| `@bsv/teranode-listener` | `1.1.1` | `1.1.5` | patch | [API and usage](../packages/network/teranode-listener.md) | No consumer migration is required; listener APIs, topics, and network configuration are unchanged. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/ with the package. | +| `@bsv/verifast` | `0.3.0` | `0.3.5` | patch | [API and usage](../packages/sdk/verifast.md) | No consumer migration is required; exports, verification behavior, worker protocols, package paths, and runtime defaults are unchanged. Keep THIRD_PARTY_NOTICES.md and LICENSES/ with every JavaScript and WebAssembly distribution. | +| `@bsv/wallet-helper` | `0.1.1` | `0.1.7` | patch | [API and usage](../packages/helpers/wallet-helper.md) | No consumer migration is required; fluent builder APIs and transaction semantics are unchanged. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/ with the package. | +| `@bsv/wallet-relay` | `0.2.2` | `0.3.6` | minor | [API and usage](../packages/wallet/wallet-relay.md) | No wallet RPC migration is required; upgrade to @bsv/sdk 2.4.1 or later. Existing relay sessions and number arrays remain valid, and host applications continue to provide their matching Express runtime and type graph. | +| `@bsv/wallet-toolbox` | `2.10.4` | `2.11.1` | minor | [API and usage](../packages/wallet/wallet-toolbox.md) | Existing actions, ordinary noSend calls, permission modules, and UMP v3 tokens require no migration. To use BRC-177, migrate every active Knex store before serving requests and run the default Wallet Toolbox monitor; IndexedDB upgrades automatically to schema version 5. Upgrade signer, active storage service, and remote monitor together to 2.11.0 or later; older remote storage is rejected before prefunding. The Knex migration also adds rebuildable prepared-BEEF and proof-epoch tables with every COOK control disabled. Validate the migration on MySQL before release and the cross-process epoch fence on non-production PXC before enabling writes. Roll out writes before reads, use backfill only after database review, and disable all three flags to roll back. Delete derived prepared rows before downgrading to code that cannot advance the epoch. Semantic modules may add handleRequest; hosts installing @bsv/ecpm-permission-module register it under the ecpm scheme. Argon2id tokens keep the same parameters and derived bytes across WebAssembly and JavaScript runtimes. Host registration is available from each package root; concurrent cold calls share a preload attempt, and hosts must make readiness/preload reentrant and cache permanent failures or back off retries. Native and JavaScript results share byte-type and exact-length validation; unrelated hash-wasm errors still propagate without WebAssembly. Canonical AtomicBEEF and number-array behavior are unchanged; use @bsv/sdk 2.5.0 or later, use docs/storage.md instead of the removed JSight export, and retain THIRD_PARTY_NOTICES.md and LICENSES/. Identity discovery requires the wallet Services ChainTracker to provide canonical chain evidence. No wallet RPC or storage migration is required for this security correction. Direct identityUtils callers must pass their independently maintained ChainTracker; missing verification context and unverifiable overlay candidates no longer produce identities. Local contacts retain separate provenance; inclusion is not proof of freshness or unspentness. Identity RPC, trust filtering, local contacts and stored data remain unchanged; no additional migration is needed. The unpublished 2.11.1 candidate aggregates C01/C02/C03 changes against published 2.10.4. New evidence limits are local admission policy, not consensus limits; applications using the generic coordinator can configure them explicitly. Identity lookup propagates typed limit/timeout failures instead of returning a definitive empty result when bounded work cannot complete. Direct utility callers can pass matching queryOverlayEvidence and IdentityEvidenceVerifier byte limits. ChainTracks adapters bracket validation with fresh tips and local reorg/reset epochs where supported; remote observations are not atomic snapshots. | +| `@bsv/wallet-toolbox-client` | `2.10.4` | `2.11.1` | minor | [API and usage](../packages/wallet/wallet-toolbox-client.md) | Existing browser actions, permission modules, and UMP v3 tokens require no migration; IndexedDB upgrades automatically. To use BRC-177 with remote storage, upgrade the active storage service and its default monitor to Wallet Toolbox 2.11.0 or later before upgrading clients; an older server is rejected before prefunding. Prepared BEEF persistence and rollout controls apply only to the full package's Knex provider, so IndexedDB and remote clients require no COOK configuration. Semantic modules may add handleRequest; installing @bsv/ecpm-permission-module requires registration under the ecpm scheme. Argon2id tokens keep the same parameters and derived bytes across WebAssembly and JavaScript runtimes. Host registration is available from each package root; concurrent cold calls share a preload attempt, and hosts must make readiness/preload reentrant and cache permanent failures or back off retries. Native and JavaScript results share byte-type and exact-length validation; unrelated hash-wasm errors still propagate without WebAssembly. Browser exports, wire types, canonical AtomicBEEF behavior, and pagination contracts are unchanged; use @bsv/sdk 2.5.0 or later and retain THIRD_PARTY_NOTICES.md and LICENSES/. Identity discovery requires the wallet Services ChainTracker to provide canonical chain evidence. No wallet RPC or storage migration is required for this security correction. Direct identityUtils callers must pass their independently maintained ChainTracker; missing verification context and unverifiable overlay candidates no longer produce identities. Local contacts retain separate provenance; inclusion is not proof of freshness or unspentness. Identity RPC, trust filtering, local contacts and stored data remain unchanged; no additional migration is needed. The unpublished 2.11.1 candidate aggregates C01/C02/C03 changes against published 2.10.4. New evidence limits are local admission policy, not consensus limits; applications using the generic coordinator can configure them explicitly. Identity lookup propagates typed limit/timeout failures instead of returning a definitive empty result when bounded work cannot complete. Direct utility callers can pass matching queryOverlayEvidence and IdentityEvidenceVerifier byte limits. ChainTracks adapters bracket validation with fresh tips and local reorg/reset epochs where supported; remote observations are not atomic snapshots. | +| `@bsv/wallet-toolbox-mobile` | `2.10.4` | `2.11.1` | minor | [API and usage](../packages/wallet/wallet-toolbox-mobile.md) | Existing mobile actions, permission modules, and UMP v3 tokens require no migration. To use BRC-177, migrate and upgrade the active remote storage service and its default monitor to Wallet Toolbox 2.11.0 or later before upgrading clients; an older server is rejected before prefunding. Prepared BEEF persistence and rollout controls apply only to the full package's Knex provider, so mobile remote clients require no COOK configuration. Semantic modules may add handleRequest without changing the Wallet interface. Argon2id tokens keep the same parameters and derived bytes across WebAssembly and JavaScript runtimes; no user device setting is required. Host registration is available from the mobile root; concurrent cold calls share a preload attempt, and hosts must make readiness/preload reentrant and cache permanent failures or back off retries. Native and JavaScript results share byte-type and exact-length validation; unrelated hash-wasm errors still propagate without WebAssembly. React Native exports, wire types, and canonical AtomicBEEF behavior are unchanged; use @bsv/sdk 2.5.0 or later and retain THIRD_PARTY_NOTICES.md and LICENSES/. Identity discovery requires the wallet Services ChainTracker to provide canonical chain evidence. No wallet RPC or storage migration is required for this security correction. Direct identityUtils callers must pass their independently maintained ChainTracker; missing verification context and unverifiable overlay candidates no longer produce identities. Local contacts retain separate provenance; inclusion is not proof of freshness or unspentness. Identity RPC, trust filtering, local contacts and stored data remain unchanged; no additional migration is needed. The unpublished 2.11.1 candidate aggregates C01/C02/C03 changes against published 2.10.4. New evidence limits are local admission policy, not consensus limits; applications using the generic coordinator can configure them explicitly. Identity lookup propagates typed limit/timeout failures instead of returning a definitive empty result when bounded work cannot complete. Direct utility callers can pass matching queryOverlayEvidence and IdentityEvidenceVerifier byte limits. ChainTracks adapters bracket validation with fresh tips and local reorg/reset epochs where supported; remote observations are not atomic snapshots. | +| `create-bsv-app` | `1.0.2` | `1.1.1` | minor | [API and usage](../packages/helpers/create-bsv-app.md) | Existing mainnet and testnet scaffolds are unchanged. New TTN projects pass --network ttn or select TerraTestNet in the configurator. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/ with the package. | `none` means the source manifest matches the recorded npm baseline. Any other value is an unpublished candidate. Publication, tags, releases, registry @@ -359,8 +359,8 @@ CLI entry points: `{"lch":"./dist/cli.js"}`. - Package documentation: [docs/packages/sdk/bsv-sdk.md](../packages/sdk/bsv-sdk.md) - Source: [packages/sdk](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/sdk) -- Release note: Adds one strict BRC-100 byte-boundary contract and secure React Native bridge validation, and completes incorporated-code compliance with hash-pinned provenance, exact MIT/ISC/BSD/Apache notices, source lineage markers, package payloads, a retained UMD banner, SBOM components, and release enforcement. Removes a broken copied Swagger UI scaffold and screenshot; the first-party OpenAPI source remains available for use with any viewer. Standardizes first-party author metadata on the current BSV Association name. -- Migration: No API migration is required. Historical number-array fast paths and React Native behavior remain compatible. Documentation users should load docs/swagger/swagger.yaml into their preferred viewer instead of using the removed static Swagger UI scaffold. Distributors must keep THIRD_PARTY_NOTICES.md and LICENSES/ with source and browser bundles. +- Release note: Adds one strict BRC-100 byte-boundary contract and secure React Native bridge validation, and completes incorporated-code compliance with hash-pinned provenance, exact MIT/ISC/BSD/Apache notices, source lineage markers, package payloads, a retained UMD banner, SBOM components, and release enforcement. Removes a broken copied Swagger UI scaffold and screenshot; the first-party OpenAPI source remains available for use with any viewer. Standardizes first-party author metadata on the current BSV Association name. Adds a generic bounded transaction-evidence coordinator with derived-txid work sharing, alternate proof candidates, exact script-work reuse, canonical dependency rechecks, context fencing and reference-counted cancellation. Adds optional pre-aggregation lookup evidence receipts while preserving legacy resolver defaults. Adds optional fresh canonical-context tokens, with explicit limits for legacy tracker and remote ABA assurance. +- Migration: No API migration is required. Historical number-array fast paths and React Native behavior remain compatible. Documentation users should load docs/swagger/swagger.yaml into their preferred viewer instead of using the removed static Swagger UI scaffold. Distributors must keep THIRD_PARTY_NOTICES.md and LICENSES/ with source and browser bundles. Existing Transaction.verify and resolver call shapes remain unchanged. New coordinator callers supply an independently maintained ChainTracker, explicit chain namespace and semantic policy id, handle bounded limit/timeout outcomes, and dispose sessions when finished. Optional ChainTracker context markers and cancellation signals are additive. No wire or storage migration is required. Coordinate resolver evidenceLimits with downstream evidence admission budgets when increasing them; limit events mean partial bounded intake, not invalid transaction evidence. | Public subpath | Runtime target(s) | Declaration target(s) | | ---------------------------------- | ---------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------- | @@ -520,8 +520,8 @@ CLI entry points: `{"wallet-relay":"./bin/init.mjs"}`. - Package documentation: [docs/packages/wallet/wallet-toolbox.md](../packages/wallet/wallet-toolbox.md) - Source: [packages/wallet/wallet-toolbox](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/wallet/wallet-toolbox) -- Release note: Adds the built-in BRC-177 noSend-expiry reference implementation with exact prefunding, durable pre-signed reclaim, atomic active-storage monitoring, backoff-controlled recovery, cross-device lifecycle synchronization, and proof-finalized race handling. Adds opt-in prepared BEEF storage for Knex-backed normal createAction funding: verified, checksummed proof closures are persisted after foreground completion and reused on later hits, while broad lookups, misses, and cache failures retain the canonical path. Reads, writes, bounded queueing, and gradual backfill default off; reorganizations stale derived rows and fence in-flight cross-process writes with a database proof epoch. Also adds the optional semantic handleRequest hook for BRC-98/99/111 permission modules, an interoperable asynchronous JavaScript Argon2id fallback when WebAssembly is unavailable, and an optional proven-ready native Argon2id backend for host runtimes, retains BRC-95/BRC-100 compatibility and stable bounded pagination, removes the obsolete JSight application bundle, and preserves the earlier Open BSV grant. Secures final overlay identity discovery with independently verified, byte-bound transaction evidence, subject-signed output fields, and explicit certificate success; cached response evidence is revalidated before use. -- Migration: Existing actions, ordinary noSend calls, permission modules, and UMP v3 tokens require no migration. To use BRC-177, migrate every active Knex store before serving requests and run the default Wallet Toolbox monitor; IndexedDB upgrades automatically to schema version 5. Upgrade signer, active storage service, and remote monitor together to 2.11.0 or later; older remote storage is rejected before prefunding. The Knex migration also adds rebuildable prepared-BEEF and proof-epoch tables with every COOK control disabled. Validate the migration on MySQL before release and the cross-process epoch fence on non-production PXC before enabling writes. Roll out writes before reads, use backfill only after database review, and disable all three flags to roll back. Delete derived prepared rows before downgrading to code that cannot advance the epoch. Semantic modules may add handleRequest; hosts installing @bsv/ecpm-permission-module register it under the ecpm scheme. Argon2id tokens keep the same parameters and derived bytes across WebAssembly and JavaScript runtimes. Host registration is available from each package root; concurrent cold calls share a preload attempt, and hosts must make readiness/preload reentrant and cache permanent failures or back off retries. Native and JavaScript results share byte-type and exact-length validation; unrelated hash-wasm errors still propagate without WebAssembly. Canonical AtomicBEEF and number-array behavior are unchanged; use @bsv/sdk 2.4.2 or later, use docs/storage.md instead of the removed JSight export, and retain THIRD_PARTY_NOTICES.md and LICENSES/. Identity discovery requires the wallet Services ChainTracker to provide canonical chain evidence. No wallet RPC or storage migration is required for this security correction. Direct identityUtils callers must pass their independently maintained ChainTracker; missing verification context and unverifiable overlay candidates no longer produce identities. Local contacts retain separate provenance; inclusion is not proof of freshness or unspentness. +- Release note: Adds the built-in BRC-177 noSend-expiry reference implementation with exact prefunding, durable pre-signed reclaim, atomic active-storage monitoring, backoff-controlled recovery, cross-device lifecycle synchronization, and proof-finalized race handling. Adds opt-in prepared BEEF storage for Knex-backed normal createAction funding: verified, checksummed proof closures are persisted after foreground completion and reused on later hits, while broad lookups, misses, and cache failures retain the canonical path. Reads, writes, bounded queueing, and gradual backfill default off; reorganizations stale derived rows and fence in-flight cross-process writes with a database proof epoch. Also adds the optional semantic handleRequest hook for BRC-98/99/111 permission modules, an interoperable asynchronous JavaScript Argon2id fallback when WebAssembly is unavailable, and an optional proven-ready native Argon2id backend for host runtimes, retains BRC-95/BRC-100 compatibility and stable bounded pagination, removes the obsolete JSight application bundle, and preserves the earlier Open BSV grant. Secures final overlay identity discovery with independently verified, byte-bound transaction evidence, subject-signed output fields, and explicit certificate success; cached response evidence is revalidated before use. Shares bounded transaction and identity cryptographic work across final discovery calls, retains alternate receipts before resolver deduplication, rechecks current canonical anchors, and fixes stale Chaintracks height-root reuse with provider/mode context fencing. +- Migration: Existing actions, ordinary noSend calls, permission modules, and UMP v3 tokens require no migration. To use BRC-177, migrate every active Knex store before serving requests and run the default Wallet Toolbox monitor; IndexedDB upgrades automatically to schema version 5. Upgrade signer, active storage service, and remote monitor together to 2.11.0 or later; older remote storage is rejected before prefunding. The Knex migration also adds rebuildable prepared-BEEF and proof-epoch tables with every COOK control disabled. Validate the migration on MySQL before release and the cross-process epoch fence on non-production PXC before enabling writes. Roll out writes before reads, use backfill only after database review, and disable all three flags to roll back. Delete derived prepared rows before downgrading to code that cannot advance the epoch. Semantic modules may add handleRequest; hosts installing @bsv/ecpm-permission-module register it under the ecpm scheme. Argon2id tokens keep the same parameters and derived bytes across WebAssembly and JavaScript runtimes. Host registration is available from each package root; concurrent cold calls share a preload attempt, and hosts must make readiness/preload reentrant and cache permanent failures or back off retries. Native and JavaScript results share byte-type and exact-length validation; unrelated hash-wasm errors still propagate without WebAssembly. Canonical AtomicBEEF and number-array behavior are unchanged; use @bsv/sdk 2.5.0 or later, use docs/storage.md instead of the removed JSight export, and retain THIRD_PARTY_NOTICES.md and LICENSES/. Identity discovery requires the wallet Services ChainTracker to provide canonical chain evidence. No wallet RPC or storage migration is required for this security correction. Direct identityUtils callers must pass their independently maintained ChainTracker; missing verification context and unverifiable overlay candidates no longer produce identities. Local contacts retain separate provenance; inclusion is not proof of freshness or unspentness. Identity RPC, trust filtering, local contacts and stored data remain unchanged; no additional migration is needed. The unpublished 2.11.1 candidate aggregates C01/C02/C03 changes against published 2.10.4. New evidence limits are local admission policy, not consensus limits; applications using the generic coordinator can configure them explicitly. Identity lookup propagates typed limit/timeout failures instead of returning a definitive empty result when bounded work cannot complete. Direct utility callers can pass matching queryOverlayEvidence and IdentityEvidenceVerifier byte limits. ChainTracks adapters bracket validation with fresh tips and local reorg/reset epochs where supported; remote observations are not atomic snapshots. | Public subpath | Runtime target(s) | Declaration target(s) | | ---------------- | ---------------------------------------------------- | -------------------------- | @@ -534,8 +534,8 @@ CLI entry points: `{"wallet-relay":"./bin/init.mjs"}`. - Package documentation: [docs/packages/wallet/wallet-toolbox-client.md](../packages/wallet/wallet-toolbox-client.md) - Source: [packages/wallet/wallet-toolbox/client](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/wallet/wallet-toolbox/client) -- Release note: Adds the built-in browser BRC-177 noSend-expiry signer, IndexedDB schema version 5 lifecycle state, remote storage capability negotiation, default monitor coordination, the optional semantic handleRequest hook, an interoperable asynchronous JavaScript Argon2id fallback when WebAssembly is unavailable, and an optional proven-ready native Argon2id backend for host runtimes. Carries the shared prepared-BEEF-aware createAction contract while IndexedDB remains canonical-only; compatible remote Knex providers can enable COOK independently. Retains transformation modules, BRC-100 wire compatibility, stable IndexedDB totals, current browser compatibility fixes, and earlier Open BSV grants. Secures final overlay identity discovery with independently verified, byte-bound transaction evidence, subject-signed output fields, and explicit certificate success; cached response evidence is revalidated before use. -- Migration: Existing browser actions, permission modules, and UMP v3 tokens require no migration; IndexedDB upgrades automatically. To use BRC-177 with remote storage, upgrade the active storage service and its default monitor to Wallet Toolbox 2.11.0 or later before upgrading clients; an older server is rejected before prefunding. Prepared BEEF persistence and rollout controls apply only to the full package's Knex provider, so IndexedDB and remote clients require no COOK configuration. Semantic modules may add handleRequest; installing @bsv/ecpm-permission-module requires registration under the ecpm scheme. Argon2id tokens keep the same parameters and derived bytes across WebAssembly and JavaScript runtimes. Host registration is available from each package root; concurrent cold calls share a preload attempt, and hosts must make readiness/preload reentrant and cache permanent failures or back off retries. Native and JavaScript results share byte-type and exact-length validation; unrelated hash-wasm errors still propagate without WebAssembly. Browser exports, wire types, canonical AtomicBEEF behavior, and pagination contracts are unchanged; use @bsv/sdk 2.4.2 or later and retain THIRD_PARTY_NOTICES.md and LICENSES/. Identity discovery requires the wallet Services ChainTracker to provide canonical chain evidence. No wallet RPC or storage migration is required for this security correction. Direct identityUtils callers must pass their independently maintained ChainTracker; missing verification context and unverifiable overlay candidates no longer produce identities. Local contacts retain separate provenance; inclusion is not proof of freshness or unspentness. +- Release note: Adds the built-in browser BRC-177 noSend-expiry signer, IndexedDB schema version 5 lifecycle state, remote storage capability negotiation, default monitor coordination, the optional semantic handleRequest hook, an interoperable asynchronous JavaScript Argon2id fallback when WebAssembly is unavailable, and an optional proven-ready native Argon2id backend for host runtimes. Carries the shared prepared-BEEF-aware createAction contract while IndexedDB remains canonical-only; compatible remote Knex providers can enable COOK independently. Retains transformation modules, BRC-100 wire compatibility, stable IndexedDB totals, current browser compatibility fixes, and earlier Open BSV grants. Secures final overlay identity discovery with independently verified, byte-bound transaction evidence, subject-signed output fields, and explicit certificate success; cached response evidence is revalidated before use. Shares bounded transaction and identity cryptographic work across final discovery calls, retains alternate receipts before resolver deduplication, rechecks current canonical anchors, and fixes stale Chaintracks height-root reuse with provider/mode context fencing. +- Migration: Existing browser actions, permission modules, and UMP v3 tokens require no migration; IndexedDB upgrades automatically. To use BRC-177 with remote storage, upgrade the active storage service and its default monitor to Wallet Toolbox 2.11.0 or later before upgrading clients; an older server is rejected before prefunding. Prepared BEEF persistence and rollout controls apply only to the full package's Knex provider, so IndexedDB and remote clients require no COOK configuration. Semantic modules may add handleRequest; installing @bsv/ecpm-permission-module requires registration under the ecpm scheme. Argon2id tokens keep the same parameters and derived bytes across WebAssembly and JavaScript runtimes. Host registration is available from each package root; concurrent cold calls share a preload attempt, and hosts must make readiness/preload reentrant and cache permanent failures or back off retries. Native and JavaScript results share byte-type and exact-length validation; unrelated hash-wasm errors still propagate without WebAssembly. Browser exports, wire types, canonical AtomicBEEF behavior, and pagination contracts are unchanged; use @bsv/sdk 2.5.0 or later and retain THIRD_PARTY_NOTICES.md and LICENSES/. Identity discovery requires the wallet Services ChainTracker to provide canonical chain evidence. No wallet RPC or storage migration is required for this security correction. Direct identityUtils callers must pass their independently maintained ChainTracker; missing verification context and unverifiable overlay candidates no longer produce identities. Local contacts retain separate provenance; inclusion is not proof of freshness or unspentness. Identity RPC, trust filtering, local contacts and stored data remain unchanged; no additional migration is needed. The unpublished 2.11.1 candidate aggregates C01/C02/C03 changes against published 2.10.4. New evidence limits are local admission policy, not consensus limits; applications using the generic coordinator can configure them explicitly. Identity lookup propagates typed limit/timeout failures instead of returning a definitive empty result when bounded work cannot complete. Direct utility callers can pass matching queryOverlayEvidence and IdentityEvidenceVerifier byte limits. ChainTracks adapters bracket validation with fresh tips and local reorg/reset epochs where supported; remote observations are not atomic snapshots. | Public subpath | Runtime target(s) | Declaration target(s) | | ---------------- | -------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------- | @@ -546,8 +546,8 @@ CLI entry points: `{"wallet-relay":"./bin/init.mjs"}`. - Package documentation: [docs/packages/wallet/wallet-toolbox-mobile.md](../packages/wallet/wallet-toolbox-mobile.md) - Source: [packages/wallet/wallet-toolbox/mobile](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/wallet/wallet-toolbox/mobile) -- Release note: Adds the built-in mobile BRC-177 noSend-expiry signer, remote storage capability negotiation, default-monitor ownership coordination across restarts and devices, the optional semantic handleRequest hook, an interoperable asynchronous JavaScript Argon2id fallback for React Native runtimes without WebAssembly, and an optional proven-ready native Argon2id backend for host runtimes. Carries the shared prepared-BEEF-aware createAction contract while mobile storage remains canonical-only; compatible remote Knex providers can enable COOK independently. Retains transformation modules, BRC-100 wire compatibility, current mobile compatibility fixes, and earlier Open BSV grants. Secures final overlay identity discovery with independently verified, byte-bound transaction evidence, subject-signed output fields, and explicit certificate success; cached response evidence is revalidated before use. -- Migration: Existing mobile actions, permission modules, and UMP v3 tokens require no migration. To use BRC-177, migrate and upgrade the active remote storage service and its default monitor to Wallet Toolbox 2.11.0 or later before upgrading clients; an older server is rejected before prefunding. Prepared BEEF persistence and rollout controls apply only to the full package's Knex provider, so mobile remote clients require no COOK configuration. Semantic modules may add handleRequest without changing the Wallet interface. Argon2id tokens keep the same parameters and derived bytes across WebAssembly and JavaScript runtimes; no user device setting is required. Host registration is available from the mobile root; concurrent cold calls share a preload attempt, and hosts must make readiness/preload reentrant and cache permanent failures or back off retries. Native and JavaScript results share byte-type and exact-length validation; unrelated hash-wasm errors still propagate without WebAssembly. React Native exports, wire types, and canonical AtomicBEEF behavior are unchanged; use @bsv/sdk 2.4.2 or later and retain THIRD_PARTY_NOTICES.md and LICENSES/. Identity discovery requires the wallet Services ChainTracker to provide canonical chain evidence. No wallet RPC or storage migration is required for this security correction. Direct identityUtils callers must pass their independently maintained ChainTracker; missing verification context and unverifiable overlay candidates no longer produce identities. Local contacts retain separate provenance; inclusion is not proof of freshness or unspentness. +- Release note: Adds the built-in mobile BRC-177 noSend-expiry signer, remote storage capability negotiation, default-monitor ownership coordination across restarts and devices, the optional semantic handleRequest hook, an interoperable asynchronous JavaScript Argon2id fallback for React Native runtimes without WebAssembly, and an optional proven-ready native Argon2id backend for host runtimes. Carries the shared prepared-BEEF-aware createAction contract while mobile storage remains canonical-only; compatible remote Knex providers can enable COOK independently. Retains transformation modules, BRC-100 wire compatibility, current mobile compatibility fixes, and earlier Open BSV grants. Secures final overlay identity discovery with independently verified, byte-bound transaction evidence, subject-signed output fields, and explicit certificate success; cached response evidence is revalidated before use. Shares bounded transaction and identity cryptographic work across final discovery calls, retains alternate receipts before resolver deduplication, rechecks current canonical anchors, and fixes stale Chaintracks height-root reuse with provider/mode context fencing. +- Migration: Existing mobile actions, permission modules, and UMP v3 tokens require no migration. To use BRC-177, migrate and upgrade the active remote storage service and its default monitor to Wallet Toolbox 2.11.0 or later before upgrading clients; an older server is rejected before prefunding. Prepared BEEF persistence and rollout controls apply only to the full package's Knex provider, so mobile remote clients require no COOK configuration. Semantic modules may add handleRequest without changing the Wallet interface. Argon2id tokens keep the same parameters and derived bytes across WebAssembly and JavaScript runtimes; no user device setting is required. Host registration is available from the mobile root; concurrent cold calls share a preload attempt, and hosts must make readiness/preload reentrant and cache permanent failures or back off retries. Native and JavaScript results share byte-type and exact-length validation; unrelated hash-wasm errors still propagate without WebAssembly. React Native exports, wire types, and canonical AtomicBEEF behavior are unchanged; use @bsv/sdk 2.5.0 or later and retain THIRD_PARTY_NOTICES.md and LICENSES/. Identity discovery requires the wallet Services ChainTracker to provide canonical chain evidence. No wallet RPC or storage migration is required for this security correction. Direct identityUtils callers must pass their independently maintained ChainTracker; missing verification context and unverifiable overlay candidates no longer produce identities. Local contacts retain separate provenance; inclusion is not proof of freshness or unspentness. Identity RPC, trust filtering, local contacts and stored data remain unchanged; no additional migration is needed. The unpublished 2.11.1 candidate aggregates C01/C02/C03 changes against published 2.10.4. New evidence limits are local admission policy, not consensus limits; applications using the generic coordinator can configure them explicitly. Identity lookup propagates typed limit/timeout failures instead of returning a definitive empty result when bounded work cannot complete. Direct utility callers can pass matching queryOverlayEvidence and IdentityEvidenceVerifier byte limits. ChainTracks adapters bracket validation with fresh tips and local reorg/reset epochs where supported; remote observations are not atomic snapshots. | Public subpath | Runtime target(s) | Declaration target(s) | | ---------------- | -------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------- | diff --git a/docs/reference/stack-facts.md b/docs/reference/stack-facts.md index 45b71107d..46d312943 100644 --- a/docs/reference/stack-facts.md +++ b/docs/reference/stack-facts.md @@ -62,7 +62,7 @@ authorized release action. | overlays | `@bsv/overlay-discovery-services` | `2.2.1` | node-library | node-cjs, node-esm | node | `>=22` | [packages/overlays/overlay-discovery-services](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/overlays/overlay-discovery-services) | | overlays | `@bsv/overlay-express` | `2.6.1` | node-library | node-cjs, node-esm | node | `>=22` | [packages/overlays/overlay-express](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/overlays/overlay-express) | | overlays | `@bsv/overlay-topics` | `1.7.1` | node-library | node-esm | node | `>=22` | [packages/overlays/topics](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/overlays/topics) | -| sdk | `@bsv/sdk` | `2.4.2` | browser-library | browser-bundler, browser-esm, node-cjs, node-esm, umd-global | browser, node, umd | `>=22` | [packages/sdk](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/sdk) | +| sdk | `@bsv/sdk` | `2.5.0` | browser-library | browser-bundler, browser-esm, node-cjs, node-esm, umd-global | browser, node, umd | `>=22` | [packages/sdk](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/sdk) | | sdk | `@bsv/verifast` | `0.3.5` | wasm-library | browser-bundler, browser-esm, node-cjs, node-esm, umd-global, wasm-worker | browser, node, umd, wasm, worker | `>=22` | [packages/verifast](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/verifast) | | wallet | `@bsv/btms` | `1.2.2` | node-library | node-cjs, node-esm | node | `>=22` | [packages/wallet/btms](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/wallet/btms) | | wallet | `@bsv/btms-permission-module` | `1.1.4` | node-library | node-esm | node | `>=22` | [packages/wallet/btms-permission-module](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/wallet/btms-permission-module) | diff --git a/governance/package-release-notes.json b/governance/package-release-notes.json index eccf304bb..148d9582c 100644 --- a/governance/package-release-notes.json +++ b/governance/package-release-notes.json @@ -167,9 +167,9 @@ { "name": "@bsv/sdk", "publishedVersion": "2.4.0", - "releaseType": "patch", - "summary": "Adds one strict BRC-100 byte-boundary contract and secure React Native bridge validation, and completes incorporated-code compliance with hash-pinned provenance, exact MIT/ISC/BSD/Apache notices, source lineage markers, package payloads, a retained UMD banner, SBOM components, and release enforcement. Removes a broken copied Swagger UI scaffold and screenshot; the first-party OpenAPI source remains available for use with any viewer. Standardizes first-party author metadata on the current BSV Association name.", - "migration": "No API migration is required. Historical number-array fast paths and React Native behavior remain compatible. Documentation users should load docs/swagger/swagger.yaml into their preferred viewer instead of using the removed static Swagger UI scaffold. Distributors must keep THIRD_PARTY_NOTICES.md and LICENSES/ with source and browser bundles." + "releaseType": "minor", + "summary": "Adds one strict BRC-100 byte-boundary contract and secure React Native bridge validation, and completes incorporated-code compliance with hash-pinned provenance, exact MIT/ISC/BSD/Apache notices, source lineage markers, package payloads, a retained UMD banner, SBOM components, and release enforcement. Removes a broken copied Swagger UI scaffold and screenshot; the first-party OpenAPI source remains available for use with any viewer. Standardizes first-party author metadata on the current BSV Association name. Adds a generic bounded transaction-evidence coordinator with derived-txid work sharing, alternate proof candidates, exact script-work reuse, canonical dependency rechecks, context fencing and reference-counted cancellation. Adds optional pre-aggregation lookup evidence receipts while preserving legacy resolver defaults. Adds optional fresh canonical-context tokens, with explicit limits for legacy tracker and remote ABA assurance.", + "migration": "No API migration is required. Historical number-array fast paths and React Native behavior remain compatible. Documentation users should load docs/swagger/swagger.yaml into their preferred viewer instead of using the removed static Swagger UI scaffold. Distributors must keep THIRD_PARTY_NOTICES.md and LICENSES/ with source and browser bundles. Existing Transaction.verify and resolver call shapes remain unchanged. New coordinator callers supply an independently maintained ChainTracker, explicit chain namespace and semantic policy id, handle bounded limit/timeout outcomes, and dispose sessions when finished. Optional ChainTracker context markers and cancellation signals are additive. No wire or storage migration is required. Coordinate resolver evidenceLimits with downstream evidence admission budgets when increasing them; limit events mean partial bounded intake, not invalid transaction evidence." }, { "name": "@bsv/simple", @@ -217,22 +217,22 @@ "name": "@bsv/wallet-toolbox", "publishedVersion": "2.10.4", "releaseType": "minor", - "summary": "Adds the built-in BRC-177 noSend-expiry reference implementation with exact prefunding, durable pre-signed reclaim, atomic active-storage monitoring, backoff-controlled recovery, cross-device lifecycle synchronization, and proof-finalized race handling. Adds opt-in prepared BEEF storage for Knex-backed normal createAction funding: verified, checksummed proof closures are persisted after foreground completion and reused on later hits, while broad lookups, misses, and cache failures retain the canonical path. Reads, writes, bounded queueing, and gradual backfill default off; reorganizations stale derived rows and fence in-flight cross-process writes with a database proof epoch. Also adds the optional semantic handleRequest hook for BRC-98/99/111 permission modules, an interoperable asynchronous JavaScript Argon2id fallback when WebAssembly is unavailable, and an optional proven-ready native Argon2id backend for host runtimes, retains BRC-95/BRC-100 compatibility and stable bounded pagination, removes the obsolete JSight application bundle, and preserves the earlier Open BSV grant. Secures final overlay identity discovery with independently verified, byte-bound transaction evidence, subject-signed output fields, and explicit certificate success; cached response evidence is revalidated before use.", - "migration": "Existing actions, ordinary noSend calls, permission modules, and UMP v3 tokens require no migration. To use BRC-177, migrate every active Knex store before serving requests and run the default Wallet Toolbox monitor; IndexedDB upgrades automatically to schema version 5. Upgrade signer, active storage service, and remote monitor together to 2.11.0 or later; older remote storage is rejected before prefunding. The Knex migration also adds rebuildable prepared-BEEF and proof-epoch tables with every COOK control disabled. Validate the migration on MySQL before release and the cross-process epoch fence on non-production PXC before enabling writes. Roll out writes before reads, use backfill only after database review, and disable all three flags to roll back. Delete derived prepared rows before downgrading to code that cannot advance the epoch. Semantic modules may add handleRequest; hosts installing @bsv/ecpm-permission-module register it under the ecpm scheme. Argon2id tokens keep the same parameters and derived bytes across WebAssembly and JavaScript runtimes. Host registration is available from each package root; concurrent cold calls share a preload attempt, and hosts must make readiness/preload reentrant and cache permanent failures or back off retries. Native and JavaScript results share byte-type and exact-length validation; unrelated hash-wasm errors still propagate without WebAssembly. Canonical AtomicBEEF and number-array behavior are unchanged; use @bsv/sdk 2.4.2 or later, use docs/storage.md instead of the removed JSight export, and retain THIRD_PARTY_NOTICES.md and LICENSES/. Identity discovery requires the wallet Services ChainTracker to provide canonical chain evidence. No wallet RPC or storage migration is required for this security correction. Direct identityUtils callers must pass their independently maintained ChainTracker; missing verification context and unverifiable overlay candidates no longer produce identities. Local contacts retain separate provenance; inclusion is not proof of freshness or unspentness." + "summary": "Adds the built-in BRC-177 noSend-expiry reference implementation with exact prefunding, durable pre-signed reclaim, atomic active-storage monitoring, backoff-controlled recovery, cross-device lifecycle synchronization, and proof-finalized race handling. Adds opt-in prepared BEEF storage for Knex-backed normal createAction funding: verified, checksummed proof closures are persisted after foreground completion and reused on later hits, while broad lookups, misses, and cache failures retain the canonical path. Reads, writes, bounded queueing, and gradual backfill default off; reorganizations stale derived rows and fence in-flight cross-process writes with a database proof epoch. Also adds the optional semantic handleRequest hook for BRC-98/99/111 permission modules, an interoperable asynchronous JavaScript Argon2id fallback when WebAssembly is unavailable, and an optional proven-ready native Argon2id backend for host runtimes, retains BRC-95/BRC-100 compatibility and stable bounded pagination, removes the obsolete JSight application bundle, and preserves the earlier Open BSV grant. Secures final overlay identity discovery with independently verified, byte-bound transaction evidence, subject-signed output fields, and explicit certificate success; cached response evidence is revalidated before use. Shares bounded transaction and identity cryptographic work across final discovery calls, retains alternate receipts before resolver deduplication, rechecks current canonical anchors, and fixes stale Chaintracks height-root reuse with provider/mode context fencing.", + "migration": "Existing actions, ordinary noSend calls, permission modules, and UMP v3 tokens require no migration. To use BRC-177, migrate every active Knex store before serving requests and run the default Wallet Toolbox monitor; IndexedDB upgrades automatically to schema version 5. Upgrade signer, active storage service, and remote monitor together to 2.11.0 or later; older remote storage is rejected before prefunding. The Knex migration also adds rebuildable prepared-BEEF and proof-epoch tables with every COOK control disabled. Validate the migration on MySQL before release and the cross-process epoch fence on non-production PXC before enabling writes. Roll out writes before reads, use backfill only after database review, and disable all three flags to roll back. Delete derived prepared rows before downgrading to code that cannot advance the epoch. Semantic modules may add handleRequest; hosts installing @bsv/ecpm-permission-module register it under the ecpm scheme. Argon2id tokens keep the same parameters and derived bytes across WebAssembly and JavaScript runtimes. Host registration is available from each package root; concurrent cold calls share a preload attempt, and hosts must make readiness/preload reentrant and cache permanent failures or back off retries. Native and JavaScript results share byte-type and exact-length validation; unrelated hash-wasm errors still propagate without WebAssembly. Canonical AtomicBEEF and number-array behavior are unchanged; use @bsv/sdk 2.5.0 or later, use docs/storage.md instead of the removed JSight export, and retain THIRD_PARTY_NOTICES.md and LICENSES/. Identity discovery requires the wallet Services ChainTracker to provide canonical chain evidence. No wallet RPC or storage migration is required for this security correction. Direct identityUtils callers must pass their independently maintained ChainTracker; missing verification context and unverifiable overlay candidates no longer produce identities. Local contacts retain separate provenance; inclusion is not proof of freshness or unspentness. Identity RPC, trust filtering, local contacts and stored data remain unchanged; no additional migration is needed. The unpublished 2.11.1 candidate aggregates C01/C02/C03 changes against published 2.10.4. New evidence limits are local admission policy, not consensus limits; applications using the generic coordinator can configure them explicitly. Identity lookup propagates typed limit/timeout failures instead of returning a definitive empty result when bounded work cannot complete. Direct utility callers can pass matching queryOverlayEvidence and IdentityEvidenceVerifier byte limits. ChainTracks adapters bracket validation with fresh tips and local reorg/reset epochs where supported; remote observations are not atomic snapshots." }, { "name": "@bsv/wallet-toolbox-client", "publishedVersion": "2.10.4", "releaseType": "minor", - "summary": "Adds the built-in browser BRC-177 noSend-expiry signer, IndexedDB schema version 5 lifecycle state, remote storage capability negotiation, default monitor coordination, the optional semantic handleRequest hook, an interoperable asynchronous JavaScript Argon2id fallback when WebAssembly is unavailable, and an optional proven-ready native Argon2id backend for host runtimes. Carries the shared prepared-BEEF-aware createAction contract while IndexedDB remains canonical-only; compatible remote Knex providers can enable COOK independently. Retains transformation modules, BRC-100 wire compatibility, stable IndexedDB totals, current browser compatibility fixes, and earlier Open BSV grants. Secures final overlay identity discovery with independently verified, byte-bound transaction evidence, subject-signed output fields, and explicit certificate success; cached response evidence is revalidated before use.", - "migration": "Existing browser actions, permission modules, and UMP v3 tokens require no migration; IndexedDB upgrades automatically. To use BRC-177 with remote storage, upgrade the active storage service and its default monitor to Wallet Toolbox 2.11.0 or later before upgrading clients; an older server is rejected before prefunding. Prepared BEEF persistence and rollout controls apply only to the full package's Knex provider, so IndexedDB and remote clients require no COOK configuration. Semantic modules may add handleRequest; installing @bsv/ecpm-permission-module requires registration under the ecpm scheme. Argon2id tokens keep the same parameters and derived bytes across WebAssembly and JavaScript runtimes. Host registration is available from each package root; concurrent cold calls share a preload attempt, and hosts must make readiness/preload reentrant and cache permanent failures or back off retries. Native and JavaScript results share byte-type and exact-length validation; unrelated hash-wasm errors still propagate without WebAssembly. Browser exports, wire types, canonical AtomicBEEF behavior, and pagination contracts are unchanged; use @bsv/sdk 2.4.2 or later and retain THIRD_PARTY_NOTICES.md and LICENSES/. Identity discovery requires the wallet Services ChainTracker to provide canonical chain evidence. No wallet RPC or storage migration is required for this security correction. Direct identityUtils callers must pass their independently maintained ChainTracker; missing verification context and unverifiable overlay candidates no longer produce identities. Local contacts retain separate provenance; inclusion is not proof of freshness or unspentness." + "summary": "Adds the built-in browser BRC-177 noSend-expiry signer, IndexedDB schema version 5 lifecycle state, remote storage capability negotiation, default monitor coordination, the optional semantic handleRequest hook, an interoperable asynchronous JavaScript Argon2id fallback when WebAssembly is unavailable, and an optional proven-ready native Argon2id backend for host runtimes. Carries the shared prepared-BEEF-aware createAction contract while IndexedDB remains canonical-only; compatible remote Knex providers can enable COOK independently. Retains transformation modules, BRC-100 wire compatibility, stable IndexedDB totals, current browser compatibility fixes, and earlier Open BSV grants. Secures final overlay identity discovery with independently verified, byte-bound transaction evidence, subject-signed output fields, and explicit certificate success; cached response evidence is revalidated before use. Shares bounded transaction and identity cryptographic work across final discovery calls, retains alternate receipts before resolver deduplication, rechecks current canonical anchors, and fixes stale Chaintracks height-root reuse with provider/mode context fencing.", + "migration": "Existing browser actions, permission modules, and UMP v3 tokens require no migration; IndexedDB upgrades automatically. To use BRC-177 with remote storage, upgrade the active storage service and its default monitor to Wallet Toolbox 2.11.0 or later before upgrading clients; an older server is rejected before prefunding. Prepared BEEF persistence and rollout controls apply only to the full package's Knex provider, so IndexedDB and remote clients require no COOK configuration. Semantic modules may add handleRequest; installing @bsv/ecpm-permission-module requires registration under the ecpm scheme. Argon2id tokens keep the same parameters and derived bytes across WebAssembly and JavaScript runtimes. Host registration is available from each package root; concurrent cold calls share a preload attempt, and hosts must make readiness/preload reentrant and cache permanent failures or back off retries. Native and JavaScript results share byte-type and exact-length validation; unrelated hash-wasm errors still propagate without WebAssembly. Browser exports, wire types, canonical AtomicBEEF behavior, and pagination contracts are unchanged; use @bsv/sdk 2.5.0 or later and retain THIRD_PARTY_NOTICES.md and LICENSES/. Identity discovery requires the wallet Services ChainTracker to provide canonical chain evidence. No wallet RPC or storage migration is required for this security correction. Direct identityUtils callers must pass their independently maintained ChainTracker; missing verification context and unverifiable overlay candidates no longer produce identities. Local contacts retain separate provenance; inclusion is not proof of freshness or unspentness. Identity RPC, trust filtering, local contacts and stored data remain unchanged; no additional migration is needed. The unpublished 2.11.1 candidate aggregates C01/C02/C03 changes against published 2.10.4. New evidence limits are local admission policy, not consensus limits; applications using the generic coordinator can configure them explicitly. Identity lookup propagates typed limit/timeout failures instead of returning a definitive empty result when bounded work cannot complete. Direct utility callers can pass matching queryOverlayEvidence and IdentityEvidenceVerifier byte limits. ChainTracks adapters bracket validation with fresh tips and local reorg/reset epochs where supported; remote observations are not atomic snapshots." }, { "name": "@bsv/wallet-toolbox-mobile", "publishedVersion": "2.10.4", "releaseType": "minor", - "summary": "Adds the built-in mobile BRC-177 noSend-expiry signer, remote storage capability negotiation, default-monitor ownership coordination across restarts and devices, the optional semantic handleRequest hook, an interoperable asynchronous JavaScript Argon2id fallback for React Native runtimes without WebAssembly, and an optional proven-ready native Argon2id backend for host runtimes. Carries the shared prepared-BEEF-aware createAction contract while mobile storage remains canonical-only; compatible remote Knex providers can enable COOK independently. Retains transformation modules, BRC-100 wire compatibility, current mobile compatibility fixes, and earlier Open BSV grants. Secures final overlay identity discovery with independently verified, byte-bound transaction evidence, subject-signed output fields, and explicit certificate success; cached response evidence is revalidated before use.", - "migration": "Existing mobile actions, permission modules, and UMP v3 tokens require no migration. To use BRC-177, migrate and upgrade the active remote storage service and its default monitor to Wallet Toolbox 2.11.0 or later before upgrading clients; an older server is rejected before prefunding. Prepared BEEF persistence and rollout controls apply only to the full package's Knex provider, so mobile remote clients require no COOK configuration. Semantic modules may add handleRequest without changing the Wallet interface. Argon2id tokens keep the same parameters and derived bytes across WebAssembly and JavaScript runtimes; no user device setting is required. Host registration is available from the mobile root; concurrent cold calls share a preload attempt, and hosts must make readiness/preload reentrant and cache permanent failures or back off retries. Native and JavaScript results share byte-type and exact-length validation; unrelated hash-wasm errors still propagate without WebAssembly. React Native exports, wire types, and canonical AtomicBEEF behavior are unchanged; use @bsv/sdk 2.4.2 or later and retain THIRD_PARTY_NOTICES.md and LICENSES/. Identity discovery requires the wallet Services ChainTracker to provide canonical chain evidence. No wallet RPC or storage migration is required for this security correction. Direct identityUtils callers must pass their independently maintained ChainTracker; missing verification context and unverifiable overlay candidates no longer produce identities. Local contacts retain separate provenance; inclusion is not proof of freshness or unspentness." + "summary": "Adds the built-in mobile BRC-177 noSend-expiry signer, remote storage capability negotiation, default-monitor ownership coordination across restarts and devices, the optional semantic handleRequest hook, an interoperable asynchronous JavaScript Argon2id fallback for React Native runtimes without WebAssembly, and an optional proven-ready native Argon2id backend for host runtimes. Carries the shared prepared-BEEF-aware createAction contract while mobile storage remains canonical-only; compatible remote Knex providers can enable COOK independently. Retains transformation modules, BRC-100 wire compatibility, current mobile compatibility fixes, and earlier Open BSV grants. Secures final overlay identity discovery with independently verified, byte-bound transaction evidence, subject-signed output fields, and explicit certificate success; cached response evidence is revalidated before use. Shares bounded transaction and identity cryptographic work across final discovery calls, retains alternate receipts before resolver deduplication, rechecks current canonical anchors, and fixes stale Chaintracks height-root reuse with provider/mode context fencing.", + "migration": "Existing mobile actions, permission modules, and UMP v3 tokens require no migration. To use BRC-177, migrate and upgrade the active remote storage service and its default monitor to Wallet Toolbox 2.11.0 or later before upgrading clients; an older server is rejected before prefunding. Prepared BEEF persistence and rollout controls apply only to the full package's Knex provider, so mobile remote clients require no COOK configuration. Semantic modules may add handleRequest without changing the Wallet interface. Argon2id tokens keep the same parameters and derived bytes across WebAssembly and JavaScript runtimes; no user device setting is required. Host registration is available from the mobile root; concurrent cold calls share a preload attempt, and hosts must make readiness/preload reentrant and cache permanent failures or back off retries. Native and JavaScript results share byte-type and exact-length validation; unrelated hash-wasm errors still propagate without WebAssembly. React Native exports, wire types, and canonical AtomicBEEF behavior are unchanged; use @bsv/sdk 2.5.0 or later and retain THIRD_PARTY_NOTICES.md and LICENSES/. Identity discovery requires the wallet Services ChainTracker to provide canonical chain evidence. No wallet RPC or storage migration is required for this security correction. Direct identityUtils callers must pass their independently maintained ChainTracker; missing verification context and unverifiable overlay candidates no longer produce identities. Local contacts retain separate provenance; inclusion is not proof of freshness or unspentness. Identity RPC, trust filtering, local contacts and stored data remain unchanged; no additional migration is needed. The unpublished 2.11.1 candidate aggregates C01/C02/C03 changes against published 2.10.4. New evidence limits are local admission policy, not consensus limits; applications using the generic coordinator can configure them explicitly. Identity lookup propagates typed limit/timeout failures instead of returning a definitive empty result when bounded work cannot complete. Direct utility callers can pass matching queryOverlayEvidence and IdentityEvidenceVerifier byte limits. ChainTracks adapters bracket validation with fresh tips and local reorg/reset epochs where supported; remote observations are not atomic snapshots." }, { "name": "create-bsv-app", diff --git a/governance/repository-health/baselines.json b/governance/repository-health/baselines.json index c75e4504c..17ed9eb42 100644 --- a/governance/repository-health/baselines.json +++ b/governance/repository-health/baselines.json @@ -322,7 +322,7 @@ "@bsv/overlay-discovery-services": "2.2.1", "@bsv/overlay-express": "2.6.1", "@bsv/overlay-topics": "1.7.1", - "@bsv/sdk": "2.4.2", + "@bsv/sdk": "2.5.0", "@bsv/verifast": "0.3.5", "@bsv/btms": "1.2.2", "@bsv/btms-permission-module": "1.1.4", diff --git a/packages/sdk/README.md b/packages/sdk/README.md index ee0e7d30e..42b97ef5a 100644 --- a/packages/sdk/README.md +++ b/packages/sdk/README.md @@ -16,6 +16,11 @@ emits a portable `number[]` settlement artifact so HTTP, WebSocket, Message Box, and JSON transports preserve identical transaction bytes. The same boundary protects overlay lookup queries and JSON BEEF responses. +Security-sensitive overlay consumers can use the exported bounded transaction +evidence coordinator to verify copied BEEF against a caller-owned chain tracker. +See the [transaction evidence guide](./docs/transaction-evidence.md) for the +receipt, graph, cache, cancellation, and resolver-intake contracts. + ## Table of Contents 1. [Objective](#objective) @@ -118,6 +123,7 @@ Comprehensive documentation is available in several formats: - **[🔧 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 +- **[Transaction evidence](./docs/transaction-evidence.md)**: Bounded verification of untrusted BEEF and overlay receipts - **[⚡ 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 diff --git a/packages/sdk/docs/reference/overlay-tools.md b/packages/sdk/docs/reference/overlay-tools.md index ec167e2bb..2ba53ad88 100644 --- a/packages/sdk/docs/reference/overlay-tools.md +++ b/packages/sdk/docs/reference/overlay-tools.md @@ -4,16 +4,15 @@ Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes), [Functions]( ## Interfaces -| | -| --- | -| [AdmittanceInstructions](#interface-admittanceinstructions) | -| [LookupQuestion](#interface-lookupquestion) | -| [LookupResolverConfig](#interface-lookupresolverconfig) | -| [OverlayBroadcastFacilitator](#interface-overlaybroadcastfacilitator) | -| [OverlayLookupFacilitator](#interface-overlaylookupfacilitator) | -| [RankedHost](#interface-rankedhost) | -| [SHIPBroadcasterConfig](#interface-shipbroadcasterconfig) | -| [TaggedBEEF](#interface-taggedbeef) | +| | | +| ----------------------------------------------------------- | --------------------------------------------------------------------- | +| [AdmittanceInstructions](#interface-admittanceinstructions) | [OverlayBroadcastFacilitator](#interface-overlaybroadcastfacilitator) | +| [LookupAnswerProgress](#interface-lookupanswerprogress) | [OverlayLookupFacilitator](#interface-overlaylookupfacilitator) | +| [LookupFreeformAnswer](#interface-lookupfreeformanswer) | [RankedHost](#interface-rankedhost) | +| [LookupQueryOptions](#interface-lookupqueryoptions) | [SHIPBroadcasterConfig](#interface-shipbroadcasterconfig) | +| [LookupQuestion](#interface-lookupquestion) | [TaggedBEEF](#interface-taggedbeef) | +| [LookupResolution](#interface-lookupresolution) | [UnreachableHostInfo](#interface-unreachablehostinfo) | +| [LookupResolverConfig](#interface-lookupresolverconfig) | | Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes), [Functions](#functions), [Types](#types), [Enums](#enums), [Variables](#variables) @@ -25,9 +24,9 @@ Instructs the Overlay Services Engine about which outputs to admit and which pre ```ts export interface AdmittanceInstructions { - outputsToAdmit: number[]; - coinsToRetain: number[]; - coinsRemoved?: number[]; + outputsToAdmit: number[] + coinsToRetain: number[] + coinsRemoved?: number[] } ``` @@ -59,14 +58,264 @@ outputsToAdmit: number[] Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes), [Functions](#functions), [Types](#types), [Enums](#enums), [Variables](#variables) --- + +### Interface: LookupAnswerProgress + +```ts +export interface LookupAnswerProgress { + type: 'output-list' + outputs: Array<{ + beef: number[] + outputIndex: number + context?: number[] + txid?: string + }> + txIds: string[] + isFinal: boolean + hostCount: number + completedHosts: number + successfulHosts: number + emptyHosts: number + failedHosts: number + rejectedHosts: number + freeformHosts: number + correlationId?: string +} +``` + +#### Property completedHosts + +Number of hosts that have settled (success / fail / timeout). + +```ts +completedHosts: number +``` + +#### Property correlationId + +Correlation id used for privacy-safe distributed diagnostics. + +```ts +correlationId?: string +``` + +#### Property emptyHosts + +Successful hosts whose output list was empty. + +```ts +emptyHosts: number +``` + +#### Property failedHosts + +Hosts that failed due to availability, timeout, or malformed responses. + +```ts +failedHosts: number +``` + +#### Property freeformHosts + +Hosts that returned a valid but non-aggregatable freeform response. + +```ts +freeformHosts: number +``` + +#### Property hostCount + +Number of ranked hosts that were queried. + +```ts +hostCount: number +``` + +#### Property isFinal + +True only for the final emission, after every in-flight host has settled. + +```ts +isFinal: boolean +``` + +#### Property rejectedHosts + +Hosts that rejected this query semantically (for example, HTTP 400). + +```ts +rejectedHosts: number +``` + +#### Property successfulHosts + +Hosts that returned a structurally valid output-list response. + +```ts +successfulHosts: number +``` + +#### Property txIds + +Parallel array of resolved tx ids for each output (same index as `outputs`). + +```ts +txIds: string[] +``` + +Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes), [Functions](#functions), [Types](#types), [Enums](#enums), [Variables](#variables) + +--- + +### Interface: LookupFreeformAnswer + +A valid non-aggregatable response returned by a lookup service. + +```ts +export interface LookupFreeformAnswer { + type: 'freeform' + result: unknown +} +``` + +Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes), [Functions](#functions), [Types](#types), [Enums](#enums), [Variables](#variables) + +--- + +### Interface: LookupQueryOptions + +```ts +export interface LookupQueryOptions { + onEvidence?: (event: LookupEvidenceEvent) => void | Promise + evidenceLimits?: { + maxOutputs?: number + maxBytes?: number + } + graceMs?: number + softTimeoutMs?: number + onUnreachableHost?: (info: UnreachableHostInfo) => void | Promise + unreachableHostNotificationCooldownMs?: number + holdForUnknownHosts?: boolean + waitForAllHosts?: boolean + correlationId?: string +} +``` + +See also: [LookupEvidenceEvent](./overlay-tools.md#type-lookupevidenceevent), [UnreachableHostInfo](./overlay-tools.md#interface-unreachablehostinfo) + +#### Property correlationId + +Correlates resolver and downstream wallet telemetry without logging the query payload. + +```ts +correlationId?: string +``` + +#### Property evidenceLimits + +Callback intake budget, independent of legacy aggregation. Defaults to 512 +outputs / 16 MiB of BEEF and context bytes. Values must be positive safe +integers. Coordinate these with a downstream verifier's admission limits. + +```ts +evidenceLimits?: { + maxOutputs?: number; + maxBytes?: number; +} +``` + +#### Property graceMs + +Override the grace window (ms) between the first valid response and the resolution of the query. +Late responders arriving within this window are merged into the result. Default 80 ms. +Raise for identity-style paths (e.g. ~300 ms) where divergence between hosts matters. + +```ts +graceMs?: number +``` + +#### Property holdForUnknownHosts + +Compatibility alias for `waitForAllHosts`. Prefer `waitForAllHosts` in new +code. `waitForAllHosts` takes precedence when both are supplied. + +```ts +holdForUnknownHosts?: boolean +``` + +#### Property onEvidence + +Owned, UNTRUSTED receipts before legacy txid/outpoint deduplication. Enqueue +promptly; callback completion is not awaited and failures are isolated. +Intake stops at the configured evidenceLimits, reporting one limit event. +No callbacks occur after the query iterator closes. Legacy answers, host +scheduling, timeout and reputation behavior are unchanged. + +```ts +onEvidence?: (event: LookupEvidenceEvent) => void | Promise +``` + +See also: [LookupEvidenceEvent](./overlay-tools.md#type-lookupevidenceevent) + +#### Property onUnreachableHost + +Fired when a SLAP-advertised host fails (network error, timeout, malformed +response). The resolver itself does not email or escalate — downstream +consumers (e.g. overlay-express) wire this up to the BSVA notification API +to let the originating overlay operator know about a stale advertisement. + +```ts +onUnreachableHost?: (info: UnreachableHostInfo) => void | Promise +``` + +See also: [UnreachableHostInfo](./overlay-tools.md#interface-unreachablehostinfo) + +#### Property softTimeoutMs + +Soft timeout (ms). When set: + +- `query()` resolves with whatever has arrived as soon as any host answers, or after this timeout. +- `query$()` emits a (possibly empty) snapshot after this timeout if no host has answered yet, + then continues yielding late-host enrichments until the iterator is broken or final emission. + +```ts +softTimeoutMs?: number +``` + +#### Property unreachableHostNotificationCooldownMs + +Minimum interval between unreachable notifications for the same host and +service. Defaults to 60 seconds to prevent notification storms. Set to 0 +to disable deduplication. + +```ts +unreachableHostNotificationCooldownMs?: number +``` + +#### Property waitForAllHosts + +Wait for every queried host to settle before the first emission. This is +the default for `query()` because generic output cardinality is not proof +of freshness or authority. It defaults to `false` for progressive +`query$()` consumers. `holdForUnknownHosts` remains as a compatibility +alias; `waitForAllHosts` takes precedence when both are supplied. + +```ts +waitForAllHosts?: boolean +``` + +Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes), [Functions](#functions), [Types](#types), [Enums](#enums), [Variables](#variables) + +--- + ### Interface: LookupQuestion The question asked to the Overlay Services Engine when a consumer of state wishes to look up information. ```ts export interface LookupQuestion { - service: string; - query: unknown; + service: string + query: unknown } ``` @@ -90,26 +339,47 @@ service: string Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes), [Functions](#functions), [Types](#types), [Enums](#enums), [Variables](#variables) --- + +### Interface: LookupResolution + +A lookup answer together with the host settlement evidence behind it. + +```ts +export interface LookupResolution { + answer: LookupAnswer + progress: LookupAnswerProgress +} +``` + +See also: [LookupAnswer](./overlay-tools.md#type-lookupanswer), [LookupAnswerProgress](./overlay-tools.md#interface-lookupanswerprogress) + +Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes), [Functions](#functions), [Types](#types), [Enums](#enums), [Variables](#variables) + +--- + ### Interface: LookupResolverConfig Configuration options for the Lookup resolver. ```ts export interface LookupResolverConfig { - networkPreset?: "mainnet" | "testnet" | "teratestnet" | "local"; - facilitator?: OverlayLookupFacilitator; - slapTrackers?: string[]; - hostOverrides?: Record; - additionalHosts?: Record; - cache?: CacheOptions; - reputationStorage?: "localStorage" | { - get: (key: string) => string | null | undefined; - set: (key: string, value: string) => void; - }; + networkPreset?: LookupNetworkPreset + facilitator?: OverlayLookupFacilitator + slapTrackers?: string[] + hostOverrides?: Record + additionalHosts?: Record + cache?: CacheOptions + reputationStorage?: + | 'localStorage' + | { + get: (key: string) => string | null | undefined + set: (key: string, value: string) => void + } + telemetry?: TelemetryConfig } ``` -See also: [OverlayLookupFacilitator](./overlay-tools.md#interface-overlaylookupfacilitator) +See also: [LookupNetworkPreset](./overlay-tools.md#type-lookupnetworkpreset), [OverlayLookupFacilitator](./overlay-tools.md#interface-overlaylookupfacilitator) #### Property additionalHosts @@ -134,6 +404,7 @@ The facilitator used to make requests to Overlay Services hosts. ```ts facilitator?: OverlayLookupFacilitator ``` + See also: [OverlayLookupFacilitator](./overlay-tools.md#interface-overlaylookupfacilitator) #### Property hostOverrides @@ -147,14 +418,18 @@ hostOverrides?: Record #### Property networkPreset The network preset to use, unless other options override it. + - mainnet: use mainnet SLAP trackers and HTTPS facilitator - testnet: use testnet SLAP trackers and HTTPS facilitator +- teratestnet: use TerraTestNet SLAP trackers and HTTPS facilitator - local: directly query from localhost:8080 and a facilitator that permits plain HTTP ```ts -networkPreset?: "mainnet" | "testnet" | "teratestnet" | "local" +networkPreset?: LookupNetworkPreset ``` +See also: [LookupNetworkPreset](./overlay-tools.md#type-lookupnetworkpreset) + #### Property reputationStorage Optional storage for host reputation data. @@ -174,16 +449,25 @@ The list of SLAP trackers queried to resolve Overlay Services hosts for a given slapTrackers?: string[] ``` +#### Property telemetry + +Optional privacy-bounded telemetry sink. Query payloads are never emitted. + +```ts +telemetry?: TelemetryConfig +``` + Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes), [Functions](#functions), [Types](#types), [Enums](#enums), [Variables](#variables) --- + ### Interface: OverlayBroadcastFacilitator Facilitates transaction broadcasts that return STEAK. ```ts export interface OverlayBroadcastFacilitator { - send: (url: string, taggedBEEF: TaggedBEEF) => Promise; + send: (url: string, taggedBEEF: TaggedBEEF) => Promise } ``` @@ -192,57 +476,66 @@ See also: [STEAK](./overlay-tools.md#type-steak), [TaggedBEEF](./overlay-tools.m Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes), [Functions](#functions), [Types](#types), [Enums](#enums), [Variables](#variables) --- + ### Interface: OverlayLookupFacilitator Facilitates lookups to URLs that return answers. ```ts export interface OverlayLookupFacilitator { - lookup: (url: string, question: LookupQuestion, timeout?: number) => Promise; + lookup: ( + url: string, + question: LookupQuestion, + timeout?: number + ) => Promise } ``` -See also: [LookupAnswer](./overlay-tools.md#type-lookupanswer), [LookupQuestion](./overlay-tools.md#interface-lookupquestion) +See also: [LookupFacilitatorAnswer](./overlay-tools.md#type-lookupfacilitatoranswer), [LookupQuestion](./overlay-tools.md#interface-lookupquestion) #### Property lookup Returns a lookup answer for a lookup question ```ts -lookup: (url: string, question: LookupQuestion, timeout?: number) => Promise +lookup: (url: string, question: LookupQuestion, timeout?: number) => + Promise ``` -See also: [LookupAnswer](./overlay-tools.md#type-lookupanswer), [LookupQuestion](./overlay-tools.md#interface-lookupquestion) + +See also: [LookupFacilitatorAnswer](./overlay-tools.md#type-lookupfacilitatoranswer), [LookupQuestion](./overlay-tools.md#interface-lookupquestion) Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes), [Functions](#functions), [Types](#types), [Enums](#enums), [Variables](#variables) --- + ### Interface: RankedHost ```ts export interface RankedHost extends HostReputationEntry { - score: number; + score: number } ``` Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes), [Functions](#functions), [Types](#types), [Enums](#enums), [Variables](#variables) --- + ### Interface: SHIPBroadcasterConfig Configuration options for the SHIP broadcaster. ```ts export interface SHIPBroadcasterConfig { - networkPreset?: "mainnet" | "testnet" | "teratestnet" | "local"; - facilitator?: OverlayBroadcastFacilitator; - resolver?: LookupResolver; - requireAcknowledgmentFromAllHostsForTopics?: "all" | "any" | string[]; - requireAcknowledgmentFromAnyHostForTopics?: "all" | "any" | string[]; - requireAcknowledgmentFromSpecificHostsForTopics?: Record; + networkPreset?: LookupNetworkPreset + facilitator?: OverlayBroadcastFacilitator + resolver?: LookupResolver + requireAcknowledgmentFromAllHostsForTopics?: TopicAcknowledgmentRequirement + requireAcknowledgmentFromAnyHostForTopics?: TopicAcknowledgmentRequirement + requireAcknowledgmentFromSpecificHostsForTopics?: Record } ``` -See also: [LookupResolver](./overlay-tools.md#class-lookupresolver), [OverlayBroadcastFacilitator](./overlay-tools.md#interface-overlaybroadcastfacilitator) +See also: [LookupNetworkPreset](./overlay-tools.md#type-lookupnetworkpreset), [LookupResolver](./overlay-tools.md#class-lookupresolver), [OverlayBroadcastFacilitator](./overlay-tools.md#interface-overlaybroadcastfacilitator), [TopicAcknowledgmentRequirement](./overlay-tools.md#type-topicacknowledgmentrequirement) #### Property facilitator @@ -251,43 +544,54 @@ The facilitator used to make requests to Overlay Services hosts. ```ts facilitator?: OverlayBroadcastFacilitator ``` + See also: [OverlayBroadcastFacilitator](./overlay-tools.md#interface-overlaybroadcastfacilitator) #### Property networkPreset The network preset to use, unless other options override it. + - mainnet: use mainnet resolver and HTTPS facilitator - testnet: use testnet resolver and HTTPS facilitator +- teratestnet: use TerraTestNet resolver and HTTPS facilitator - local: directly send to localhost:8080 and a facilitator that permits plain HTTP ```ts -networkPreset?: "mainnet" | "testnet" | "teratestnet" | "local" +networkPreset?: LookupNetworkPreset ``` +See also: [LookupNetworkPreset](./overlay-tools.md#type-lookupnetworkpreset) + #### Property requireAcknowledgmentFromAllHostsForTopics Determines which topics (all, any, or a specific list) must be present within all STEAKs received from every host for the broadcast to be considered a success. By default, all hosts must acknowledge all topics. ```ts -requireAcknowledgmentFromAllHostsForTopics?: "all" | "any" | string[] +requireAcknowledgmentFromAllHostsForTopics?: TopicAcknowledgmentRequirement ``` +See also: [TopicAcknowledgmentRequirement](./overlay-tools.md#type-topicacknowledgmentrequirement) + #### Property requireAcknowledgmentFromAnyHostForTopics Determines which topics (all, any, or a specific list) must be present within STEAK received from at least one host for the broadcast to be considered a success. ```ts -requireAcknowledgmentFromAnyHostForTopics?: "all" | "any" | string[] +requireAcknowledgmentFromAnyHostForTopics?: TopicAcknowledgmentRequirement ``` +See also: [TopicAcknowledgmentRequirement](./overlay-tools.md#type-topicacknowledgmentrequirement) + #### Property requireAcknowledgmentFromSpecificHostsForTopics Determines a mapping whose keys are specific hosts and whose values are the topics (all, any, or a specific list) that must be present within the STEAK received by the given hosts, in order for the broadcast to be considered a success. ```ts -requireAcknowledgmentFromSpecificHostsForTopics?: Record +requireAcknowledgmentFromSpecificHostsForTopics?: Record ``` +See also: [TopicAcknowledgmentRequirement](./overlay-tools.md#type-topicacknowledgmentrequirement) + #### Property resolver The resolver used to locate suitable hosts with SHIP @@ -295,36 +599,89 @@ The resolver used to locate suitable hosts with SHIP ```ts resolver?: LookupResolver ``` + See also: [LookupResolver](./overlay-tools.md#class-lookupresolver) Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes), [Functions](#functions), [Types](#types), [Enums](#enums), [Variables](#variables) --- + ### Interface: TaggedBEEF Tagged BEEF ```ts export interface TaggedBEEF { - beef: number[]; - topics: string[]; - offChainValues?: number[]; + beef: number[] + topics: string[] + offChainValues?: number[] +} +``` + +Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes), [Functions](#functions), [Types](#types), [Enums](#enums), [Variables](#variables) + +--- + +### Interface: UnreachableHostInfo + +Info supplied to onUnreachableHost callbacks. + +```ts +export interface UnreachableHostInfo { + host: string + service: string + error: string + advertisedBy?: string } ``` +#### Property advertisedBy + +SLAP tracker URL that advertised this host, if known. + +```ts +advertisedBy?: string +``` + +#### Property error + +Error message from the facilitator. + +```ts +error: string +``` + +#### Property host + +Host URL that failed. + +```ts +host: string +``` + +#### Property service + +Lookup service that was being queried when the failure occurred. + +```ts +service: string +``` + Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes), [Functions](#functions), [Types](#types), [Enums](#enums), [Variables](#variables) --- + ## Classes -| | -| --- | +| | +| --------------------------------------------------------------------------- | | [HTTPSOverlayBroadcastFacilitator](#class-httpsoverlaybroadcastfacilitator) | -| [HTTPSOverlayLookupFacilitator](#class-httpsoverlaylookupfacilitator) | -| [HostReputationTracker](#class-hostreputationtracker) | -| [LookupResolver](#class-lookupresolver) | -| [OverlayAdminTokenTemplate](#class-overlayadmintokentemplate) | -| [TopicBroadcaster](#class-topicbroadcaster) | +| [HTTPSOverlayLookupFacilitator](#class-httpsoverlaylookupfacilitator) | +| [HostReputationTracker](#class-hostreputationtracker) | +| [LookupHTTPError](#class-lookuphttperror) | +| [LookupResolver](#class-lookupresolver) | +| [OverlayAdminTokenTemplate](#class-overlayadmintokentemplate) | +| [TopicBroadcaster](#class-topicbroadcaster) | Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes), [Functions](#functions), [Types](#types), [Enums](#enums), [Variables](#variables) @@ -334,10 +691,10 @@ Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes), [Functions]( ```ts export class HTTPSOverlayBroadcastFacilitator implements OverlayBroadcastFacilitator { - httpClient: typeof fetch; - allowHTTP: boolean; - constructor(httpClient = fetch, allowHTTP: boolean = false) - async send(url: string, taggedBEEF: TaggedBEEF): Promise + httpClient: typeof fetch + allowHTTP: boolean + constructor(httpClient = fetch, allowHTTP: boolean = false) + async send(url: string, taggedBEEF: TaggedBEEF): Promise } ``` @@ -346,84 +703,154 @@ See also: [OverlayBroadcastFacilitator](./overlay-tools.md#interface-overlaybroa Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes), [Functions](#functions), [Types](#types), [Enums](#enums), [Variables](#variables) --- + ### Class: HTTPSOverlayLookupFacilitator ```ts export class HTTPSOverlayLookupFacilitator implements OverlayLookupFacilitator { - fetchClient: typeof fetch; - allowHTTP: boolean; - constructor(httpClient = defaultFetch, allowHTTP: boolean = false) - async lookup(url: string, question: LookupQuestion, timeout: number = 5000): Promise + fetchClient: typeof fetch + allowHTTP: boolean + constructor(httpClient = defaultFetch, allowHTTP: boolean = false) + async lookup( + url: string, + question: LookupQuestion, + timeout: number = 2000 + ): Promise } ``` -See also: [LookupAnswer](./overlay-tools.md#type-lookupanswer), [LookupQuestion](./overlay-tools.md#interface-lookupquestion), [OverlayLookupFacilitator](./overlay-tools.md#interface-overlaylookupfacilitator) +See also: [LookupFacilitatorAnswer](./overlay-tools.md#type-lookupfacilitatoranswer), [LookupQuestion](./overlay-tools.md#interface-lookupquestion), [OverlayLookupFacilitator](./overlay-tools.md#interface-overlaylookupfacilitator) Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes), [Functions](#functions), [Types](#types), [Enums](#enums), [Variables](#variables) --- + ### Class: HostReputationTracker ```ts export class HostReputationTracker { - constructor(store?: KeyValueStore) - reset(): void - recordSuccess(host: string, latencyMs: number): void - recordFailure(host: string, reason?: unknown): void - rankHosts(hosts: string[], now: number = Date.now()): RankedHost[] - snapshot(host: string): HostReputationEntry | undefined + constructor(store?: KeyValueStore) + reset(): void + recordSuccess(host: string, latencyMs: number): void + recordFailure(host: string, reason?: unknown): void + rankHosts(hosts: string[], now: number = Date.now()): RankedHost[] + snapshot(host: string): HostReputationEntry | undefined + flush(): void } ``` See also: [RankedHost](./overlay-tools.md#interface-rankedhost) +#### Method flush + +Flushes a pending debounced persistence write immediately. + +```ts +flush(): void +``` + +Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes), [Functions](#functions), [Types](#types), [Enums](#enums), [Variables](#variables) + +--- + +### Class: LookupHTTPError + +An HTTP failure with enough classification for reputation handling. + +```ts +export class LookupHTTPError extends Error { + readonly status: number + readonly kind: LookupHTTPErrorKind + constructor(status: number, kind: LookupHTTPErrorKind, statusText?: string) +} +``` + +See also: [LookupHTTPErrorKind](./overlay-tools.md#type-lookuphttperrorkind) + Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes), [Functions](#functions), [Types](#types), [Enums](#enums), [Variables](#variables) --- + ### Class: LookupResolver Represents a Lookup Resolver. ```ts export default class LookupResolver { - constructor(config: LookupResolverConfig = {}) - async query(question: LookupQuestion, timeout?: number): Promise + constructor(config: LookupResolverConfig = {}) + async query( + question: LookupQuestion, + timeout?: number, + options?: LookupQueryOptions + ): Promise + async queryDetailed( + question: LookupQuestion, + timeout?: number, + options?: LookupQueryOptions + ): Promise + async *query$( + question: LookupQuestion, + timeout?: number, + options?: LookupQueryOptions + ): AsyncIterable } ``` -See also: [LookupAnswer](./overlay-tools.md#type-lookupanswer), [LookupQuestion](./overlay-tools.md#interface-lookupquestion), [LookupResolverConfig](./overlay-tools.md#interface-lookupresolverconfig) +See also: [LookupAnswer](./overlay-tools.md#type-lookupanswer), [LookupAnswerProgress](./overlay-tools.md#interface-lookupanswerprogress), [LookupQueryOptions](./overlay-tools.md#interface-lookupqueryoptions), [LookupQuestion](./overlay-tools.md#interface-lookupquestion), [LookupResolution](./overlay-tools.md#interface-lookupresolution), [LookupResolverConfig](./overlay-tools.md#interface-lookupresolverconfig) #### Method query Given a LookupQuestion, returns a LookupAnswer. Aggregates across multiple services and supports resiliency. +Optional `options.graceMs` overrides the per-call grace window (default 80 ms). +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`). + ```ts -async query(question: LookupQuestion, timeout?: number): Promise +async query(question: LookupQuestion, timeout?: number, options?: LookupQueryOptions): Promise ``` -See also: [LookupAnswer](./overlay-tools.md#type-lookupanswer), [LookupQuestion](./overlay-tools.md#interface-lookupquestion) + +See also: [LookupAnswer](./overlay-tools.md#type-lookupanswer), [LookupQueryOptions](./overlay-tools.md#interface-lookupqueryoptions), [LookupQuestion](./overlay-tools.md#interface-lookupquestion) + +#### Method queryDetailed + +Performs a lookup and returns both its answer and the host settlement +evidence required by security-sensitive consumers to distinguish an +authoritative empty result from an availability failure. + +```ts +async queryDetailed(question: LookupQuestion, timeout?: number, options?: LookupQueryOptions): Promise +``` + +See also: [LookupQueryOptions](./overlay-tools.md#interface-lookupqueryoptions), [LookupQuestion](./overlay-tools.md#interface-lookupquestion), [LookupResolution](./overlay-tools.md#interface-lookupresolution) Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes), [Functions](#functions), [Types](#types), [Enums](#enums), [Variables](#variables) --- + ### Class: OverlayAdminTokenTemplate Script template enabling the creation, unlocking, and decoding of SHIP and SLAP advertisements. ```ts export default class OverlayAdminTokenTemplate implements ScriptTemplate { - pushDrop: PushDrop; - static decode(script: LockingScript): { - protocol: "SHIP" | "SLAP"; - identityKey: string; - domain: string; - topicOrService: string; - } - constructor(wallet: WalletInterface, originator?: OriginatorDomainNameStringUnder250Bytes) - async lock(protocol: "SHIP" | "SLAP", domain: string, topicOrService: string): Promise - unlock(protocol: "SHIP" | "SLAP"): { - sign: (tx: Transaction, inputIndex: number) => Promise; - estimateLength: (tx: Transaction, inputIndex: number) => Promise; - } + pushDrop: PushDrop + static decode(script: LockingScript): { + protocol: 'SHIP' | 'SLAP' + identityKey: string + domain: string + topicOrService: string + } + constructor(wallet: WalletInterface, originator?: OriginatorDomainNameStringUnder250Bytes) + async lock( + protocol: 'SHIP' | 'SLAP', + domain: string, + topicOrService: string + ): Promise + unlock(protocol: 'SHIP' | 'SLAP'): { + sign: (tx: Transaction, inputIndex: number) => Promise + estimateLength: (tx: Transaction, inputIndex: number) => Promise + } } ``` @@ -434,14 +861,15 @@ See also: [LockingScript](./script.md#class-lockingscript), [OriginatorDomainNam Constructs a new Overlay Admin template instance ```ts -constructor(wallet: WalletInterface, originator?: OriginatorDomainNameStringUnder250Bytes) +constructor(wallet: WalletInterface, originator?: OriginatorDomainNameStringUnder250Bytes) ``` + See also: [OriginatorDomainNameStringUnder250Bytes](./wallet.md#type-originatordomainnamestringunder250bytes), [WalletInterface](./wallet.md#interface-walletinterface) Argument Details -+ **wallet** - + Wallet to use for locking and unlocking +- **wallet** + - Wallet to use for locking and unlocking #### Method decode @@ -453,8 +881,9 @@ static decode(script: LockingScript): { identityKey: string; domain: string; topicOrService: string; -} +} ``` + See also: [LockingScript](./script.md#class-lockingscript) Returns @@ -463,16 +892,17 @@ Decoded SHIP or SLAP advertisement Argument Details -+ **script** - + Locking script comprising a SHIP or SLAP token to decode +- **script** + - Locking script comprising a SHIP or SLAP token to decode #### Method lock Creates a new advertisement locking script ```ts -async lock(protocol: "SHIP" | "SLAP", domain: string, topicOrService: string): Promise +async lock(protocol: "SHIP" | "SLAP", domain: string, topicOrService: string): Promise ``` + See also: [LockingScript](./script.md#class-lockingscript) Returns @@ -481,12 +911,12 @@ Locking script comprising the advertisement token Argument Details -+ **protocol** - + SHIP or SLAP -+ **domain** - + Domain where the topic or service is available -+ **topicOrService** - + Topic or service to advertise +- **protocol** + - SHIP or SLAP +- **domain** + - Domain where the topic or service is available +- **topicOrService** + - Topic or service to advertise #### Method unlock @@ -496,8 +926,9 @@ Unlocks an advertisement token as part of a transaction. unlock(protocol: "SHIP" | "SLAP"): { sign: (tx: Transaction, inputIndex: number) => Promise; estimateLength: (tx: Transaction, inputIndex: number) => Promise; -} +} ``` + See also: [Transaction](./transaction.md#class-transaction), [UnlockingScript](./script.md#class-unlockingscript), [sign](./compat.md#variable-sign) Returns @@ -506,20 +937,21 @@ Script unlocker capable of unlocking the advertisement token Argument Details -+ **protocol** - + SHIP or SLAP, depending on the token to unlock +- **protocol** + - SHIP or SLAP, depending on the token to unlock Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes), [Functions](#functions), [Types](#types), [Enums](#enums), [Variables](#variables) --- + ### Class: TopicBroadcaster Broadcasts transactions to one or more overlay topics. ```ts export default class TopicBroadcaster implements Broadcaster { - constructor(topics: string[], config: SHIPBroadcasterConfig = {}) - async broadcast(tx: Transaction): Promise + constructor(topics: string[], config: SHIPBroadcasterConfig = {}) + async broadcast(tx: Transaction): Promise } ``` @@ -530,24 +962,26 @@ See also: [BroadcastFailure](./transaction.md#interface-broadcastfailure), [Broa Constructs an instance of the SHIP broadcaster. ```ts -constructor(topics: string[], config: SHIPBroadcasterConfig = {}) +constructor(topics: string[], config: SHIPBroadcasterConfig = {}) ``` + See also: [SHIPBroadcasterConfig](./overlay-tools.md#interface-shipbroadcasterconfig) Argument Details -+ **topics** - + The list of SHIP topic names where transactions are to be sent. -+ **config** - + Configuration options for the SHIP broadcaster. +- **topics** + - The list of SHIP topic names where transactions are to be sent. +- **config** + - Configuration options for the SHIP broadcaster. #### Method broadcast Broadcasts a transaction to Overlay Services via SHIP. ```ts -async broadcast(tx: Transaction): Promise +async broadcast(tx: Transaction): Promise ``` + See also: [BroadcastFailure](./transaction.md#interface-broadcastfailure), [BroadcastResponse](./transaction.md#interface-broadcastresponse), [Transaction](./transaction.md#class-transaction) Returns @@ -556,12 +990,13 @@ A promise that resolves to either a success or failure response. Argument Details -+ **tx** - + The transaction to be sent. +- **tx** + - The transaction to be sent. Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes), [Functions](#functions), [Types](#types), [Enums](#enums), [Variables](#variables) --- + ## Functions ### Function: withDoubleSpendRetry @@ -571,7 +1006,11 @@ When a double-spend is detected, broadcasts the competing transaction to update the overlay with missing state, then retries the operation. ```ts -export async function withDoubleSpendRetry(operation: () => Promise, broadcaster: TopicBroadcaster, maxRetries: number = MAX_DOUBLE_SPEND_RETRIES): Promise +export async function withDoubleSpendRetry( + operation: () => Promise, + broadcaster: TopicBroadcaster, + maxRetries: number = MAX_DOUBLE_SPEND_RETRIES +): Promise ``` See also: [TopicBroadcaster](./overlay-tools.md#class-topicbroadcaster) @@ -582,12 +1021,12 @@ The result of the successful operation Argument Details -+ **operation** - + The async operation to execute (e.g., createAction + signAction) -+ **broadcaster** - + The TopicBroadcaster to use for syncing missing state -+ **maxRetries** - + Maximum number of retry attempts (default: MAX_DOUBLE_SPEND_RETRIES) +- **operation** + - The async operation to execute (e.g., createAction + signAction) +- **broadcaster** + - The TopicBroadcaster to use for syncing missing state +- **maxRetries** + - Maximum number of retry attempts (default: MAX_DOUBLE_SPEND_RETRIES) Throws @@ -596,12 +1035,19 @@ If max retries exceeded or non-double-spend error occurs Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes), [Functions](#functions), [Types](#types), [Enums](#enums), [Variables](#variables) --- + ## Types -| | -| --- | -| [LookupAnswer](#type-lookupanswer) | -| [STEAK](#type-steak) | +| | +| ---------------------------------------------------------------------- | +| [LookupAnswer](#type-lookupanswer) | +| [LookupEvidenceEvent](#type-lookupevidenceevent) | +| [LookupFacilitatorAnswer](#type-lookupfacilitatoranswer) | +| [LookupHTTPErrorKind](#type-lookuphttperrorkind) | +| [LookupNetworkPreset](#type-lookupnetworkpreset) | +| [RequireMode](#type-requiremode) | +| [STEAK](#type-steak) | +| [TopicAcknowledgmentRequirement](#type-topicacknowledgmentrequirement) | Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes), [Functions](#functions), [Types](#types), [Enums](#enums), [Variables](#variables) @@ -609,23 +1055,94 @@ Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes), [Functions]( ### Type: LookupAnswer -How the Overlay Services Engine responds to a Lookup Question. -It may comprise either an output list or a freeform response from the Lookup Service. +An aggregatable output-list answer returned by the resolver. ```ts export type LookupAnswer = { - type: "output-list"; - outputs: Array<{ - beef: number[]; - outputIndex: number; - context?: number[]; - }>; + type: 'output-list' + outputs: Array<{ + beef: number[] + outputIndex: number + context?: number[] + txid?: string + }> } ``` Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes), [Functions](#functions), [Types](#types), [Enums](#enums), [Variables](#variables) --- + +### Type: LookupEvidenceEvent + +Additive evidence intake, independent of the legacy aggregated answer. + +```ts +export type LookupEvidenceEvent = + | { + type: 'output' + host: string + output: LookupAnswer['outputs'][number] + } + | { + type: 'limit' + } +``` + +See also: [LookupAnswer](./overlay-tools.md#type-lookupanswer) + +Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes), [Functions](#functions), [Types](#types), [Enums](#enums), [Variables](#variables) + +--- + +### Type: LookupFacilitatorAnswer + +Responses a facilitator may return before the resolver aggregates them. + +```ts +export type LookupFacilitatorAnswer = LookupAnswer | LookupFreeformAnswer +``` + +See also: [LookupAnswer](./overlay-tools.md#type-lookupanswer), [LookupFreeformAnswer](./overlay-tools.md#interface-lookupfreeformanswer) + +Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes), [Functions](#functions), [Types](#types), [Enums](#enums), [Variables](#variables) + +--- + +### Type: LookupHTTPErrorKind + +```ts +export type LookupHTTPErrorKind = 'semantic' | 'availability' +``` + +Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes), [Functions](#functions), [Types](#types), [Enums](#enums), [Variables](#variables) + +--- + +### Type: LookupNetworkPreset + +Public overlay network presets understood by lookup and SHIP routing. + +```ts +export type LookupNetworkPreset = 'mainnet' | 'testnet' | 'teratestnet' | 'local' +``` + +Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes), [Functions](#functions), [Types](#types), [Enums](#enums), [Variables](#variables) + +--- + +### Type: RequireMode + +The require mode for topic acknowledgment: all topics must be present, or any one suffices. + +```ts +export type RequireMode = 'all' | 'any' +``` + +Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes), [Functions](#functions), [Types](#types), [Enums](#enums), [Variables](#variables) + +--- + ### Type: STEAK Submitted Transaction Execution AcKnowledgment @@ -639,14 +1156,30 @@ See also: [AdmittanceInstructions](./overlay-tools.md#interface-admittanceinstru Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes), [Functions](#functions), [Types](#types), [Enums](#enums), [Variables](#variables) --- + +### Type: TopicAcknowledgmentRequirement + +Specifies which topics must be acknowledged: all, any, or a specific list. + +```ts +export type TopicAcknowledgmentRequirement = RequireMode | string[] +``` + +See also: [RequireMode](./overlay-tools.md#type-requiremode) + +Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes), [Functions](#functions), [Types](#types), [Enums](#enums), [Variables](#variables) + +--- + ## Enums ## Variables -| | -| --- | -| [DEFAULT_SLAP_TRACKERS](#variable-default_slap_trackers) | -| [DEFAULT_TESTNET_SLAP_TRACKERS](#variable-default_testnet_slap_trackers) | +| | +| ---------------------------------------------------------------------------- | +| [DEFAULT_SLAP_TRACKERS](#variable-default_slap_trackers) | +| [DEFAULT_TESTNET_SLAP_TRACKERS](#variable-default_testnet_slap_trackers) | +| [DEFAULT_TTN_SLAP_TRACKERS](#variable-default_ttn_slap_trackers) | | [getOverlayHostReputationTracker](#variable-getoverlayhostreputationtracker) | Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes), [Functions](#functions), [Types](#types), [Enums](#enums), [Variables](#variables) @@ -667,6 +1200,7 @@ DEFAULT_SLAP_TRACKERS: string[] = [ Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes), [Functions](#functions), [Types](#types), [Enums](#enums), [Variables](#variables) --- + ### Variable: DEFAULT_TESTNET_SLAP_TRACKERS ```ts @@ -678,6 +1212,19 @@ DEFAULT_TESTNET_SLAP_TRACKERS: string[] = [ Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes), [Functions](#functions), [Types](#types), [Enums](#enums), [Variables](#variables) --- + +### Variable: DEFAULT_TTN_SLAP_TRACKERS + +```ts +DEFAULT_TTN_SLAP_TRACKERS: string[] = [ + "https://staging-overlay.babbage.systems" +] +``` + +Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes), [Functions](#functions), [Types](#types), [Enums](#enums), [Variables](#variables) + +--- + ### Variable: getOverlayHostReputationTracker ```ts diff --git a/packages/sdk/docs/reference/transaction.md b/packages/sdk/docs/reference/transaction.md index d1791a8b2..7d74d8b00 100644 --- a/packages/sdk/docs/reference/transaction.md +++ b/packages/sdk/docs/reference/transaction.md @@ -4,17 +4,17 @@ Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes), [Functions]( ## Interfaces -| | | -| --- | --- | -| [ArcConfig](#interface-arcconfig) | [HttpsModuleLike](#interface-httpsmodulelike) | -| [BroadcastFailure](#interface-broadcastfailure) | [HttpsNodejs](#interface-httpsnodejs) | -| [BroadcastResponse](#interface-broadcastresponse) | [MerklePathLeaf](#interface-merklepathleaf) | -| [Broadcaster](#interface-broadcaster) | [NodejsHttpClientRequest](#interface-nodejshttpclientrequest) | -| [ChainTracker](#interface-chaintracker) | [NodejsRequestLike](#interface-nodejsrequestlike) | -| [FeeModel](#interface-feemodel) | [TransactionInput](#interface-transactioninput) | -| [FetchOptions](#interface-fetchoptions) | [TransactionOutput](#interface-transactionoutput) | -| [HttpClient](#interface-httpclient) | [WhatsOnChainConfig](#interface-whatsonchainconfig) | -| [HttpClientRequestOptions](#interface-httpclientrequestoptions) | | +| | | | +| ----------------------------------------------------------- | --------------------------------------------------------------- | ----------------------------------------------------------------------------------------- | +| [ArcConfig](#interface-arcconfig) | [FeeModel](#interface-feemodel) | [TransactionEvidence](#interface-transactionevidence) | +| [BdkVerifierInterface](#interface-bdkverifierinterface) | [FetchOptions](#interface-fetchoptions) | [TransactionEvidenceContext](#interface-transactionevidencecontext) | +| [BdkVerifyScriptsParams](#interface-bdkverifyscriptsparams) | [HttpClient](#interface-httpclient) | [TransactionEvidenceCoordinatorOptions](#interface-transactionevidencecoordinatoroptions) | +| [BroadcastFailure](#interface-broadcastfailure) | [HttpClientRequestOptions](#interface-httpclientrequestoptions) | [TransactionEvidenceLimits](#interface-transactionevidencelimits) | +| [BroadcastResponse](#interface-broadcastresponse) | [HttpsModuleLike](#interface-httpsmodulelike) | [TransactionInput](#interface-transactioninput) | +| [Broadcaster](#interface-broadcaster) | [HttpsNodejs](#interface-httpsnodejs) | [TransactionOutput](#interface-transactionoutput) | +| [ChainTracker](#interface-chaintracker) | [MerklePathLeaf](#interface-merklepathleaf) | [VerifiedTransactionOutput](#interface-verifiedtransactionoutput) | +| [EvidenceCandidate](#interface-evidencecandidate) | [NodejsHttpClientRequest](#interface-nodejshttpclientrequest) | [WhatsOnChainConfig](#interface-whatsonchainconfig) | +| [EvidenceScriptScope](#interface-evidencescriptscope) | [NodejsRequestLike](#interface-nodejsrequestlike) | | Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes), [Functions](#functions), [Types](#types), [Enums](#enums), [Variables](#variables) @@ -26,12 +26,12 @@ Configuration options for the ARC broadcaster. ```ts export interface ArcConfig { - apiKey?: string; - httpClient?: HttpClient; - deploymentId?: string; - callbackUrl?: string; - callbackToken?: string; - headers?: Record; + apiKey?: string + httpClient?: HttpClient + deploymentId?: string + callbackUrl?: string + callbackToken?: string + headers?: Record } ``` @@ -84,44 +84,132 @@ The HTTP client used to make requests to the ARC API. ```ts httpClient?: HttpClient ``` + See also: [HttpClient](./transaction.md#interface-httpclient) Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes), [Functions](#functions), [Types](#types), [Enums](#enums), [Variables](#variables) --- + +### Interface: BdkVerifierInterface + +A pluggable backend that verifies ALL input scripts of a single transaction. + +Implementations (e.g. + +```ts +export default interface BdkVerifierInterface { + supportsMemoryLimit?: boolean + shouldVerifyScripts?: (params: BdkVerifyScriptsParams) => boolean + verifyScripts: (params: BdkVerifyScriptsParams) => Promise + verifyScriptsBatch?: (params: readonly BdkVerifyScriptsParams[]) => Promise +} +``` + +See also: [BdkVerifyScriptsParams](./transaction.md#interface-bdkverifyscriptsparams) + +#### Property shouldVerifyScripts + +Optionally decide whether this backend should handle the transaction now. +Returning false preserves the SDK's synchronous JavaScript interpreter path. +Implementations can use this to avoid waiting for a cold optional backend. + +```ts +shouldVerifyScripts?: (params: BdkVerifyScriptsParams) => boolean +``` + +See also: [BdkVerifyScriptsParams](./transaction.md#interface-bdkverifyscriptsparams) + +#### Property supportsMemoryLimit + +True only when this backend applies `params.memoryLimit` during script +execution. Backends that omit this capability are bypassed for calls with +an explicit memory limit. + +```ts +supportsMemoryLimit?: boolean +``` + +#### Property verifyScripts + +Verify all input scripts of `params.tx`. + +```ts +verifyScripts: (params: BdkVerifyScriptsParams) => Promise +``` + +See also: [BdkVerifyScriptsParams](./transaction.md#interface-bdkverifyscriptsparams) + +#### Property verifyScriptsBatch + +Verify several independent transactions in one backend scheduling pass. +Implementations may use packed native calls and worker-level parallelism. + +```ts +verifyScriptsBatch?: (params: readonly BdkVerifyScriptsParams[]) => Promise +``` + +See also: [BdkVerifyScriptsParams](./transaction.md#interface-bdkverifyscriptsparams) + +Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes), [Functions](#functions), [Types](#types), [Enums](#enums), [Variables](#variables) + +--- + +### Interface: BdkVerifyScriptsParams + +Parameters shared by script-verifier routing and execution. + +```ts +export interface BdkVerifyScriptsParams { + tx: Transaction + blockHeight: number + consensus: boolean + verifyFlags?: string | string[] + memoryLimit?: number +} +``` + +See also: [Transaction](./transaction.md#class-transaction) + +Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes), [Functions](#functions), [Types](#types), [Enums](#enums), [Variables](#variables) + +--- + ### Interface: BroadcastFailure Defines the structure of a failed broadcast response. ```ts export interface BroadcastFailure { - status: "error"; - code: string; - txid?: string; - description: string; - more?: object; + status: 'error' + code: string + txid?: string + description: string + more?: object } ``` Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes), [Functions](#functions), [Types](#types), [Enums](#enums), [Variables](#variables) --- + ### Interface: BroadcastResponse Defines the structure of a successful broadcast response. ```ts export interface BroadcastResponse { - status: "success"; - txid: string; - message: string; - competingTxs?: string[]; + status: 'success' + txid: string + message: string + competingTxs?: string[] } ``` Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes), [Functions](#functions), [Types](#types), [Enums](#enums), [Variables](#variables) --- + ### Interface: Broadcaster Represents the interface for a transaction broadcaster. @@ -129,8 +217,8 @@ This interface defines a standard method for broadcasting transactions. ```ts export interface Broadcaster { - broadcast: (transaction: Transaction) => Promise; - broadcastMany?: (txs: Transaction[]) => Promise; + broadcast: (transaction: Transaction) => Promise + broadcastMany?: (txs: Transaction[]) => Promise } ``` @@ -139,6 +227,7 @@ See also: [BroadcastFailure](./transaction.md#interface-broadcastfailure), [Broa Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes), [Functions](#functions), [Types](#types), [Enums](#enums), [Variables](#variables) --- + ### Interface: ChainTracker The Chain Tracker is responsible for verifying the validity of a given Merkle root @@ -163,14 +252,77 @@ const chainTracker = { ```ts export default interface ChainTracker { - isValidRootForHeight: (root: string, height: number) => Promise; - currentHeight: () => Promise; + isValidRootForHeight: (root: string, height: number, signal?: AbortSignal) => Promise + currentHeight: (signal?: AbortSignal) => Promise + getVerificationContext?: () => string | number + getVerificationContextToken?: (signal?: AbortSignal) => Promise } ``` +#### Property getVerificationContext + +Optional trusted local provider/policy/recovery context. Change this value +when switching sources or resetting their state. It is not a canonical +chain snapshot: consumers must still check current canonical dependencies. +Implementations without cancellable I/O may ignore the optional signals. + +```ts +getVerificationContext?: () => string | number +``` + +#### Property getVerificationContextToken + +Optional fresh canonical context token from the trusted chain provider. +Include canonical block/tip identity and any available monotonic reorg or +reset epoch. Consumers compare tokens around asynchronous verification. +Two remote tip observations are not an atomic snapshot and cannot detect +an intervening transition back to the identical tip (ABA). + +```ts +getVerificationContextToken?: (signal?: AbortSignal) => Promise +``` + +Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes), [Functions](#functions), [Types](#types), [Enums](#enums), [Variables](#variables) + +--- + +### Interface: EvidenceCandidate + +Internal owned candidate. Never constructed from a host's verification assertion. + +```ts +export interface EvidenceCandidate { + tx: Transaction + txid: string + receipt: string + byteLength: number + outputIndex: number + graphBinding: string +} +``` + +See also: [Transaction](./transaction.md#class-transaction) + Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes), [Functions](#functions), [Types](#types), [Enums](#enums), [Variables](#variables) --- + +### Interface: EvidenceScriptScope + +```ts +export interface EvidenceScriptScope { + work: EvidenceScriptWork + signal: AbortSignal + check: () => void +} +``` + +See also: [EvidenceScriptWork](./transaction.md#class-evidencescriptwork) + +Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes), [Functions](#functions), [Types](#types), [Enums](#enums), [Variables](#variables) + +--- + ### Interface: FeeModel Represents the interface for a transaction fee model. @@ -178,7 +330,7 @@ This interface defines a standard method for computing a fee when given a transa ```ts export default interface FeeModel { - computeFee: (transaction: Transaction) => Promise; + computeFee: (transaction: Transaction) => Promise } ``` @@ -187,6 +339,7 @@ See also: [Transaction](./transaction.md#class-transaction) Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes), [Functions](#functions), [Types](#types), [Enums](#enums), [Variables](#variables) --- + ### Interface: FetchOptions An interface for configuration of the request to be passed to the fetch method @@ -194,9 +347,9 @@ limited to options needed by ts-sdk. ```ts export interface FetchOptions { - method?: string; - headers?: Record; - body?: string | null; + method?: string + headers?: Record + body?: string | null } ``` @@ -227,13 +380,17 @@ method?: string Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes), [Functions](#functions), [Types](#types), [Enums](#enums), [Variables](#variables) --- + ### Interface: HttpClient An interface for HTTP client used to make HTTP requests. ```ts export interface HttpClient { - request: (url: string, options: HttpClientRequestOptions) => Promise>; + request: ( + url: string, + options: HttpClientRequestOptions + ) => Promise> } ``` @@ -244,23 +401,26 @@ See also: [HttpClientRequestOptions](./transaction.md#interface-httpclientreques Makes a request to the server. ```ts -request: (url: string, options: HttpClientRequestOptions) => Promise> +request: (url: string, options: HttpClientRequestOptions) => + Promise> ``` + See also: [HttpClientRequestOptions](./transaction.md#interface-httpclientrequestoptions), [HttpClientResponse](./transaction.md#type-httpclientresponse) Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes), [Functions](#functions), [Types](#types), [Enums](#enums), [Variables](#variables) --- + ### Interface: HttpClientRequestOptions An interface for configuration of the request to be passed to the request method. ```ts export interface HttpClientRequestOptions { - method?: string; - headers?: Record; - data?: Data; - signal?: AbortSignal; + method?: string + headers?: Record + data?: Data + signal?: AbortSignal } ``` @@ -299,13 +459,18 @@ signal?: AbortSignal Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes), [Functions](#functions), [Types](#types), [Enums](#enums), [Variables](#variables) --- + ### Interface: HttpsModuleLike Common interface for Node.js https modules ```ts export interface HttpsModuleLike { - request: (url: string, options: HttpClientRequestOptions, callback: (res: any) => void) => NodejsRequestLike; + request: ( + url: string, + options: HttpClientRequestOptions, + callback: (res: any) => void + ) => NodejsRequestLike } ``` @@ -314,13 +479,18 @@ See also: [HttpClientRequestOptions](./transaction.md#interface-httpclientreques Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes), [Functions](#functions), [Types](#types), [Enums](#enums), [Variables](#variables) --- + ### Interface: HttpsNodejs Node Https module interface limited to options needed by ts-sdk ```ts export interface HttpsNodejs { - request: (url: string, options: HttpClientRequestOptions, callback: (res: any) => void) => NodejsHttpClientRequest; + request: ( + url: string, + options: HttpClientRequestOptions, + callback: (res: any) => void + ) => NodejsHttpClientRequest } ``` @@ -329,50 +499,147 @@ See also: [HttpClientRequestOptions](./transaction.md#interface-httpclientreques Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes), [Functions](#functions), [Types](#types), [Enums](#enums), [Variables](#variables) --- + ### Interface: MerklePathLeaf ```ts export interface MerklePathLeaf { - offset: number; - hash?: string; - txid?: boolean; - duplicate?: boolean; + offset: number + hash?: string + txid?: boolean + duplicate?: boolean } ``` Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes), [Functions](#functions), [Types](#types), [Enums](#enums), [Variables](#variables) --- + ### Interface: NodejsHttpClientRequest Nodejs result of the Node https.request call limited to options needed by ts-sdk ```ts export interface NodejsHttpClientRequest { - write: (chunk: string) => void; - on: (event: string, callback: (data: any) => void) => void; - end: () => void; + write: (chunk: string) => void + on: (event: string, callback: (data: any) => void) => void + end: () => void } ``` Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes), [Functions](#functions), [Types](#types), [Enums](#enums), [Variables](#variables) --- + ### Interface: NodejsRequestLike Common interface for Node.js https module request objects ```ts export interface NodejsRequestLike { - write: (chunk: any) => void; - on: (event: string, callback: (data: any) => void) => void; - end: () => void; + write: (chunk: any) => void + on: (event: string, callback: (data: any) => void) => void + end: () => void +} +``` + +Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes), [Functions](#functions), [Types](#types), [Enums](#enums), [Variables](#variables) + +--- + +### Interface: TransactionEvidence + +Untrusted transaction evidence. The optional txid is only a consistency hint. + +```ts +export interface TransactionEvidence { + beef: number[] + outputIndex: number + txid?: string +} +``` + +Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes), [Functions](#functions), [Types](#types), [Enums](#enums), [Variables](#variables) + +--- + +### Interface: TransactionEvidenceContext + +Caller-controlled trust configuration; never populate this from lookup metadata. + +```ts +export interface TransactionEvidenceContext { + chainTracker: ChainTracker + chainNamespace: string + policyId: string + verifier?: BdkVerifierInterface +} +``` + +See also: [BdkVerifierInterface](./transaction.md#interface-bdkverifierinterface), [ChainTracker](./transaction.md#interface-chaintracker) + +#### Property chainNamespace + +Network/genesis identifier or an explicit application chain namespace. + +```ts +chainNamespace: string +``` + +#### Property policyId + +Semantic verification policy/backend version; change it when policy changes. + +```ts +policyId: string +``` + +Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes), [Functions](#functions), [Types](#types), [Enums](#enums), [Variables](#variables) + +--- + +### Interface: TransactionEvidenceCoordinatorOptions + +```ts +export interface TransactionEvidenceCoordinatorOptions extends TransactionEvidenceContext { + limits?: Partial } ``` +See also: [TransactionEvidenceContext](./transaction.md#interface-transactionevidencecontext), [TransactionEvidenceLimits](./transaction.md#interface-transactionevidencelimits) + Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes), [Functions](#functions), [Types](#types), [Enums](#enums), [Variables](#variables) --- + +### Interface: TransactionEvidenceLimits + +Local admission policy, not consensus limits. Byte limits count serialized bytes. + +```ts +export interface TransactionEvidenceLimits { + candidateBytes: number + retainedBytes: number + transactions: number + inputs: number + scriptBytes: number + scriptMemoryBytes: number + candidatesPerTransaction: number + pendingTransactions: number + concurrentTransactions: number + pendingChainCalls: number + consumers: number + cacheEntries: number + cacheAgeMs: number + attemptTimeoutMs: number + requestTimeoutMs: number +} +``` + +Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes), [Functions](#functions), [Types](#types), [Enums](#enums), [Variables](#variables) + +--- + ### Interface: TransactionInput Represents an input to a Bitcoin transaction. @@ -398,15 +665,15 @@ txInput.unlockingScriptTemplate = { ```ts export default interface TransactionInput { - sourceTransaction?: Transaction; - sourceTXID?: string; - sourceOutputIndex: number; - unlockingScript?: UnlockingScript; - unlockingScriptTemplate?: { - sign: (tx: Transaction, inputIndex: number) => Promise; - estimateLength: (tx: Transaction, inputIndex: number) => Promise; - }; - sequence?: number; + sourceTransaction?: Transaction + sourceTXID?: string + sourceOutputIndex: number + unlockingScript?: UnlockingScript + unlockingScriptTemplate?: { + sign: (tx: Transaction, inputIndex: number) => Promise + estimateLength: (tx: Transaction, inputIndex: number) => Promise + } + sequence?: number } ``` @@ -415,6 +682,7 @@ See also: [Transaction](./transaction.md#class-transaction), [UnlockingScript](. Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes), [Functions](#functions), [Types](#types), [Enums](#enums), [Variables](#variables) --- + ### Interface: TransactionOutput Represents an output in a Bitcoin transaction. @@ -429,14 +697,14 @@ let txOutput = { satoshis: 1000, lockingScript: LockingScript.fromASM('OP_DUP OP_HASH160 ... OP_EQUALVERIFY OP_CHECKSIG'), change: false -}; +} ``` ```ts export default interface TransactionOutput { - satoshis?: number; - lockingScript: LockingScript; - change?: boolean; + satoshis?: number + lockingScript: LockingScript + change?: boolean } ``` @@ -445,14 +713,34 @@ See also: [LockingScript](./script.md#class-lockingscript) Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes), [Functions](#functions), [Types](#types), [Enums](#enums), [Variables](#variables) --- + +### Interface: VerifiedTransactionOutput + +Verified transaction inclusion/ancestry; no service relevance or unspentness claim. + +```ts +export interface VerifiedTransactionOutput { + readonly txid: string + readonly outputIndex: number + readonly outpoint: string + readonly lockingScript: LockingScript +} +``` + +See also: [LockingScript](./script.md#class-lockingscript) + +Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes), [Functions](#functions), [Types](#types), [Enums](#enums), [Variables](#variables) + +--- + ### Interface: WhatsOnChainConfig Configuration options for the WhatsOnChain ChainTracker. ```ts export interface WhatsOnChainConfig { - apiKey?: string; - httpClient?: HttpClient; + apiKey?: string + httpClient?: HttpClient } ``` @@ -473,26 +761,24 @@ The HTTP client used to make requests to the API. ```ts httpClient?: HttpClient ``` + See also: [HttpClient](./transaction.md#interface-httpclient) Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes), [Functions](#functions), [Types](#types), [Enums](#enums), [Variables](#variables) --- + ## Classes -| | -| --- | -| [ARC](#class-arc) | -| [Beef](#class-beef) | -| [BeefParty](#class-beefparty) | -| [BeefTx](#class-beeftx) | -| [FetchHttpClient](#class-fetchhttpclient) | -| [LivePolicy](#class-livepolicy) | -| [MerklePath](#class-merklepath) | -| [NodejsHttpClient](#class-nodejshttpclient) | -| [SatoshisPerKilobyte](#class-satoshisperkilobyte) | -| [Transaction](#class-transaction) | -| [WhatsOnChain](#class-whatsonchain) | +| | | +| ----------------------------------------------- | ----------------------------------------------------------------------- | +| [ARC](#class-arc) | [MerklePath](#class-merklepath) | +| [Beef](#class-beef) | [NodejsHttpClient](#class-nodejshttpclient) | +| [BeefParty](#class-beefparty) | [SatoshisPerKilobyte](#class-satoshisperkilobyte) | +| [BeefTx](#class-beeftx) | [Transaction](#class-transaction) | +| [EvidenceScriptWork](#class-evidencescriptwork) | [TransactionEvidenceCoordinator](#class-transactionevidencecoordinator) | +| [FetchHttpClient](#class-fetchhttpclient) | [TransactionEvidenceError](#class-transactionevidenceerror) | +| [LivePolicy](#class-livepolicy) | [WhatsOnChain](#class-whatsonchain) | Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes), [Functions](#functions), [Types](#types), [Enums](#enums), [Variables](#variables) @@ -504,17 +790,17 @@ Represents an ARC transaction broadcaster. ```ts export default class ARC implements Broadcaster { - readonly URL: string; - readonly apiKey: string | undefined; - readonly deploymentId: string; - readonly callbackUrl: string | undefined; - readonly callbackToken: string | undefined; - readonly headers: Record | undefined; - constructor(URL: string, config?: ArcConfig); - constructor(URL: string, apiKey?: string); - constructor(URL: string, config?: string | ArcConfig) - async broadcast(tx: Transaction): Promise - async broadcastMany(txs: Transaction[]): Promise + readonly URL: string + readonly apiKey: string | undefined + readonly deploymentId: string + readonly callbackUrl: string | undefined + readonly callbackToken: string | undefined + readonly headers: Record | undefined + constructor(URL: string, config?: ArcConfig) + constructor(URL: string, apiKey?: string) + constructor(URL: string, config?: string | ArcConfig) + async broadcast(tx: Transaction): Promise + async broadcastMany(txs: Transaction[]): Promise } ``` @@ -527,14 +813,15 @@ Constructs an instance of the ARC broadcaster. ```ts constructor(URL: string, config?: ArcConfig) ``` + See also: [ArcConfig](./transaction.md#interface-arcconfig) Argument Details -+ **URL** - + The URL endpoint for the ARC API. -+ **config** - + Configuration options for the ARC broadcaster. +- **URL** + - The URL endpoint for the ARC API. +- **config** + - Configuration options for the ARC broadcaster. #### Constructor @@ -546,18 +833,19 @@ constructor(URL: string, apiKey?: string) Argument Details -+ **URL** - + The URL endpoint for the ARC API. -+ **apiKey** - + The API key used for authorization with the ARC API. +- **URL** + - The URL endpoint for the ARC API. +- **apiKey** + - The API key used for authorization with the ARC API. #### Method broadcast Broadcasts a transaction via ARC. ```ts -async broadcast(tx: Transaction): Promise +async broadcast(tx: Transaction): Promise ``` + See also: [BroadcastFailure](./transaction.md#interface-broadcastfailure), [BroadcastResponse](./transaction.md#interface-broadcastresponse), [Transaction](./transaction.md#class-transaction) Returns @@ -566,8 +854,8 @@ A promise that resolves to either a success or failure response. Argument Details -+ **tx** - + The transaction to be broadcasted. +- **tx** + - The transaction to be broadcasted. #### Method broadcastMany @@ -575,8 +863,9 @@ Broadcasts multiple transactions via ARC. Handles mixed responses where some transactions succeed and others fail. ```ts -async broadcastMany(txs: Transaction[]): Promise +async broadcastMany(txs: Transaction[]): Promise ``` + See also: [Transaction](./transaction.md#class-transaction) Returns @@ -585,60 +874,70 @@ A promise that resolves to an array of objects. Argument Details -+ **txs** - + Array of transactions to be broadcasted. +- **txs** + - Array of transactions to be broadcasted. Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes), [Functions](#functions), [Types](#types), [Enums](#enums), [Variables](#variables) --- + ### Class: Beef ```ts export class Beef { - bumps: MerklePath[] = []; - txs: BeefTx[] = []; - version: number = BEEF_V2; - atomicTxid: string | undefined = undefined; - constructor(version: number = BEEF_V2) - findTxid(txid: string): BeefTx | undefined - makeTxidOnly(txid: string): BeefTx | undefined - findBump(txid: string): MerklePath | undefined - findTransactionForSigning(txid: string): Transaction | undefined - findAtomicTransaction(txid: string): Transaction | undefined - mergeBump(bump: MerklePath): number - mergeRawTx(rawTx: number[] | Uint8Array, bumpIndex?: number): BeefTx - mergeTransaction(tx: Transaction): BeefTx - removeExistingTxid(txid: string): void - mergeTxidOnly(txid: string): BeefTx - mergeBeefTx(btx: BeefTx): BeefTx - mergeBeef(beef: Beef | number[] | Uint8Array): void - isValid(allowTxidOnly?: boolean): boolean - async verify(chainTracker: ChainTracker, allowTxidOnly?: boolean): Promise - verifyValid(allowTxidOnly?: boolean): { - valid: boolean; - roots: Record; - } - toWriter(writer: Writer | WriterUint8Array): void - toBinary(): number[] - toUint8Array(): Uint8Array - toBinaryAtomic(txid: string): number[] - toUint8ArrayAtomic(txid: string): Uint8Array - toHex(): string - static fromReader(br: Reader | ReaderUint8Array): Beef - static fromBinary(bin: number[] | Uint8Array): Beef - static fromString(s: string, enc: "hex" | "utf8" | "base64" = "hex"): Beef - sortTxs(): { - missingInputs: string[]; - notValid: string[]; - valid: string[]; - withMissingInputs: string[]; - txidOnly: string[]; - } - clone(): Beef - trimKnownTxids(knownTxids: string[]): void - getValidTxids(): string[] - toLogString(): string - addComputedLeaves(): void + bumps: MerklePath[] = [] + txs: BeefTx[] = [] + version: number = BEEF_V2 + atomicTxid: string | undefined = undefined + constructor(version: number = BEEF_V2) + isAtomic(txid: string = this.atomicTxid ?? ''): boolean + findTxid(txid: string): BeefTx | undefined + makeTxidOnly(txid: string): BeefTx | undefined + findBump(txid: string): MerklePath | undefined + findTransactionForSigning(txid: string): Transaction | undefined + findAtomicTransaction(txid: string): Transaction | undefined + mergeBump(bump: MerklePath): number + mergeProvenTxs( + entries: Array<{ + rawTx: number[] | Uint8Array + merklePath: MerklePath + merkleRoot?: string + }> + ): BeefTx[] + mergeRawTx(rawTx: number[] | Uint8Array, bumpIndex?: number): BeefTx + mergeTransaction(tx: Transaction): BeefTx + removeExistingTxid(txid: string): void + mergeTxidOnly(txid: string): BeefTx + mergeBeefTx(btx: BeefTx): BeefTx + mergeBeef(beef: Beef | number[] | Uint8Array): void + isValid(allowTxidOnly?: boolean): boolean + async verify(chainTracker: ChainTracker, allowTxidOnly?: boolean): Promise + verifyValid(allowTxidOnly?: boolean): { + valid: boolean + roots: Record + } + toWriter(writer: Writer | WriterUint8Array): void + toBinary(): number[] + toUint8Array(): Uint8Array + toBinaryAtomic(txid: string): number[] + toUint8ArrayAtomic(txid: string): Uint8Array + toHex(): string + static fromReader(br: Reader | ReaderUint8Array): Beef + static fromBinary(bin: number[] | Uint8Array): Beef + static fromBinaryView(bin: Uint8Array): Beef + static fromString(s: string, enc: 'hex' | 'utf8' | 'base64' = 'hex'): Beef + sortTxs(): { + missingInputs: string[] + notValid: string[] + valid: string[] + withMissingInputs: string[] + txidOnly: string[] + } + clone(): Beef + trimKnownTxids(knownTxids: string[]): void + getValidTxids(): string[] + toLogString(): string + addComputedLeaves(): void } ``` @@ -650,14 +949,15 @@ In some circumstances it may be helpful for the BUMP MerklePaths to include leaves that can be computed from row zero. ```ts -addComputedLeaves(): void +addComputedLeaves(): void ``` #### Method clone ```ts -clone(): Beef +clone(): Beef ``` + See also: [Beef](./transaction.md#class-beef) Returns @@ -671,8 +971,9 @@ Builds the proof tree rooted at a specific `Transaction`. To succeed, the Beef must contain all the required transaction and merkle path data. ```ts -findAtomicTransaction(txid: string): Transaction | undefined +findAtomicTransaction(txid: string): Transaction | undefined ``` + See also: [Transaction](./transaction.md#class-transaction) Returns @@ -681,14 +982,15 @@ Transaction with input `SourceTransaction` and `MerklePath` populated from this Argument Details -+ **txid** - + The id of the target transaction. +- **txid** + - The id of the target transaction. #### Method findBump ```ts -findBump(txid: string): MerklePath | undefined +findBump(txid: string): MerklePath | undefined ``` + See also: [MerklePath](./transaction.md#class-merklepath) Returns @@ -703,8 +1005,9 @@ and adds any missing input SourceTransactions from this `Beef`. The result is suitable for signing. ```ts -findTransactionForSigning(txid: string): Transaction | undefined +findTransactionForSigning(txid: string): Transaction | undefined ``` + See also: [Transaction](./transaction.md#class-transaction) Returns @@ -713,14 +1016,15 @@ Transaction with all available input `SourceTransaction`s from this Beef. Argument Details -+ **txid** - + The id of the target transaction. +- **txid** + - The id of the target transaction. #### Method findTxid ```ts -findTxid(txid: string): BeefTx | undefined +findTxid(txid: string): BeefTx | undefined ``` + See also: [BeefTx](./transaction.md#class-beeftx) Returns @@ -729,16 +1033,17 @@ Returns Argument Details -+ **txid** - + of `beefTx` to find +- **txid** + - of `beefTx` to find #### Method fromBinary Constructs an instance of the Beef class based on the provided binary array ```ts -static fromBinary(bin: number[] | Uint8Array): Beef +static fromBinary(bin: number[] | Uint8Array): Beef ``` + See also: [Beef](./transaction.md#class-beef) Returns @@ -747,16 +1052,28 @@ An instance of the Beef class constructed from the binary data Argument Details -+ **bin** - + The binary array or Uint8Array from which to construct BEEF +- **bin** + - The binary array or Uint8Array from which to construct BEEF + +#### Method fromBinaryView + +Parses BEEF while retaining zero-copy views over `bin`. The caller must not +mutate the buffer for the lifetime of the returned object. + +```ts +static fromBinaryView(bin: Uint8Array): Beef +``` + +See also: [Beef](./transaction.md#class-beef) #### Method fromString Constructs an instance of the Beef class based on the provided string ```ts -static fromString(s: string, enc: "hex" | "utf8" | "base64" = "hex"): Beef +static fromString(s: string, enc: "hex" | "utf8" | "base64" = "hex"): Beef ``` + See also: [Beef](./transaction.md#class-beef) Returns @@ -765,21 +1082,31 @@ An instance of the Beef class constructed from the string Argument Details -+ **s** - + The string value from which to construct BEEF -+ **enc** - + The encoding of the string value from which BEEF should be constructed +- **s** + - The string value from which to construct BEEF +- **enc** + - The encoding of the string value from which BEEF should be constructed #### Method getValidTxids ```ts -getValidTxids(): string[] +getValidTxids(): string[] ``` Returns array of transaction txids that either have a proof or whose inputs chain back to a proven transaction. +#### Method isAtomic + +Checks the BRC-95 transaction-inclusion rule without requiring header-root +validation: the subject must exist and every included transaction must be +in its recursive dependency graph. + +```ts +isAtomic(txid: string = this.atomicTxid ?? ""): boolean +``` + #### Method isValid Sorts `txs` and checks structural validity of beef. @@ -787,19 +1114,20 @@ Sorts `txs` and checks structural validity of beef. Does NOT verify merkle roots. Validity requirements: + 1. No 'known' txids, unless `allowTxidOnly` is true. 2. All transactions have bumps or their inputs chain back to bumps (or are known). 3. Order of transactions satisfies dependencies before dependents. 4. No transactions with duplicate txids. ```ts -isValid(allowTxidOnly?: boolean): boolean +isValid(allowTxidOnly?: boolean): boolean ``` Argument Details -+ **allowTxidOnly** - + optional. If true, transaction txid only is assumed valid +- **allowTxidOnly** + - optional. If true, transaction txid only is assumed valid #### Method makeTxidOnly @@ -810,8 +1138,9 @@ updated by this method without affecting the original. ```ts -makeTxidOnly(txid: string): BeefTx | undefined +makeTxidOnly(txid: string): BeefTx | undefined ``` + See also: [BeefTx](./transaction.md#class-beeftx) Returns @@ -823,14 +1152,34 @@ undefined if txid is unknown. Merge a MerklePath that is assumed to be fully valid. ```ts -mergeBump(bump: MerklePath): number +mergeBump(bump: MerklePath): number ``` + See also: [MerklePath](./transaction.md#class-merklepath) Returns index of merged bump +#### Method mergeProvenTxs + +Merge several independently proven transactions in one mutation pass. + +This is equivalent to calling `mergeRawTx` followed by `mergeBump` for +every entry, but synchronizes nested BEEF state only once. That distinction +matters for wallets assembling a BEEF from a fragmented UTXO set because +proof paths are otherwise re-scanned after every input. + +```ts +mergeProvenTxs(entries: Array<{ + rawTx: number[] | Uint8Array; + merklePath: MerklePath; + merkleRoot?: string; +}>): BeefTx[] +``` + +See also: [BeefTx](./transaction.md#class-beeftx), [MerklePath](./transaction.md#class-merklepath) + #### Method mergeRawTx Merge a serialized transaction. @@ -840,8 +1189,9 @@ Checks that a transaction with the same txid hasn't already been merged. Replaces existing transaction with same txid. ```ts -mergeRawTx(rawTx: number[] | Uint8Array, bumpIndex?: number): BeefTx +mergeRawTx(rawTx: number[] | Uint8Array, bumpIndex?: number): BeefTx ``` + See also: [BeefTx](./transaction.md#class-beeftx) Returns @@ -850,8 +1200,8 @@ txid of rawTx Argument Details -+ **bumpIndex** - + Optional. If a number, must be valid index into bumps array. +- **bumpIndex** + - Optional. If a number, must be valid index into bumps array. #### Method mergeTransaction @@ -862,8 +1212,9 @@ Replaces existing transaction with same txid. Attempts to match an existing bump to the new transaction. ```ts -mergeTransaction(tx: Transaction): BeefTx +mergeTransaction(tx: Transaction): BeefTx ``` + See also: [BeefTx](./transaction.md#class-beeftx), [Transaction](./transaction.md#class-transaction) Returns @@ -875,17 +1226,18 @@ txid of tx Removes an existing transaction from the BEEF, given its TXID ```ts -removeExistingTxid(txid: string): void +removeExistingTxid(txid: string): void ``` Argument Details -+ **txid** - + TXID of the transaction to remove +- **txid** + - TXID of the transaction to remove #### Method sortTxs Sort the `txs` by input txid dependency order: + - Oldest Tx Anchored by Path or txid only - Newer Txs depending on Older parents - Newest Tx @@ -899,7 +1251,7 @@ sortTxs(): { valid: string[]; withMissingInputs: string[]; txidOnly: string[]; -} +} ``` Returns @@ -911,7 +1263,7 @@ Returns Returns a binary array representing the serialized BEEF ```ts -toBinary(): number[] +toBinary(): number[] ``` Returns @@ -926,10 +1278,10 @@ Serialize this Beef as AtomicBEEF. `txid` must exist -after sorting, if txid is not last txid, creates a clone and removes newer txs +includes exactly the subject transaction and its recursive dependencies ```ts -toBinaryAtomic(txid: string): number[] +toBinaryAtomic(txid: string): number[] ``` Returns @@ -941,7 +1293,7 @@ serialized contents of this Beef with AtomicBEEF prefix. Returns a hex string representing the serialized BEEF ```ts -toHex(): string +toHex(): string ``` Returns @@ -951,7 +1303,7 @@ A hex string representing the BEEF #### Method toLogString ```ts -toLogString(): string +toLogString(): string ``` Returns @@ -963,7 +1315,7 @@ Summary of `Beef` contents as multi-line string. Returns a binary array representing the serialized BEEF ```ts -toUint8Array(): Uint8Array +toUint8Array(): Uint8Array ``` Returns @@ -976,10 +1328,10 @@ Serialize this Beef as AtomicBEEF. `txid` must exist -after sorting, if txid is not last txid, creates a clone and removes newer txs +includes exactly the subject transaction and its recursive dependencies ```ts -toUint8ArrayAtomic(txid: string): Uint8Array +toUint8ArrayAtomic(txid: string): Uint8Array ``` Returns @@ -991,8 +1343,9 @@ serialized contents of this Beef with AtomicBEEF prefix. Serializes this data to `writer` ```ts -toWriter(writer: Writer | WriterUint8Array): void +toWriter(writer: Writer | WriterUint8Array): void ``` + See also: [Writer](./primitives.md#class-writer), [WriterUint8Array](./primitives.md#class-writeruint8array) #### Method trimKnownTxids @@ -1000,7 +1353,7 @@ See also: [Writer](./primitives.md#class-writer), [WriterUint8Array](./primitive Ensure that all the txids in `knownTxids` are txidOnly ```ts -trimKnownTxids(knownTxids: string[]): void +trimKnownTxids(knownTxids: string[]): void ``` #### Method verify @@ -1010,22 +1363,24 @@ by validating structure of this beef and confirming computed merkle roots using `chainTracker`. Validity requirements: + 1. No 'known' txids, unless `allowTxidOnly` is true. 2. All transactions have bumps or their inputs chain back to bumps (or are known). 3. Order of transactions satisfies dependencies before dependents. 4. No transactions with duplicate txids. ```ts -async verify(chainTracker: ChainTracker, allowTxidOnly?: boolean): Promise +async verify(chainTracker: ChainTracker, allowTxidOnly?: boolean): Promise ``` + See also: [ChainTracker](./transaction.md#interface-chaintracker) Argument Details -+ **chainTracker** - + Used to verify computed merkle path roots for all bump txids. -+ **allowTxidOnly** - + optional. If true, transaction txid is assumed valid +- **chainTracker** + - Used to verify computed merkle path roots for all bump txids. +- **allowTxidOnly** + - optional. If true, transaction txid is assumed valid #### Method verifyValid @@ -1035,6 +1390,7 @@ by validating structure of this beef. Returns block heights and merkle root values to be confirmed by a chaintracker. Validity requirements: + 1. No 'known' txids, unless `allowTxidOnly` is true. 2. All transactions have bumps or their inputs chain back to bumps (or are known). 3. Order of transactions satisfies dependencies before dependents. @@ -1044,7 +1400,7 @@ Validity requirements: verifyValid(allowTxidOnly?: boolean): { valid: boolean; roots: Record; -} +} ``` Returns @@ -1054,12 +1410,13 @@ Returns Argument Details -+ **allowTxidOnly** - + optional. If true, transaction txid is assumed valid +- **allowTxidOnly** + - optional. If true, transaction txid is assumed valid Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes), [Functions](#functions), [Types](#types), [Enums](#enums), [Variables](#variables) --- + ### Class: BeefParty Extends `Beef` that is used to exchange transaction validity data with more than one external party. @@ -1083,14 +1440,17 @@ The size and redundancy of these Beefs becomes a problem when chained transactio ```ts export class BeefParty extends Beef { - knownTo: Record> = {}; - constructor(parties?: string[]) - isParty(party: string): boolean - addParty(party: string): void - getKnownTxidsForParty(party: string): string[] - getTrimmedBeefForParty(party: string): Beef - addKnownTxidsForParty(party: string, knownTxids: string[]): void - mergeBeefFromParty(party: string, beef: number[] | Beef): void + knownTo: Record> = Object.create(null) as Record< + string, + Record + > + constructor(parties?: string[]) + isParty(party: string): boolean + addParty(party: string): void + getKnownTxidsForParty(party: string): string[] + getTrimmedBeefForParty(party: string): Beef + addKnownTxidsForParty(party: string, knownTxids: string[]): void + mergeBeefFromParty(party: string, beef: number[] | Uint8Array | Beef): void } ``` @@ -1099,13 +1459,13 @@ See also: [Beef](./transaction.md#class-beef) #### Constructor ```ts -constructor(parties?: string[]) +constructor(parties?: string[]) ``` Argument Details -+ **parties** - + Optional array of initial unique party identifiers. +- **parties** + - Optional array of initial unique party identifiers. #### Property knownTo @@ -1113,7 +1473,10 @@ keys are party identifiers. values are records of txids with truthy value for which the party already has validity proof. ```ts -knownTo: Record> = {} +knownTo: Record> = Object.create(null) as Record< + string, + Record +> ``` #### Method addKnownTxidsForParty @@ -1121,26 +1484,26 @@ knownTo: Record> = {} Make note of additional txids "known" to `party`. ```ts -addKnownTxidsForParty(party: string, knownTxids: string[]): void +addKnownTxidsForParty(party: string, knownTxids: string[]): void ``` Argument Details -+ **party** - + unique identifier, added if new. +- **party** + - unique identifier, added if new. #### Method addParty Adds a new unique party identifier to this `BeefParty`. ```ts -addParty(party: string): void +addParty(party: string): void ``` #### Method getKnownTxidsForParty ```ts -getKnownTxidsForParty(party: string): string[] +getKnownTxidsForParty(party: string): string[] ``` Returns @@ -1150,8 +1513,9 @@ Array of txids "known" to `party`. #### Method getTrimmedBeefForParty ```ts -getTrimmedBeefForParty(party: string): Beef +getTrimmedBeefForParty(party: string): Beef ``` + See also: [Beef](./transaction.md#class-beef) Returns @@ -1161,7 +1525,7 @@ trimmed beef of unknown transactions and proofs for `party` #### Method isParty ```ts -isParty(party: string): boolean +isParty(party: string): boolean ``` Returns @@ -1177,13 +1541,15 @@ corresponding to transactions for which `party` has raw transaction and validity proof data. ```ts -mergeBeefFromParty(party: string, beef: number[] | Beef): void +mergeBeefFromParty(party: string, beef: number[] | Uint8Array | Beef): void ``` + See also: [Beef](./transaction.md#class-beef) Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes), [Functions](#functions), [Types](#types), [Enums](#enums), [Variables](#variables) --- + ### Class: BeefTx A single bitcoin transaction associated with a `Beef` validity proof set. @@ -1196,26 +1562,31 @@ which they can merge if necessary to create a valid beef. ```ts export default class BeefTx { - _bumpIndex?: number; - _tx?: Transaction; - _rawTx?: Uint8Array; - _txid?: string; - inputTxids: string[] = []; - isValid?: boolean = undefined; - get bumpIndex(): number | undefined - set bumpIndex(v: number | undefined) - get hasProof(): boolean - get isTxidOnly(): boolean - get txid(): string - get tx(): Transaction | undefined - get rawTx(): number[] | undefined - get rawTxUint8Array(): Uint8Array | undefined - constructor(tx: Transaction | Uint8Array | number[] | string, bumpIndex?: number) - static fromTx(tx: Transaction, bumpIndex?: number): BeefTx - static fromRawTx(rawTx: Uint8Array | number[], bumpIndex?: number): BeefTx - static fromTxid(txid: string, bumpIndex?: number): BeefTx - toWriter(writer: Writer | WriterUint8Array, version: number): void - static fromReader(br: Reader | ReaderUint8Array, version: number): BeefTx + _bumpIndex?: number + _tx?: Transaction + _rawTx?: Uint8Array + _txid?: string + inputTxids: string[] = [] + isValid?: boolean = undefined + get bumpIndex(): number | undefined + set bumpIndex(v: number | undefined) + get hasProof(): boolean + get isTxidOnly(): boolean + get txid(): string + get tx(): Transaction | undefined + get rawTx(): number[] | undefined + get rawTxUint8Array(): Uint8Array | undefined + syncRawTxFromTransaction(): boolean + constructor( + tx: Transaction | Uint8Array | number[] | string, + bumpIndex?: number, + inputTxids?: string[] + ) + static fromTx(tx: Transaction, bumpIndex?: number): BeefTx + static fromRawTx(rawTx: Uint8Array | number[], bumpIndex?: number): BeefTx + static fromTxid(txid: string, bumpIndex?: number): BeefTx + toWriter(writer: Writer | WriterUint8Array, version: number): void + static fromReader(br: Reader | ReaderUint8Array, version: number): BeefTx } ``` @@ -1224,16 +1595,17 @@ See also: [Reader](./primitives.md#class-reader), [ReaderUint8Array](./primitive #### Constructor ```ts -constructor(tx: Transaction | Uint8Array | number[] | string, bumpIndex?: number) +constructor(tx: Transaction | Uint8Array | number[] | string, bumpIndex?: number, inputTxids?: string[]) ``` + See also: [Transaction](./transaction.md#class-transaction) Argument Details -+ **tx** - + If string, must be a valid txid. If `number[]` must be a valid serialized transaction. -+ **bumpIndex** - + If transaction already has a proof in the beef to which it will be added. +- **tx** + - If string, must be a valid txid. If `number[]` must be a valid serialized transaction. +- **bumpIndex** + - If transaction already has a proof in the beef to which it will be added. #### Property isValid @@ -1245,17 +1617,47 @@ Typically set by sorting transactions by proven dependency chains. isValid?: boolean = undefined ``` +#### Method syncRawTxFromTransaction + +Synchronizes a nested transaction after mutation through the normal +Transaction APIs. Returns true when its serialized identity or dependencies +changed. + +```ts +syncRawTxFromTransaction(): boolean +``` + Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes), [Functions](#functions), [Types](#types), [Enums](#enums), [Variables](#variables) --- + +### Class: EvidenceScriptWork + +Internal cache. Only coordinator-owned transactions are bound to its scope below. + +```ts +export class EvidenceScriptWork { + constructor(private readonly limits: Readonly) + clear(): void + inputs(scope: EvidenceScriptScope, params: BdkVerifyScriptsParams, verify: (skipScripts: boolean) => InputResult): InputResult + async batch(scope: EvidenceScriptScope, params: readonly BdkVerifyScriptsParams[], backend: BdkVerifierInterface): Promise +} +``` + +See also: [BdkVerifierInterface](./transaction.md#interface-bdkverifierinterface), [BdkVerifyScriptsParams](./transaction.md#interface-bdkverifyscriptsparams), [EvidenceScriptScope](./transaction.md#interface-evidencescriptscope), [TransactionEvidenceLimits](./transaction.md#interface-transactionevidencelimits), [verify](./compat.md#variable-verify) + +Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes), [Functions](#functions), [Types](#types), [Enums](#enums), [Variables](#variables) + +--- + ### Class: FetchHttpClient Adapter for Node Https module to be used as HttpClient ```ts export class FetchHttpClient implements HttpClient { - constructor(private readonly fetch: Fetch) - async request(url: string, options: HttpClientRequestOptions): Promise> + constructor(private readonly fetch: Fetch) + async request(url: string, options: HttpClientRequestOptions): Promise> } ``` @@ -1264,6 +1666,7 @@ See also: [Fetch](./transaction.md#type-fetch), [HttpClient](./transaction.md#in Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes), [Functions](#functions), [Types](#types), [Enums](#enums), [Variables](#variables) --- + ### Class: LivePolicy Represents a live fee policy that fetches current rates from ARC GorillaPool. @@ -1271,9 +1674,9 @@ Extends SatoshisPerKilobyte to reuse transaction size calculation logic. ```ts export default class LivePolicy extends SatoshisPerKilobyte { - constructor(cacheValidityMs: number = 5 * 60 * 1000) - static getInstance(cacheValidityMs: number = 5 * 60 * 1000): LivePolicy - async computeFee(tx: Transaction): Promise + constructor(cacheValidityMs: number = 5 * 60 * 1000) + static getInstance(cacheValidityMs: number = 5 * 60 * 1000): LivePolicy + override async computeFee(tx: Transaction): Promise } ``` @@ -1284,13 +1687,13 @@ See also: [SatoshisPerKilobyte](./transaction.md#class-satoshisperkilobyte), [Tr Constructs an instance of the live policy fee model. ```ts -constructor(cacheValidityMs: number = 5 * 60 * 1000) +constructor(cacheValidityMs: number = 5 * 60 * 1000) ``` Argument Details -+ **cacheValidityMs** - + How long to cache the fee rate in milliseconds (default: 5 minutes) +- **cacheValidityMs** + - How long to cache the fee rate in milliseconds (default: 5 minutes) #### Method computeFee @@ -1298,8 +1701,9 @@ Computes the fee for a given transaction using the current live rate. Overrides the parent method to use dynamic rate fetching. ```ts -async computeFee(tx: Transaction): Promise +override async computeFee(tx: Transaction): Promise ``` + See also: [Transaction](./transaction.md#class-transaction) Returns @@ -1308,16 +1712,17 @@ The fee in satoshis for the transaction. Argument Details -+ **tx** - + The transaction for which a fee is to be computed. +- **tx** + - The transaction for which a fee is to be computed. #### Method getInstance Gets the singleton instance of LivePolicy to ensure cache sharing across the application. ```ts -static getInstance(cacheValidityMs: number = 5 * 60 * 1000): LivePolicy +static getInstance(cacheValidityMs: number = 5 * 60 * 1000): LivePolicy ``` + See also: [LivePolicy](./transaction.md#class-livepolicy) Returns @@ -1326,12 +1731,13 @@ The singleton LivePolicy instance Argument Details -+ **cacheValidityMs** - + How long to cache the fee rate in milliseconds (default: 5 minutes) +- **cacheValidityMs** + - How long to cache the fee rate in milliseconds (default: 5 minutes) Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes), [Functions](#functions), [Types](#types), [Enums](#enums), [Variables](#variables) --- + ### Class: MerklePath Represents a Merkle Path, which is used to provide a compact proof of inclusion for a @@ -1342,39 +1748,56 @@ Example ```ts // Creating and verifying a Merkle Path -const merklePath = MerklePath.fromHex('...'); -const isValid = merklePath.verify(txid, chainTracker); +const merklePath = MerklePath.fromHex('...') +const isValid = merklePath.verify(txid, chainTracker) ``` ```ts export default class MerklePath { - blockHeight: number; - path: Array>; - static fromHex(hex: string): MerklePath - static fromReader(reader: Reader | ReaderUint8Array, legalOffsetsOnly: boolean = true): MerklePath - static fromBinary(bump: number[] | Uint8Array): MerklePath - static fromCoinbaseTxidAndHeight(txid: string, height: number): MerklePath - constructor(blockHeight: number, path: Array>, legalOffsetsOnly: boolean = true) - toWriter(writer: Writer | WriterUint8Array): void - toBinary(): number[] - toBinaryUint8Array(): Uint8Array - toHex(): string - computeRoot(txid?: string): string - findOrComputeLeaf(height: number, offset: number): MerklePathLeaf | undefined - async verify(txid: string, chainTracker: ChainTracker): Promise - combine(other: MerklePath): void - trim(): void - extract(txids: string[]): MerklePath + blockHeight: number + path: Array< + Array<{ + offset: number + hash?: string + txid?: boolean + duplicate?: boolean + }> + > + static fromHex(hex: string): MerklePath + static fromReader( + reader: Reader | ReaderUint8Array, + legalOffsetsOnly: boolean = true, + validateRoots: boolean = true + ): MerklePath + static fromBinary( + bump: number[] | Uint8Array, + legalOffsetsOnly: boolean = true, + validateRoots: boolean = true + ): MerklePath + static fromCoinbaseTxidAndHeight(txid: string, height: number): MerklePath + constructor( + blockHeight: number, + path: Array< + Array<{ + offset: number + hash?: string + txid?: boolean + duplicate?: boolean + }> + >, + legalOffsetsOnly: boolean = true, + validateRoots: boolean = true + ) + toWriter(writer: Writer | WriterUint8Array): void + toBinary(): number[] + toBinaryUint8Array(): Uint8Array + toHex(): string + computeRoot(txid?: string): string + findOrComputeLeaf(height: number, offset: number): MerklePathLeaf | undefined + async verify(txid: string, chainTracker: ChainTracker): Promise + combine(other: MerklePath): void + trim(): void + extract(txids: string[]): MerklePath } ``` @@ -1385,14 +1808,15 @@ See also: [ChainTracker](./transaction.md#interface-chaintracker), [MerklePathLe Combines this MerklePath with another to create a compound proof. ```ts -combine(other: MerklePath): void +combine(other: MerklePath): void ``` + See also: [MerklePath](./transaction.md#class-merklepath) Argument Details -+ **other** - + Another MerklePath to combine with this path. +- **other** + - Another MerklePath to combine with this path. Throws @@ -1403,7 +1827,7 @@ Throws Computes the Merkle root from the provided transaction ID. ```ts -computeRoot(txid?: string): string +computeRoot(txid?: string): string ``` Returns @@ -1412,8 +1836,8 @@ Returns Argument Details -+ **txid** - + The transaction ID to compute the Merkle root for. If not provided, the root will be computed from an unspecified branch, and not all branches will be validated! +- **txid** + - The transaction ID to compute the Merkle root for. If not provided, the root will be computed from an unspecified branch, and not all branches will be validated! Throws @@ -1431,8 +1855,9 @@ into a single trimmed compound path. The extracted path is verified to compute the same Merkle root as the source. ```ts -extract(txids: string[]): MerklePath +extract(txids: string[]): MerklePath ``` + See also: [MerklePath](./transaction.md#class-merklepath) Returns @@ -1441,8 +1866,8 @@ Returns Argument Details -+ **txids** - + Transaction IDs to extract proofs for. +- **txids** + - Transaction IDs to extract proofs for. Throws @@ -1465,8 +1890,9 @@ Find leaf with `offset` at `height` or compute from level below, recursively. Does not add computed leaves to path. ```ts -findOrComputeLeaf(height: number, offset: number): MerklePathLeaf | undefined +findOrComputeLeaf(height: number, offset: number): MerklePathLeaf | undefined ``` + See also: [MerklePathLeaf](./transaction.md#interface-merklepathleaf) #### Method fromBinary @@ -1474,8 +1900,9 @@ See also: [MerklePathLeaf](./transaction.md#interface-merklepathleaf) Creates a MerklePath instance from a binary array. ```ts -static fromBinary(bump: number[] | Uint8Array): MerklePath +static fromBinary(bump: number[] | Uint8Array, legalOffsetsOnly: boolean = true, validateRoots: boolean = true): MerklePath ``` + See also: [MerklePath](./transaction.md#class-merklepath) Returns @@ -1484,14 +1911,15 @@ Returns Argument Details -+ **bump** - + The binary array representation of the Merkle Path. +- **bump** + - The binary array representation of the Merkle Path. #### Method fromCoinbaseTxidAndHeight ```ts -static fromCoinbaseTxidAndHeight(txid: string, height: number): MerklePath +static fromCoinbaseTxidAndHeight(txid: string, height: number): MerklePath ``` + See also: [MerklePath](./transaction.md#class-merklepath) Returns @@ -1500,18 +1928,19 @@ Returns Argument Details -+ **txid** - + The coinbase txid. -+ **height** - + The height of the block. +- **txid** + - The coinbase txid. +- **height** + - The height of the block. #### Method fromHex Creates a MerklePath instance from a hexadecimal string. ```ts -static fromHex(hex: string): MerklePath +static fromHex(hex: string): MerklePath ``` + See also: [MerklePath](./transaction.md#class-merklepath) Returns @@ -1520,15 +1949,15 @@ Returns Argument Details -+ **hex** - + The hexadecimal string representation of the Merkle Path. +- **hex** + - The hexadecimal string representation of the Merkle Path. #### Method toBinary Converts the MerklePath to a binary array format. ```ts -toBinary(): number[] +toBinary(): number[] ``` Returns @@ -1540,7 +1969,7 @@ Returns Converts the MerklePath to a binary array format. ```ts -toBinaryUint8Array(): Uint8Array +toBinaryUint8Array(): Uint8Array ``` Returns @@ -1552,7 +1981,7 @@ Returns Converts the MerklePath to a hexadecimal string format. ```ts -toHex(): string +toHex(): string ``` Returns @@ -1564,14 +1993,15 @@ Returns Serializes the MerklePath to the writer provided. ```ts -toWriter(writer: Writer | WriterUint8Array): void +toWriter(writer: Writer | WriterUint8Array): void ``` + See also: [Writer](./primitives.md#class-writer), [WriterUint8Array](./primitives.md#class-writeruint8array) Argument Details -+ **writer** - + The writer to which the Merkle Path will be serialized. +- **writer** + - The writer to which the Merkle Path will be serialized. #### Method trim @@ -1580,7 +2010,7 @@ Assumes that at least all required nodes are present. Leaves all levels sorted by increasing offset. ```ts -trim(): void +trim(): void ``` #### Method verify @@ -1588,8 +2018,9 @@ trim(): void Verifies if the given transaction ID is part of the Merkle tree at the specified block height. ```ts -async verify(txid: string, chainTracker: ChainTracker): Promise +async verify(txid: string, chainTracker: ChainTracker): Promise ``` + See also: [ChainTracker](./transaction.md#interface-chaintracker) Returns @@ -1598,22 +2029,23 @@ Returns Argument Details -+ **txid** - + The transaction ID to verify. -+ **chainTracker** - + The ChainTracker instance used to verify the Merkle root. +- **txid** + - The transaction ID to verify. +- **chainTracker** + - The ChainTracker instance used to verify the Merkle root. Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes), [Functions](#functions), [Types](#types), [Enums](#enums), [Variables](#variables) --- + ### Class: NodejsHttpClient Adapter for Node Https module to be used as HttpClient ```ts export class NodejsHttpClient implements HttpClient { - constructor(private readonly https: HttpsNodejs) - async request(url: string, requestOptions: HttpClientRequestOptions): Promise + constructor(private readonly https: HttpsNodejs) + async request(url: string, requestOptions: HttpClientRequestOptions): Promise } ``` @@ -1622,15 +2054,16 @@ See also: [HttpClient](./transaction.md#interface-httpclient), [HttpClientReques Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes), [Functions](#functions), [Types](#types), [Enums](#enums), [Variables](#variables) --- + ### Class: SatoshisPerKilobyte Represents the "satoshis per kilobyte" transaction fee model. ```ts export default class SatoshisPerKilobyte implements FeeModel { - value: number; - constructor(value: number) - async computeFee(tx: Transaction): Promise + value: number + constructor(value: number) + async computeFee(tx: Transaction): Promise } ``` @@ -1641,21 +2074,22 @@ See also: [FeeModel](./transaction.md#interface-feemodel), [Transaction](./trans Constructs an instance of the sat/kb fee model. ```ts -constructor(value: number) +constructor(value: number) ``` Argument Details -+ **value** - + The number of satoshis per kilobyte to charge as a fee. +- **value** + - The number of satoshis per kilobyte to charge as a fee. #### Method computeFee Computes the fee for a given transaction. ```ts -async computeFee(tx: Transaction): Promise +async computeFee(tx: Transaction): Promise ``` + See also: [Transaction](./transaction.md#class-transaction) Returns @@ -1664,12 +2098,13 @@ The fee in satoshis for the transaction, as a BigNumber. Argument Details -+ **tx** - + The transaction for which a fee is to be computed. +- **tx** + - The transaction for which a fee is to be computed. Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes), [Functions](#functions), [Types](#types), [Enums](#enums), [Variables](#variables) --- + ### Class: Transaction Represents a complete Bitcoin transaction. This class encapsulates all the details @@ -1690,80 +2125,113 @@ await tx.broadcast(); ```ts export default class Transaction { - version: number; - inputs: TransactionInput[]; - outputs: TransactionOutput[]; - lockTime: number; - metadata: Record; - merklePath?: MerklePath; - static fromBEEF(beef: number[] | Uint8Array, txid?: string): Transaction - static fromAtomicBEEF(beef: number[] | Uint8Array): Transaction - static fromEF(ef: number[] | Uint8Array): Transaction - static parseScriptOffsets(bin: number[] | Uint8Array): { - inputs: Array<{ - vin: number; - offset: number; - length: number; - }>; - outputs: Array<{ - vout: number; - offset: number; - length: number; - }>; - } - static fromReader(br: Reader | ReaderUint8Array): Transaction - static fromBinary(bin: number[] | Uint8Array): Transaction - static fromHex(hex: string): Transaction - static fromHexEF(hex: string): Transaction - static fromHexBEEF(hex: string, txid?: string): Transaction - constructor(version: number = 1, inputs: TransactionInput[] = [], outputs: TransactionOutput[] = [], lockTime: number = 0, metadata: Record = new Map(), merklePath?: MerklePath) - addInput(input: TransactionInput): void - addOutput(output: TransactionOutput): void - addP2PKHOutput(address: number[] | string, satoshis?: number): void - updateMetadata(metadata: Record): void - async fee(modelOrFee: FeeModel | number = LivePolicy.getInstance(), changeDistribution: "equal" | "random" = "equal"): Promise - getFee(): number - async sign(): Promise - async broadcast(broadcaster: Broadcaster = defaultBroadcaster()): Promise - toBinary(): number[] - toUint8Array(): Uint8Array - toEF(): number[] - toEFUint8Array(): Uint8Array - toEFBinary(): Uint8Array - toHexEF(): string - toHex(): string - toHexBEEF(): string - toHexAtomicBEEF(): string - hash(enc?: "hex"): number[] | string - id(): number[]; - id(enc: "hex"): string; - id(enc?: "hex"): number[] | string - async verify(chainTracker: ChainTracker | "scripts only" = defaultChainTracker(), feeModel?: FeeModel, memoryLimit?: number): Promise - writeSerializedBEEF(writer: Writer | WriterUint8Array, allowPartial?: boolean): void - toBEEF(allowPartial?: boolean): number[] - toBEEFUint8Array(allowPartial?: boolean): Uint8Array - toAtomicBEEF(allowPartial?: boolean): number[] - toAtomicBEEFUint8Array(allowPartial?: boolean): Uint8Array - async completeWithWallet(wallet: WalletInterface, actionDescription?: DescriptionString5to50Bytes, originator?: string, options?: CreateActionOptions): Promise - preimage(inputIndex?: number, signatureScope?: number, subscript?: LockingScript): number[] + version: number + inputs: TransactionInput[] + outputs: TransactionOutput[] + lockTime: number + metadata: Record + merklePath?: MerklePath + getSignatureHashCache(): SignatureHashCache + materializeSourceTXIDs(): void + static fromBEEF(beef: number[] | Uint8Array, txid?: string): Transaction + static fromBEEFView(beef: Uint8Array, txid?: string): Transaction + static fromAtomicBEEF(beef: number[] | Uint8Array): Transaction + static fromAtomicBEEFView(beef: Uint8Array): Transaction + static fromEF(ef: number[] | Uint8Array): Transaction + static parseScriptOffsets(bin: number[] | Uint8Array): { + inputs: Array<{ + vin: number + offset: number + length: number + }> + outputs: Array<{ + vout: number + offset: number + length: number + }> + } + static fromReader(br: Reader | ReaderUint8Array): Transaction + static fromBinary(bin: number[] | Uint8Array): Transaction + static fromBinaryView(bin: Uint8Array): Transaction + static fromHex(hex: string): Transaction + static fromHexEF(hex: string): Transaction + static fromHexBEEF(hex: string, txid?: string): Transaction + constructor( + version: number = 1, + inputs: TransactionInput[] = [], + outputs: TransactionOutput[] = [], + lockTime: number = 0, + metadata: Record = new Map(), + merklePath?: MerklePath + ) + addInput(input: TransactionInput): void + addOutput(output: TransactionOutput): void + addP2PKHOutput(address: number[] | string, satoshis?: number): void + updateMetadata(metadata: Record): void + async fee( + modelOrFee: FeeModel | number = LivePolicy.getInstance(), + changeDistribution: 'equal' | 'random' = 'equal' + ): Promise + getFee(): number + async sign( + options: { + skipExistingSignatures?: boolean + } = {} + ): Promise + async broadcast( + broadcaster: Broadcaster = defaultBroadcaster() + ): Promise + toBinary(): number[] + toUint8Array(): Uint8Array + toEF(): number[] + toEFUint8Array(): Uint8Array + toEFBinary(): Uint8Array + toHexEF(): string + toHex(): string + toHexBEEF(): string + toHexAtomicBEEF(): string + hash(enc?: 'hex'): number[] | string + id(): number[] + id(enc: 'hex'): string + id(enc?: 'hex'): number[] | string + async verify( + chainTracker: ChainTracker | 'scripts only' = defaultChainTracker(), + feeModel?: FeeModel, + memoryLimit?: number, + verifier?: BdkVerifierInterface + ): Promise + writeSerializedBEEF(writer: Writer | WriterUint8Array, allowPartial?: boolean): void + toBEEF(allowPartial?: boolean): number[] + toBEEFUint8Array(allowPartial?: boolean): Uint8Array + toBEEFBytes(allowPartial?: boolean): Uint8Array + toAtomicBEEF(allowPartial?: boolean): number[] + toAtomicBEEFUint8Array(allowPartial?: boolean): Uint8Array + async completeWithWallet( + wallet: WalletInterface, + actionDescription?: DescriptionString5to50Bytes, + originator?: string, + options?: CreateActionOptions + ): Promise + preimage(inputIndex?: number, signatureScope?: number, subscript?: LockingScript): number[] } ``` -See also: [BroadcastFailure](./transaction.md#interface-broadcastfailure), [BroadcastResponse](./transaction.md#interface-broadcastresponse), [Broadcaster](./transaction.md#interface-broadcaster), [ChainTracker](./transaction.md#interface-chaintracker), [CreateActionOptions](./wallet.md#interface-createactionoptions), [DescriptionString5to50Bytes](./wallet.md#type-descriptionstring5to50bytes), [FeeModel](./transaction.md#interface-feemodel), [LivePolicy](./transaction.md#class-livepolicy), [LockingScript](./script.md#class-lockingscript), [MerklePath](./transaction.md#class-merklepath), [Reader](./primitives.md#class-reader), [ReaderUint8Array](./primitives.md#class-readeruint8array), [TransactionInput](./transaction.md#interface-transactioninput), [TransactionOutput](./transaction.md#interface-transactionoutput), [WalletInterface](./wallet.md#interface-walletinterface), [Writer](./primitives.md#class-writer), [WriterUint8Array](./primitives.md#class-writeruint8array), [defaultBroadcaster](./transaction.md#function-defaultbroadcaster), [defaultChainTracker](./transaction.md#function-defaultchaintracker), [sign](./compat.md#variable-sign), [toHex](./primitives.md#variable-tohex), [toUint8Array](./primitives.md#variable-touint8array), [verify](./compat.md#variable-verify) +See also: [BdkVerifierInterface](./transaction.md#interface-bdkverifierinterface), [BroadcastFailure](./transaction.md#interface-broadcastfailure), [BroadcastResponse](./transaction.md#interface-broadcastresponse), [Broadcaster](./transaction.md#interface-broadcaster), [ChainTracker](./transaction.md#interface-chaintracker), [CreateActionOptions](./wallet.md#interface-createactionoptions), [DescriptionString5to50Bytes](./wallet.md#type-descriptionstring5to50bytes), [FeeModel](./transaction.md#interface-feemodel), [LivePolicy](./transaction.md#class-livepolicy), [LockingScript](./script.md#class-lockingscript), [MerklePath](./transaction.md#class-merklepath), [Reader](./primitives.md#class-reader), [ReaderUint8Array](./primitives.md#class-readeruint8array), [SignatureHashCache](./primitives.md#interface-signaturehashcache), [TransactionInput](./transaction.md#interface-transactioninput), [TransactionOutput](./transaction.md#interface-transactionoutput), [WalletInterface](./wallet.md#interface-walletinterface), [Writer](./primitives.md#class-writer), [WriterUint8Array](./primitives.md#class-writeruint8array), [defaultBroadcaster](./transaction.md#function-defaultbroadcaster), [defaultChainTracker](./transaction.md#function-defaultchaintracker), [sign](./compat.md#variable-sign), [toHex](./primitives.md#variable-tohex), [toUint8Array](./primitives.md#variable-touint8array), [verify](./compat.md#variable-verify) #### Method addInput Adds a new input to the transaction. ```ts -addInput(input: TransactionInput): void +addInput(input: TransactionInput): void ``` + See also: [TransactionInput](./transaction.md#interface-transactioninput) Argument Details -+ **input** - + The TransactionInput object to add to the transaction. +- **input** + - The TransactionInput object to add to the transaction. Throws @@ -1774,37 +2242,39 @@ Throws Adds a new output to the transaction. ```ts -addOutput(output: TransactionOutput): void +addOutput(output: TransactionOutput): void ``` + See also: [TransactionOutput](./transaction.md#interface-transactionoutput) Argument Details -+ **output** - + The TransactionOutput object to add to the transaction. +- **output** + - The TransactionOutput object to add to the transaction. #### Method addP2PKHOutput Adds a new P2PKH output to the transaction. ```ts -addP2PKHOutput(address: number[] | string, satoshis?: number): void +addP2PKHOutput(address: number[] | string, satoshis?: number): void ``` Argument Details -+ **address** - + The P2PKH address of the output. -+ **satoshis** - + The number of satoshis to send to the address - if not provided, the output is considered a change output. +- **address** + - The P2PKH address of the output. +- **satoshis** + - The number of satoshis to send to the address - if not provided, the output is considered a change output. #### Method broadcast Broadcasts a transaction. ```ts -async broadcast(broadcaster: Broadcaster = defaultBroadcaster()): Promise +async broadcast(broadcaster: Broadcaster = defaultBroadcaster()): Promise ``` + See also: [BroadcastFailure](./transaction.md#interface-broadcastfailure), [BroadcastResponse](./transaction.md#interface-broadcastresponse), [Broadcaster](./transaction.md#interface-broadcaster), [defaultBroadcaster](./transaction.md#function-defaultbroadcaster) Returns @@ -1813,8 +2283,8 @@ A BroadcastResponse or BroadcastFailure from the Broadcaster Argument Details -+ **broadcaster** - + The Broadcaster instance wwhere the transaction will be sent +- **broadcaster** + - The Broadcaster instance wwhere the transaction will be sent #### Method completeWithWallet @@ -1824,20 +2294,21 @@ transaction into a format that can be processed by the wallet, and then updates this transaction object with the result from the wallet. ```ts -async completeWithWallet(wallet: WalletInterface, actionDescription?: DescriptionString5to50Bytes, originator?: string, options?: CreateActionOptions): Promise +async completeWithWallet(wallet: WalletInterface, actionDescription?: DescriptionString5to50Bytes, originator?: string, options?: CreateActionOptions): Promise ``` + See also: [CreateActionOptions](./wallet.md#interface-createactionoptions), [DescriptionString5to50Bytes](./wallet.md#type-descriptionstring5to50bytes), [WalletInterface](./wallet.md#interface-walletinterface) Argument Details -+ **wallet** - + The BRC-100 compliant wallet to use for completing the transaction -+ **actionDescription** - + Optional description for the action -+ **originator** - + Optional originator domain name -+ **options** - + Optional settings for transaction creation (e.g., acceptDelayedBroadcast, trustSelf, noSend, etc.) +- **wallet** + - The BRC-100 compliant wallet to use for completing the transaction +- **actionDescription** + - Optional description for the action +- **originator** + - Optional originator domain name +- **options** + - Optional settings for transaction creation (e.g., acceptDelayedBroadcast, trustSelf, noSend, etc.) #### Method fee @@ -1846,17 +2317,18 @@ If no fee model is provided, uses a LivePolicy fee model that fetches current ra If fee is a number, the transaction uses that value as fee. ```ts -async fee(modelOrFee: FeeModel | number = LivePolicy.getInstance(), changeDistribution: "equal" | "random" = "equal"): Promise +async fee(modelOrFee: FeeModel | number = LivePolicy.getInstance(), changeDistribution: "equal" | "random" = "equal"): Promise ``` + See also: [FeeModel](./transaction.md#interface-feemodel), [LivePolicy](./transaction.md#class-livepolicy) Argument Details -+ **modelOrFee** - + The initialized fee model to use or fixed fee for the transaction -+ **changeDistribution** - + Specifies how the change should be distributed -amongst the change outputs +- **modelOrFee** + - The initialized fee model to use or fixed fee for the transaction +- **changeDistribution** + - Specifies how the change should be distributed + amongst the change outputs #### Method fromAtomicBEEF @@ -1864,8 +2336,9 @@ Creates a new transaction from an Atomic BEEF (BRC-95) structure. Extracts the subject transaction and supporting merkle path and source transactions contained in the BEEF data ```ts -static fromAtomicBEEF(beef: number[] | Uint8Array): Transaction +static fromAtomicBEEF(beef: number[] | Uint8Array): Transaction ``` + See also: [Transaction](./transaction.md#class-transaction) Returns @@ -1874,8 +2347,8 @@ The subject transaction, linked to its associated inputs populated with merkle p Argument Details -+ **beef** - + A binary representation of an Atomic BEEF structure. +- **beef** + - A binary representation of an Atomic BEEF structure. #### Method fromBEEF @@ -1885,8 +2358,9 @@ If the TXID is provided but not found in the BEEF data, an error will be thrown. If no TXID is provided, the last transaction in the BEEF data is returned, or the atomic txid. ```ts -static fromBEEF(beef: number[] | Uint8Array, txid?: string): Transaction +static fromBEEF(beef: number[] | Uint8Array, txid?: string): Transaction ``` + See also: [Transaction](./transaction.md#class-transaction) Returns @@ -1895,18 +2369,19 @@ An anchored transaction, linked to its associated inputs populated with merkle p Argument Details -+ **beef** - + A binary representation of transactions in BEEF format. -+ **txid** - + Optional TXID of the transaction to retrieve from the BEEF data. +- **beef** + - A binary representation of transactions in BEEF format. +- **txid** + - Optional TXID of the transaction to retrieve from the BEEF data. #### Method fromBinary Creates a Transaction instance from a binary array. ```ts -static fromBinary(bin: number[] | Uint8Array): Transaction +static fromBinary(bin: number[] | Uint8Array): Transaction ``` + See also: [Transaction](./transaction.md#class-transaction) Returns @@ -1915,16 +2390,28 @@ Returns Argument Details -+ **bin** - + The binary array representation of the transaction. +- **bin** + - The binary array representation of the transaction. + +#### Method fromBinaryView + +Parses a transaction while retaining zero-copy views over `bin` for the raw +transaction and its scripts. The caller must not mutate `bin`. + +```ts +static fromBinaryView(bin: Uint8Array): Transaction +``` + +See also: [Transaction](./transaction.md#class-transaction) #### Method fromEF Creates a new transaction, linked to its inputs and their associated merkle paths, from a EF (BRC-30) structure. ```ts -static fromEF(ef: number[] | Uint8Array): Transaction +static fromEF(ef: number[] | Uint8Array): Transaction ``` + See also: [Transaction](./transaction.md#class-transaction) Returns @@ -1933,16 +2420,17 @@ An extended transaction, linked to its associated inputs by locking script and s Argument Details -+ **ef** - + A binary representation of a transaction in EF format. +- **ef** + - A binary representation of a transaction in EF format. #### Method fromHex Creates a Transaction instance from a hexadecimal string. ```ts -static fromHex(hex: string): Transaction +static fromHex(hex: string): Transaction ``` + See also: [Transaction](./transaction.md#class-transaction) Returns @@ -1951,8 +2439,8 @@ Returns Argument Details -+ **hex** - + The hexadecimal string representation of the transaction. +- **hex** + - The hexadecimal string representation of the transaction. #### Method fromHexBEEF @@ -1962,8 +2450,9 @@ If the TXID is provided but not found in the BEEF data, an error will be thrown. If no TXID is provided, the last transaction in the BEEF data is returned. ```ts -static fromHexBEEF(hex: string, txid?: string): Transaction +static fromHexBEEF(hex: string, txid?: string): Transaction ``` + See also: [Transaction](./transaction.md#class-transaction) Returns @@ -1972,18 +2461,19 @@ Returns Argument Details -+ **hex** - + The hexadecimal string representation of the transaction BEEF. -+ **txid** - + Optional TXID of the transaction to retrieve from the BEEF data. +- **hex** + - The hexadecimal string representation of the transaction BEEF. +- **txid** + - Optional TXID of the transaction to retrieve from the BEEF data. #### Method fromHexEF Creates a Transaction instance from a hexadecimal string encoded EF. ```ts -static fromHexEF(hex: string): Transaction +static fromHexEF(hex: string): Transaction ``` + See also: [Transaction](./transaction.md#class-transaction) Returns @@ -1992,27 +2482,38 @@ Returns Argument Details -+ **hex** - + The hexadecimal string representation of the transaction EF. +- **hex** + - The hexadecimal string representation of the transaction EF. #### Method getFee Utility method that returns the current fee based on inputs and outputs ```ts -getFee(): number +getFee(): number ``` Returns The current transaction fee +#### Method getSignatureHashCache + +Returns the transaction-wide signature hash cache active during signing. +Callers outside a signing operation receive an isolated cache. + +```ts +getSignatureHashCache(): SignatureHashCache +``` + +See also: [SignatureHashCache](./primitives.md#interface-signaturehashcache) + #### Method hash Calculates the transaction's hash. ```ts -hash(enc?: "hex"): number[] | string +hash(enc?: "hex"): number[] | string ``` Returns @@ -2021,8 +2522,8 @@ Returns Argument Details -+ **enc** - + The encoding to use for the hash. If 'hex', returns a hexadecimal string; otherwise returns a binary array. +- **enc** + - The encoding to use for the hash. If 'hex', returns a hexadecimal string; otherwise returns a binary array. #### Method id @@ -2050,15 +2551,15 @@ Returns Argument Details -+ **enc** - + The encoding to use for the ID. If 'hex', returns a hexadecimal string. +- **enc** + - The encoding to use for the ID. If 'hex', returns a hexadecimal string. #### Method id Calculates the transaction's ID. ```ts -id(enc?: "hex"): number[] | string +id(enc?: "hex"): number[] | string ``` Returns @@ -2067,8 +2568,17 @@ Returns Argument Details -+ **enc** - + The encoding to use for the ID. If 'hex', returns a hexadecimal string; otherwise returns a binary array. +- **enc** + - The encoding to use for the ID. If 'hex', returns a hexadecimal string; otherwise returns a binary array. + +#### Method materializeSourceTXIDs + +Iteratively materializes source transaction IDs so deep spend chains do not +recurse through `hash()` while serializing their parents. + +```ts +materializeSourceTXIDs(): void +``` #### Method parseScriptOffsets @@ -2092,7 +2602,7 @@ static parseScriptOffsets(bin: number[] | Uint8Array): { offset: number; length: number; }>; -} +} ``` Returns @@ -2103,16 +2613,17 @@ outputs: { vout: number, offset: number, length: number }[] Argument Details -+ **bin** - + binary transaction data +- **bin** + - binary transaction data #### Method preimage Returns the formatted preimage of a transaction for the requested input index, signature scope (default SIGHASH_FORKID | SIGHASH_ALL), and optional subscript. ```ts -preimage(inputIndex?: number, signatureScope?: number, subscript?: LockingScript): number[] +preimage(inputIndex?: number, signatureScope?: number, subscript?: LockingScript): number[] ``` + See also: [LockingScript](./script.md#class-lockingscript) Returns @@ -2121,21 +2632,28 @@ The formatted preimage Argument Details -+ **inputIndex** - + The index of the input to generate the preimage for -+ **signatureScope** - + The signature scope to use for the preimage -+ **subscript** - + The subscript to use for the preimage (optional) +- **inputIndex** + - The index of the input to generate the preimage for +- **signatureScope** + - The signature scope to use for the preimage +- **subscript** + - The subscript to use for the preimage (optional) #### Method sign Signs a transaction, hydrating all its unlocking scripts based on the provided script templates where they are available. ```ts -async sign(): Promise +async sign(options: { + skipExistingSignatures?: boolean; +} = {}): Promise ``` +Argument Details + +- **options** + - Signing behavior. Set `skipExistingSignatures` to preserve inputs that already have an unlocking script. + #### Method toAtomicBEEF Serializes this transaction and its inputs into the Atomic BEEF (BRC-95) format. @@ -2144,7 +2662,7 @@ and then the BEEF data containing only the subject transaction and its dependenc This format ensures that the BEEF structure is atomic and contains no unrelated transactions. ```ts -toAtomicBEEF(allowPartial?: boolean): number[] +toAtomicBEEF(allowPartial?: boolean): number[] ``` Returns @@ -2153,8 +2671,8 @@ Returns Argument Details -+ **allowPartial** - + If true, error will not be thrown if there are any missing sourceTransactions. +- **allowPartial** + - If true, error will not be thrown if there are any missing sourceTransactions. Throws @@ -2168,7 +2686,7 @@ and then the BEEF data containing only the subject transaction and its dependenc This format ensures that the BEEF structure is atomic and contains no unrelated transactions. ```ts -toAtomicBEEFUint8Array(allowPartial?: boolean): Uint8Array +toAtomicBEEFUint8Array(allowPartial?: boolean): Uint8Array ``` Returns @@ -2177,8 +2695,8 @@ Returns Argument Details -+ **allowPartial** - + If true, error will not be thrown if there are any missing sourceTransactions. +- **allowPartial** + - If true, error will not be thrown if there are any missing sourceTransactions. Throws @@ -2189,7 +2707,7 @@ Error if there are any missing sourceTransactions unless `allowPartial` is true. Serializes this transaction, together with its inputs and the respective merkle proofs, into the BEEF (BRC-62) format. This enables efficient verification of its compliance with the rules of SPV. ```ts -toBEEF(allowPartial?: boolean): number[] +toBEEF(allowPartial?: boolean): number[] ``` Returns @@ -2198,19 +2716,27 @@ The serialized BEEF structure Argument Details -+ **allowPartial** - + If true, error will not be thrown if there are any missing sourceTransactions. +- **allowPartial** + - If true, error will not be thrown if there are any missing sourceTransactions. Throws Error if there are any missing sourceTransactions unless `allowPartial` is true. +#### Method toBEEFBytes + +Serializes BEEF to a real typed byte array. + +```ts +toBEEFBytes(allowPartial?: boolean): Uint8Array +``` + #### Method toBEEFUint8Array Serializes this transaction, together with its inputs and the respective merkle proofs, into the BEEF (BRC-62) format. This enables efficient verification of its compliance with the rules of SPV. ```ts -toBEEFUint8Array(allowPartial?: boolean): Uint8Array +toBEEFUint8Array(allowPartial?: boolean): Uint8Array ``` Returns @@ -2219,8 +2745,8 @@ The serialized BEEF structure Argument Details -+ **allowPartial** - + If true, error will not be thrown if there are any missing sourceTransactions. +- **allowPartial** + - If true, error will not be thrown if there are any missing sourceTransactions. Throws @@ -2231,7 +2757,7 @@ Error if there are any missing sourceTransactions unless `allowPartial` is true. Converts the transaction to a binary array format. ```ts -toBinary(): number[] +toBinary(): number[] ``` Returns @@ -2243,45 +2769,43 @@ Returns Converts the transaction to a BRC-30 EF format. ```ts -toEF(): number[] +toEF(): number[] ``` Returns - The BRC-30 EF representation of the transaction. -#### Method toEFUint8Array +#### Method toEFBinary -Converts the transaction to a BRC-30 EF format. +Converts the transaction to a memoized BRC-30 EF byte array. ```ts -toEFUint8Array(): Uint8Array +toEFBinary(): Uint8Array ``` Returns -- The BRC-30 EF representation of the transaction. +The cached BRC-30 EF representation. -#### Method toEFBinary +#### Method toEFUint8Array -Converts the transaction to a memoized BRC-30 EF byte array. The returned view -is reused until transaction or referenced source-output serialization state -changes and must be treated as immutable; call `.slice()` for a mutable copy. +Converts the transaction to a BRC-30 EF format. ```ts -toEFBinary(): Uint8Array +toEFUint8Array(): Uint8Array ``` Returns -The cached BRC-30 EF representation. +- The BRC-30 EF representation of the transaction. #### Method toHex Converts the transaction to a hexadecimal string format. ```ts -toHex(): string +toHex(): string ``` Returns @@ -2293,7 +2817,7 @@ Returns Converts the transaction to a hexadecimal string Atomic BEEF. ```ts -toHexAtomicBEEF(): string +toHexAtomicBEEF(): string ``` Returns @@ -2305,7 +2829,7 @@ Returns Converts the transaction to a hexadecimal string BEEF. ```ts -toHexBEEF(): string +toHexBEEF(): string ``` Returns @@ -2317,7 +2841,7 @@ Returns Converts the transaction to a hexadecimal string EF. ```ts -toHexEF(): string +toHexEF(): string ``` Returns @@ -2329,22 +2853,23 @@ Returns Updates the transaction's metadata. ```ts -updateMetadata(metadata: Record): void +updateMetadata(metadata: Record): void ``` Argument Details -+ **metadata** - + The metadata object to merge into the existing metadata. +- **metadata** + - The metadata object to merge into the existing metadata. #### Method verify Verifies the legitimacy of the Bitcoin transaction according to the rules of SPV by ensuring all the input transactions link back to valid block headers, the chain of spends for all inputs are valid, and the sum of inputs is not less than the sum of outputs. ```ts -async verify(chainTracker: ChainTracker | "scripts only" = defaultChainTracker(), feeModel?: FeeModel, memoryLimit?: number): Promise +async verify(chainTracker: ChainTracker | "scripts only" = defaultChainTracker(), feeModel?: FeeModel, memoryLimit?: number, verifier?: BdkVerifierInterface): Promise ``` -See also: [ChainTracker](./transaction.md#interface-chaintracker), [FeeModel](./transaction.md#interface-feemodel), [defaultChainTracker](./transaction.md#function-defaultchaintracker) + +See also: [BdkVerifierInterface](./transaction.md#interface-bdkverifierinterface), [ChainTracker](./transaction.md#interface-chaintracker), [FeeModel](./transaction.md#interface-feemodel), [defaultChainTracker](./transaction.md#function-defaultchaintracker) Returns @@ -2352,13 +2877,16 @@ Whether the transaction is valid according to the rules of SPV. Argument Details -+ **chainTracker** - + An instance of ChainTracker, a Bitcoin block header tracker. If the value is set to 'scripts only', headers will not be verified. If not provided then the default chain tracker will be used. -+ **feeModel** - + An instance of FeeModel, a fee model to use for fee calculation. If not provided then the default fee model will be used. -+ **memoryLimit** - + Optional caller-supplied local interpreter memory budget. If omitted, -post-Genesis validation does not impose an arbitrary SDK memory cap. +- **chainTracker** + - An instance of ChainTracker, a Bitcoin block header tracker. If the value is set to 'scripts only', headers will not be verified. If not provided then the default chain tracker will be used. +- **feeModel** + - An instance of FeeModel, a fee model to use for fee calculation. If not provided then the default fee model will be used. +- **memoryLimit** + - Optional caller-supplied local script-interpreter + memory budget. If omitted, post-Genesis validation does not impose an + arbitrary SDK memory cap. +- **verifier** + - An optional asynchronous script backend. Adaptive backends may decline before execution to preserve the JavaScript path. Example @@ -2371,8 +2899,9 @@ tx.verify(new WhatsOnChain(), LivePolicy.getInstance()) Serializes this transaction, together with its inputs and the respective merkle proofs, into the BEEF (BRC-62) format. This enables efficient verification of its compliance with the rules of SPV. ```ts -writeSerializedBEEF(writer: Writer | WriterUint8Array, allowPartial?: boolean): void +writeSerializedBEEF(writer: Writer | WriterUint8Array, allowPartial?: boolean): void ``` + See also: [Writer](./primitives.md#class-writer), [WriterUint8Array](./primitives.md#class-writeruint8array) Returns @@ -2381,10 +2910,10 @@ The serialized BEEF structure Argument Details -+ **writer** - + The writer to serialize to -+ **allowPartial** - + If true, error will not be thrown if there are any missing sourceTransactions. +- **writer** + - The writer to serialize to +- **allowPartial** + - If true, error will not be thrown if there are any missing sourceTransactions. Throws @@ -2393,20 +2922,110 @@ Error if there are any missing sourceTransactions unless `allowPartial` is true. Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes), [Functions](#functions), [Types](#types), [Enums](#enums), [Variables](#variables) --- + +### Class: TransactionEvidenceCoordinator + +Bounded, process-local transaction evidence work sharing. This is independent of +lookup services, certificates and trust ratings. Positive reuse always checks +canonical anchors again; ChainTracker remains the caller's trusted chain source. +Synchronous parsing/script execution is byte/memory bounded, not preemptible. + +```ts +export class TransactionEvidenceCoordinator { + readonly limits: Readonly + constructor(options: TransactionEvidenceCoordinatorOptions) + setContext(context: TransactionEvidenceContext): void + dispose(): void + async verify( + evidence: TransactionEvidence, + options: { + signal?: AbortSignal + } = {} + ): Promise + getStats(): { + pendingTransactions: number + consumers: number + cachedTransactions: number + retainedBytes: number + pendingChainCalls: number + activeAttempts: number + } +} +``` + +See also: [TransactionEvidence](./transaction.md#interface-transactionevidence), [TransactionEvidenceContext](./transaction.md#interface-transactionevidencecontext), [TransactionEvidenceCoordinatorOptions](./transaction.md#interface-transactionevidencecoordinatoroptions), [TransactionEvidenceLimits](./transaction.md#interface-transactionevidencelimits), [VerifiedTransactionOutput](./transaction.md#interface-verifiedtransactionoutput), [verify](./compat.md#variable-verify) + +#### Method getStats + +Payload-free local diagnostics; pending calls include abandoned, non-abortable I/O. + +```ts +getStats(): { + pendingTransactions: number; + consumers: number; + cachedTransactions: number; + retainedBytes: number; + pendingChainCalls: number; + activeAttempts: number; +} +``` + +#### Method setContext + +Explicit session/network/policy change. Stale in-flight work cannot publish. + +```ts +setContext(context: TransactionEvidenceContext): void +``` + +See also: [TransactionEvidenceContext](./transaction.md#interface-transactionevidencecontext) + +#### Method verify + +Snapshot intake is synchronous up to the returned Promise's first await. + +```ts +async verify(evidence: TransactionEvidence, options: { + signal?: AbortSignal; +} = {}): Promise +``` + +See also: [TransactionEvidence](./transaction.md#interface-transactionevidence), [VerifiedTransactionOutput](./transaction.md#interface-verifiedtransactionoutput) + +Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes), [Functions](#functions), [Types](#types), [Enums](#enums), [Variables](#variables) + +--- + +### Class: TransactionEvidenceError + +Bounded, payload-free outcome; rejected receipts never permanently reject a txid. + +```ts +export class TransactionEvidenceError extends Error { + constructor(public readonly code: TransactionEvidenceErrorCode) +} +``` + +See also: [TransactionEvidenceErrorCode](./transaction.md#type-transactionevidenceerrorcode) + +Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes), [Functions](#functions), [Types](#types), [Enums](#enums), [Variables](#variables) + +--- + ### Class: WhatsOnChain Represents a chain tracker based on What's On Chain . ```ts export default class WhatsOnChain implements ChainTracker { - readonly network: string; - readonly apiKey: string; - protected readonly URL: string; - protected readonly httpClient: HttpClient; - constructor(network: "main" | "test" | "stn" = "main", config: WhatsOnChainConfig = {}) - async isValidRootForHeight(root: string, height: number): Promise - async currentHeight(): Promise - protected getHttpHeaders(): Record + readonly network: string + readonly apiKey: string + protected readonly URL: string + protected readonly httpClient: HttpClient + constructor(network: 'main' | 'test' | 'stn' = 'main', config: WhatsOnChainConfig = {}) + async isValidRootForHeight(root: string, height: number): Promise + async currentHeight(): Promise + protected getHttpHeaders(): Record } ``` @@ -2417,30 +3036,48 @@ See also: [ChainTracker](./transaction.md#interface-chaintracker), [HttpClient]( Constructs an instance of the WhatsOnChain ChainTracker. ```ts -constructor(network: "main" | "test" | "stn" = "main", config: WhatsOnChainConfig = {}) +constructor(network: "main" | "test" | "stn" = "main", config: WhatsOnChainConfig = {}) ``` + See also: [WhatsOnChainConfig](./transaction.md#interface-whatsonchainconfig) Argument Details -+ **network** - + The BSV network to use when calling the WhatsOnChain API. -+ **config** - + Configuration options for the WhatsOnChain ChainTracker. +- **network** + - The BSV network to use when calling the WhatsOnChain API. +- **config** + - Configuration options for the WhatsOnChain ChainTracker. Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes), [Functions](#functions), [Types](#types), [Enums](#enums), [Variables](#variables) --- + ## Functions -| | -| --- | -| [defaultBroadcaster](#function-defaultbroadcaster) | -| [defaultChainTracker](#function-defaultchaintracker) | -| [defaultHttpClient](#function-defaulthttpclient) | -| [executeNodejsRequest](#function-executenodejsrequest) | -| [isBroadcastFailure](#function-isbroadcastfailure) | -| [isBroadcastResponse](#function-isbroadcastresponse) | +| | | +| ------------------------------------------------------------ | ------------------------------------------------------------------------------------ | +| [assertEvidenceUnchanged](#function-assertevidenceunchanged) | [isBroadcastResponse](#function-isbroadcastresponse) | +| [defaultBroadcaster](#function-defaultbroadcaster) | [parseEvidence](#function-parseevidence) | +| [defaultChainTracker](#function-defaultchaintracker) | [registerScriptVerificationBackend](#function-registerscriptverificationbackend) | +| [defaultHttpClient](#function-defaulthttpclient) | [scopedScriptBackend](#function-scopedscriptbackend) | +| [evidenceError](#function-evidenceerror) | [scriptVerificationBackend](#function-scriptverificationbackend) | +| [evidenceScriptScope](#function-evidencescriptscope) | [unregisterScriptVerificationBackend](#function-unregisterscriptverificationbackend) | +| [executeNodejsRequest](#function-executenodejsrequest) | [withEvidenceScriptWork](#function-withevidencescriptwork) | +| [isBroadcastFailure](#function-isbroadcastfailure) | | + +Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes), [Functions](#functions), [Types](#types), [Enums](#enums), [Variables](#variables) + +--- + +### Function: assertEvidenceUnchanged + +Fence all owned transaction/proof bytes, including backend readiness callbacks. + +```ts +export function assertEvidenceUnchanged(candidate: EvidenceCandidate): void +``` + +See also: [EvidenceCandidate](./transaction.md#interface-evidencecandidate) Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes), [Functions](#functions), [Types](#types), [Enums](#enums), [Variables](#variables) @@ -2449,7 +3086,7 @@ Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes), [Functions]( ### Function: defaultBroadcaster ```ts -export function defaultBroadcaster(isTestnet: boolean = false, config: ArcConfig = {}): Broadcaster +export function defaultBroadcaster(isTestnet: boolean = false, config: ArcConfig = {}): Broadcaster ``` See also: [ArcConfig](./transaction.md#interface-arcconfig), [Broadcaster](./transaction.md#interface-broadcaster) @@ -2457,10 +3094,11 @@ See also: [ArcConfig](./transaction.md#interface-arcconfig), [Broadcaster](./tra Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes), [Functions](#functions), [Types](#types), [Enums](#enums), [Variables](#variables) --- + ### Function: defaultChainTracker ```ts -export function defaultChainTracker(): ChainTracker +export function defaultChainTracker(): ChainTracker ``` See also: [ChainTracker](./transaction.md#interface-chaintracker) @@ -2468,14 +3106,15 @@ See also: [ChainTracker](./transaction.md#interface-chaintracker) Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes), [Functions](#functions), [Types](#types), [Enums](#enums), [Variables](#variables) --- + ### Function: defaultHttpClient Returns a default HttpClient implementation based on the environment that it is run on. -This method will attempt to use `window.fetch` if available (in browser environments). -If running in a Node environment, it falls back to using the Node `https` module +This method will attempt to use `window.fetch` if available (in browser environments), +then `globalThis.fetch` (service workers, Deno, Node 18+), then the Node `https` module. ```ts -export function defaultHttpClient(): HttpClient +export function defaultHttpClient(): HttpClient ``` See also: [HttpClient](./transaction.md#interface-httpclient) @@ -2483,37 +3122,70 @@ See also: [HttpClient](./transaction.md#interface-httpclient) Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes), [Functions](#functions), [Types](#types), [Enums](#enums), [Variables](#variables) --- + +### Function: evidenceError + +Keep bounded-work outcomes distinct without exposing errors containing evidence. + +```ts +export function evidenceError(error: unknown): TransactionEvidenceError +``` + +See also: [TransactionEvidenceError](./transaction.md#class-transactionevidenceerror) + +Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes), [Functions](#functions), [Types](#types), [Enums](#enums), [Variables](#variables) + +--- + +### Function: evidenceScriptScope + +```ts +export function evidenceScriptScope(tx: Transaction): EvidenceScriptScope | undefined +``` + +See also: [EvidenceScriptScope](./transaction.md#interface-evidencescriptscope), [Transaction](./transaction.md#class-transaction) + +Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes), [Functions](#functions), [Types](#types), [Enums](#enums), [Variables](#variables) + +--- + ### Function: executeNodejsRequest Shared implementation for handling Node.js HTTP requests. Used by both NodejsHttpClient and BinaryNodejsHttpClient. ```ts -export function executeNodejsRequest(https: HttpsModuleLike, url: string, requestOptions: HttpClientRequestOptions, serializeData: (data: any) => any): Promise +export function executeNodejsRequest( + https: HttpsModuleLike, + url: string, + requestOptions: HttpClientRequestOptions, + serializeData: (data: any) => any +): Promise ``` See also: [HttpClientRequestOptions](./transaction.md#interface-httpclientrequestoptions), [HttpClientResponse](./transaction.md#type-httpclientresponse), [HttpsModuleLike](./transaction.md#interface-httpsmodulelike) Argument Details -+ **https** - + The Node.js https module (or compatible) -+ **url** - + The URL to make the request to -+ **requestOptions** - + The request configuration -+ **serializeData** - + Function to serialize the request data for writing +- **https** + - The Node.js https module (or compatible) +- **url** + - The URL to make the request to +- **requestOptions** + - The request configuration +- **serializeData** + - Function to serialize the request data for writing Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes), [Functions](#functions), [Types](#types), [Enums](#enums), [Variables](#variables) --- + ### Function: isBroadcastFailure Convenience type guard for response from `Broadcaster.broadcast` ```ts -export function isBroadcastFailure(r: BroadcastResponse | BroadcastFailure): r is BroadcastFailure +export function isBroadcastFailure(r: BroadcastResponse | BroadcastFailure): r is BroadcastFailure ``` See also: [BroadcastFailure](./transaction.md#interface-broadcastfailure), [BroadcastResponse](./transaction.md#interface-broadcastresponse) @@ -2521,12 +3193,13 @@ See also: [BroadcastFailure](./transaction.md#interface-broadcastfailure), [Broa Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes), [Functions](#functions), [Types](#types), [Enums](#enums), [Variables](#variables) --- + ### Function: isBroadcastResponse Convenience type guard for response from `Broadcaster.broadcast` ```ts -export function isBroadcastResponse(r: BroadcastResponse | BroadcastFailure): r is BroadcastResponse +export function isBroadcastResponse(r: BroadcastResponse | BroadcastFailure): r is BroadcastResponse ``` See also: [BroadcastFailure](./transaction.md#interface-broadcastfailure), [BroadcastResponse](./transaction.md#interface-broadcastresponse) @@ -2534,12 +3207,103 @@ See also: [BroadcastFailure](./transaction.md#interface-broadcastfailure), [Broa Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes), [Functions](#functions), [Types](#types), [Enums](#enums), [Variables](#variables) --- + +### Function: parseEvidence + +```ts +export function parseEvidence( + evidence: TransactionEvidence, + limits: TransactionEvidenceLimits +): EvidenceCandidate +``` + +See also: [EvidenceCandidate](./transaction.md#interface-evidencecandidate), [TransactionEvidence](./transaction.md#interface-transactionevidence), [TransactionEvidenceLimits](./transaction.md#interface-transactionevidencelimits) + +Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes), [Functions](#functions), [Types](#types), [Enums](#enums), [Variables](#variables) + +--- + +### Function: registerScriptVerificationBackend + +Installs a process/page-wide optional script backend. + +```ts +export function registerScriptVerificationBackend(backend: ScriptVerificationBackend): void +``` + +See also: [ScriptVerificationBackend](./transaction.md#type-scriptverificationbackend) + +Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes), [Functions](#functions), [Types](#types), [Enums](#enums), [Variables](#variables) + +--- + +### Function: scopedScriptBackend + +```ts +export function scopedScriptBackend( + scope: EvidenceScriptScope, + backend: BdkVerifierInterface +): BdkVerifierInterface +``` + +See also: [BdkVerifierInterface](./transaction.md#interface-bdkverifierinterface), [EvidenceScriptScope](./transaction.md#interface-evidencescriptscope) + +Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes), [Functions](#functions), [Types](#types), [Enums](#enums), [Variables](#variables) + +--- + +### Function: scriptVerificationBackend + +Returns the currently registered optional script backend, if any. + +```ts +export function scriptVerificationBackend(): ScriptVerificationBackend | undefined +``` + +See also: [ScriptVerificationBackend](./transaction.md#type-scriptverificationbackend) + +Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes), [Functions](#functions), [Types](#types), [Enums](#enums), [Variables](#variables) + +--- + +### Function: unregisterScriptVerificationBackend + +Removes `backend` if it is still the active optional implementation. + +```ts +export function unregisterScriptVerificationBackend(backend: ScriptVerificationBackend): void +``` + +See also: [ScriptVerificationBackend](./transaction.md#type-scriptverificationbackend) + +Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes), [Functions](#functions), [Types](#types), [Enums](#enums), [Variables](#variables) + +--- + +### Function: withEvidenceScriptWork + +```ts +export async function withEvidenceScriptWork( + tx: Transaction, + scope: EvidenceScriptScope, + verify: () => Promise +): Promise +``` + +See also: [EvidenceScriptScope](./transaction.md#interface-evidencescriptscope), [Transaction](./transaction.md#class-transaction), [verify](./compat.md#variable-verify) + +Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes), [Functions](#functions), [Types](#types), [Enums](#enums), [Variables](#variables) + +--- + ## Types -| | -| --- | -| [Fetch](#type-fetch) | -| [HttpClientResponse](#type-httpclientresponse) | +| | +| ------------------------------------------------------------------ | +| [Fetch](#type-fetch) | +| [HttpClientResponse](#type-httpclientresponse) | +| [ScriptVerificationBackend](#type-scriptverificationbackend) | +| [TransactionEvidenceErrorCode](#type-transactionevidenceerrorcode) | Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes), [Functions](#functions), [Types](#types), [Enums](#enums), [Variables](#variables) @@ -2560,49 +3324,80 @@ See also: [FetchOptions](./transaction.md#interface-fetchoptions) Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes), [Functions](#functions), [Types](#types), [Enums](#enums), [Variables](#variables) --- + ### Type: HttpClientResponse An interface for the response returned by the request method. ```ts -export type HttpClientResponse = { - data: T; - status: number; - statusText: string; - ok: true; -} | { - data: any; - status: number; - statusText: string; - ok: false; -} +export type HttpClientResponse = + | { + data: T + status: number + statusText: string + ok: true + } + | { + data: any + status: number + statusText: string + ok: false + } ``` Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes), [Functions](#functions), [Types](#types), [Enums](#enums), [Variables](#variables) --- + +### Type: ScriptVerificationBackend + +Backend shape shared by transaction-graph and individual-Spend routing. + +```ts +export type ScriptVerificationBackend = BdkVerifierInterface & SpendVerifierInterface +``` + +See also: [BdkVerifierInterface](./transaction.md#interface-bdkverifierinterface), [SpendVerifierInterface](./script.md#interface-spendverifierinterface) + +Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes), [Functions](#functions), [Types](#types), [Enums](#enums), [Variables](#variables) + +--- + +### Type: TransactionEvidenceErrorCode + +```ts +export type TransactionEvidenceErrorCode = + 'invalid-evidence' | 'limit' | 'cancelled' | 'timeout' | 'context-changed' | 'disposed' +``` + +Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes), [Functions](#functions), [Types](#types), [Enums](#enums), [Variables](#variables) + +--- + ## Enums ### Enum: TX_DATA_FORMAT ```ts export enum TX_DATA_FORMAT { - RAWTX = 0, - RAWTX_AND_BUMP_INDEX = 1, - TXID_ONLY = 2 + RAWTX = 0, + RAWTX_AND_BUMP_INDEX = 1, + TXID_ONLY = 2 } ``` Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes), [Functions](#functions), [Types](#types), [Enums](#enums), [Variables](#variables) --- + ## Variables -| | -| --- | -| [ATOMIC_BEEF](#variable-atomic_beef) | -| [BEEF_V1](#variable-beef_v1) | -| [BEEF_V2](#variable-beef_v2) | +| | +| ------------------------------------------------------------------------------ | +| [ATOMIC_BEEF](#variable-atomic_beef) | +| [BEEF_V1](#variable-beef_v1) | +| [BEEF_V2](#variable-beef_v2) | +| [defaultTransactionEvidenceLimits](#variable-defaulttransactionevidencelimits) | Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes), [Functions](#functions), [Types](#types), [Enums](#enums), [Variables](#variables) @@ -2617,6 +3412,7 @@ ATOMIC_BEEF = 16843009 Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes), [Functions](#functions), [Types](#types), [Enums](#enums), [Variables](#variables) --- + ### Variable: BEEF_V1 ```ts @@ -2626,6 +3422,7 @@ BEEF_V1 = 4022206465 Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes), [Functions](#functions), [Types](#types), [Enums](#enums), [Variables](#variables) --- + ### Variable: BEEF_V2 ```ts @@ -2635,3 +3432,31 @@ BEEF_V2 = 4022206466 Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes), [Functions](#functions), [Types](#types), [Enums](#enums), [Variables](#variables) --- + +### Variable: defaultTransactionEvidenceLimits + +```ts +defaultTransactionEvidenceLimits: Readonly = Object.freeze({ + candidateBytes: 1024 * 1024, + retainedBytes: 16 * 1024 * 1024, + transactions: 256, + inputs: 4096, + scriptBytes: 256 * 1024, + scriptMemoryBytes: 16 * 1024 * 1024, + candidatesPerTransaction: 8, + pendingTransactions: 32, + concurrentTransactions: 4, + pendingChainCalls: 8, + consumers: 128, + cacheEntries: 128, + cacheAgeMs: 60000, + attemptTimeoutMs: 5000, + requestTimeoutMs: 15000 +}) +``` + +See also: [TransactionEvidenceLimits](./transaction.md#interface-transactionevidencelimits) + +Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes), [Functions](#functions), [Types](#types), [Enums](#enums), [Variables](#variables) + +--- diff --git a/packages/sdk/docs/transaction-evidence.md b/packages/sdk/docs/transaction-evidence.md new file mode 100644 index 000000000..238bfa4fb --- /dev/null +++ b/packages/sdk/docs/transaction-evidence.md @@ -0,0 +1,160 @@ +# Transaction evidence + +This guide documents the bounded transaction-evidence APIs exported by the SDK. +The C02/C03 implementation is under review; these notes describe the current +source contract and its limits. They do not claim that the wider verification +plan or release gates have completed. + +## Evidence and verification + +The transaction entry point exports `TransactionEvidence`, +`VerifiedTransactionOutput`, `TransactionEvidenceError`, +`TransactionEvidenceLimits`, `defaultTransactionEvidenceLimits`, and +`TransactionEvidenceCoordinator`. The source contracts are in +[`TransactionEvidence.ts`](../src/transaction/TransactionEvidence.ts), +[`TransactionEvidenceCoordinator.ts`](../src/transaction/TransactionEvidenceCoordinator.ts), +and [`ChainTracker.ts`](../src/transaction/ChainTracker.ts). + +`TransactionEvidence` is an untrusted receipt: + +```ts +interface TransactionEvidence { + beef: number[] + outputIndex: number + txid?: string +} +``` + +The optional `txid` is a consistency hint. The coordinator copies the BEEF +bytes before parsing or deriving a receipt, derives the selected transaction ID +from the atomic transaction (or the final transaction when no atomic ID is +present), and rejects a mismatched hint. A successful verification returns the +derived transaction ID, selected output index, canonical outpoint, and locking +script. It establishes transaction inclusion/ancestry and script validity +under the supplied verifier and chain tracker. It does not establish service +relevance, current unspentness, or ownership. + +The parser walks the complete unconfirmed graph on every attempt, including +positive-cache use. It checks source transaction presence, source TXID +consistency, duplicate spent outpoints, input and serialized-script limits, +and rejects an unconfirmed zero-input leaf. Confirmed ancestors still require +canonical Merkle-root validation through the caller's tracker. Receipt bytes +are copied and owned by the SDK; host metadata is never chain authority. + +## Caller context and bounded policy + +The coordinator requires a caller-owned `chainTracker`, nonempty +`chainNamespace`, and nonempty `policyId`. The namespace identifies the +caller's network or application trust domain. The policy ID identifies the +semantic verification policy or backend. Neither is populated from lookup +metadata. + +`ChainTracker.getVerificationContext?: () => string | number` is an optional +synchronous provider, policy, or recovery-generation marker. The optional +`getVerificationContextToken(signal?)` asynchronously obtains a fresh trusted +tip/context token. The coordinator brackets canonical-root and height checks +with that token and rejects a `context-changed` result when the token changes. +Chaintracks and local adapters include monotonic reorganization/reset epochs +and fence reset entry and failure paths. A remote tip read can still miss an +unobserved ABA transition back to the same tip, and a token over multiple +sources is not an atomic multi-source snapshot. Canonical roots and observed +heights are therefore still rechecked on every use, including positive-cache +reuse. The required tracker methods accept optional abort signals; existing +implementations may ignore them. + +The limits in [`TransactionEvidence.ts`](../src/transaction/TransactionEvidence.ts) +are local admission and memory policy, not consensus limits. Defaults are: + +| Limit | Default | +| -------------------------- | ---------: | +| Candidate bytes | 1 MiB | +| Retained evidence bytes | 16 MiB | +| Transactions | 256 | +| Inputs | 4,096 | +| Serialized script bytes | 256 KiB | +| Script memory | 16 MiB | +| Candidates per transaction | 8 | +| Pending transactions | 32 | +| Concurrent transactions | 4 | +| Pending chain calls | 8 | +| Consumers | 128 | +| Positive cache entries | 128 | +| Positive cache age | 60 seconds | +| Attempt timeout | 5 seconds | +| Request timeout | 15 seconds | + +Overrides must be positive safe integers. Invalid limits fail closed with +`TransactionEvidenceError` code `limit`. These limits are configurable local +policy, not consensus limits. Script resource exhaustion is also mapped to the +typed `limit` outcome; malformed or inconsistent evidence remains +`invalid-evidence`. + +The coordinator shares bounded transaction work and canonical chain calls +among consumers. Consumer cancellation detaches that consumer. Non-abortable +script or backend work remains counted until its actual promise settles, and +one consumer cannot cancel another consumer's owned work. Positive results are +bounded by entry count, retained bytes, and age. Rejected receipts are evicted +and do not become permanent negative decisions for a transaction ID. + +An internal `EvidenceScriptWork` hook is now present in +[`EvidenceScriptWork.ts`](../src/transaction/EvidenceScriptWork.ts). Its binding +covers the actual transaction, every input's source transaction/output bytes +and value, and the script policy/backend parameters. The binding is +transaction-wide; it is not an input-index-only key. A hit may skip +cryptographic script execution only. It must still perform source/value +binding, complete graph traversal, canonical chain calls, and policy/context +checks. Non-batch individual executions keep their own fulfilled or rejected +result, including the typed error; only a backend batch-level failure rejects +every in-flight entry. The cache and in-flight owners remain bounded by the +coordinator limits. A normal block arrival before the next reuse does not +erase exact script work when the transaction/source/policy binding remains +valid; canonical anchors and fresh context are still checked. + +## Resolver evidence intake + +`LookupQueryOptions.onEvidence` and `LookupEvidenceEvent` are additive optional +APIs in [`LookupResolver.ts`](../src/overlay-tools/LookupResolver.ts). The +callback receives copied, untrusted per-host receipts before legacy TXID and +outpoint aggregation: + +```ts +type LookupEvidenceEvent = + { type: 'output'; host: string; output: LookupAnswer['outputs'][number] } | { type: 'limit' } +``` + +Intake defaults to 512 outputs or 16 MiB per query and reports one `limit` +event. `evidenceLimits: { maxOutputs?, maxBytes? }` can configure those local +callback bounds; values must be positive safe integers. Coordinate them with +the downstream verifier's admission limits when admitting larger valid +evidence. +Callback completion is not awaited, callback failures are isolated, and no +callback is delivered after the query iterator closes. The legacy answer, +host scheduling, reputation behavior, timeout behavior, and existing +positional call forms remain unchanged. The additive callback preserves the +existing 2-second lookup default and 5-second tracker wait bound. Older or +custom resolvers can continue through the legacy aggregated-answer fallback. + +Aggregated output may carry a host-supplied TXID hint. The resolver uses a +nonempty hint as a fast path and otherwise derives a TXID from BEEF with a +bounded memo. Security-sensitive consumers must still validate the bytes and +compare the derived TXID. Aggregation remains first-wins for each TXID/output +index key; use `onEvidence` when every bounded host receipt is needed. + +## Compatibility + +Existing two-method `ChainTracker` implementations and existing resolver +callers remain source-compatible. The new transaction exports, optional +tracker context and abort signals, and optional resolver callback are additive. +No caller migration is required for existing calls. The SDK's declared source +version is 2.5.0; the root release review owns final release evidence. + +The synthetic shared-ancestor fixture is 556 BEEF bytes with three reachable +transactions, three inputs, and 314 serialized script bytes. Two child graphs +share the signed ancestor and result in three actual script executions. This +is characterization evidence for graph traversal and script-work sharing, not +a general performance or throughput claim. + +No worker, latency, throughput, browser/mobile deployment, C04/C05, or whole +plan completion claim is made here. Run the package's declared build, +typecheck, lint, format, test, packed-consumer, browser, and documentation +checks before treating this guide as release evidence. diff --git a/packages/sdk/package.json b/packages/sdk/package.json index 089328862..9b804e5c7 100644 --- a/packages/sdk/package.json +++ b/packages/sdk/package.json @@ -1,6 +1,6 @@ { "name": "@bsv/sdk", - "version": "2.4.2", + "version": "2.5.0", "sideEffects": false, "engines": { "node": ">=22" diff --git a/packages/sdk/src/overlay-tools/LookupResolver.ts b/packages/sdk/src/overlay-tools/LookupResolver.ts index 66f44f1bc..eac559734 100644 --- a/packages/sdk/src/overlay-tools/LookupResolver.ts +++ b/packages/sdk/src/overlay-tools/LookupResolver.ts @@ -53,6 +53,20 @@ export type LookupFacilitatorAnswer = LookupAnswer | LookupFreeformAnswer * All optional; defaults preserve prior behavior. */ export interface LookupQueryOptions { + /** + * Owned, UNTRUSTED receipts before legacy txid/outpoint deduplication. Enqueue + * promptly; callback completion is not awaited and failures are isolated. + * Intake stops at the configured evidenceLimits, reporting one limit event. + * No callbacks occur after the query iterator closes. Legacy answers, host + * scheduling, timeout and reputation behavior are unchanged. + */ + onEvidence?: (event: LookupEvidenceEvent) => void | Promise + /** + * Callback intake budget, independent of legacy aggregation. Defaults to 512 + * outputs / 16 MiB of BEEF and context bytes. Values must be positive safe + * integers. Coordinate these with a downstream verifier's admission limits. + */ + evidenceLimits?: { maxOutputs?: number; maxBytes?: number } /** * Override the grace window (ms) between the first valid response and the resolution of the query. * Late responders arriving within this window are merged into the result. Default 80 ms. @@ -96,6 +110,10 @@ export interface LookupQueryOptions { correlationId?: string } +/** Additive evidence intake, independent of the legacy aggregated answer. */ +export type LookupEvidenceEvent = + { type: 'output'; host: string; output: LookupAnswer['outputs'][number] } | { type: 'limit' } + /** Info supplied to onUnreachableHost callbacks. */ export interface UnreachableHostInfo { /** Host URL that failed. */ @@ -498,10 +516,7 @@ export class HTTPSOverlayLookupFacilitator implements OverlayLookupFacilitator { } type LookupQueryEvent = - | { kind: 'answer'; answer: LookupAnswer } - | { kind: 'done' } - | { kind: 'grace' } - | { kind: 'soft' } + { kind: 'answer'; answer: LookupAnswer } | { kind: 'done' } | { kind: 'grace' } | { kind: 'soft' } interface LookupQuerySessionOptions { hostCount: number @@ -509,10 +524,8 @@ interface LookupQuerySessionOptions { softTimeoutMs?: number waitForAllHosts: boolean correlationId?: string - resolveTxId: ( - output: LookupAnswer['outputs'][number], - now: number - ) => string | null + evidenceLimits?: LookupQueryOptions['evidenceLimits'] + resolveTxId: (output: LookupAnswer['outputs'][number], now: number) => string | null } class LookupQuerySession { @@ -526,6 +539,12 @@ class LookupQuerySession { rejectedHosts = 0 freeformHosts = 0 emittedFinal = false + closed = false + private evidenceOutputs = 0 + private evidenceBytes = 0 + private evidenceLimited = false + private readonly maxEvidenceOutputs: number + private readonly maxEvidenceBytes: number private readonly graceMs: number private readonly softTimeoutMs?: number @@ -545,6 +564,14 @@ class LookupQuerySession { private emittedOnce = false constructor(options: LookupQuerySessionOptions) { + this.maxEvidenceOutputs = options.evidenceLimits?.maxOutputs ?? 512 + this.maxEvidenceBytes = options.evidenceLimits?.maxBytes ?? 16 * 1024 * 1024 + if ( + ![this.maxEvidenceOutputs, this.maxEvidenceBytes].every( + value => Number.isSafeInteger(value) && value > 0 + ) + ) + throw new Error('Evidence intake limits must be positive safe integers') this.hostCount = options.hostCount this.graceMs = options.graceMs this.softTimeoutMs = options.softTimeoutMs @@ -570,6 +597,43 @@ class LookupQuerySession { this.push({ kind: 'answer', answer }) } + receiveEvidence( + host: string, + answer: LookupAnswer, + callback: LookupQueryOptions['onEvidence'] + ): void { + if (callback === undefined || this.closed || this.evidenceLimited) return + const deliver = (event: LookupEvidenceEvent): void => { + try { + void Promise.resolve(callback(event)).catch(() => {}) + } catch { + /* consumer isolation */ + } + } + for (const output of answer.outputs) { + const bytes = output.beef.length + (output.context?.length ?? 0) + if ( + this.evidenceOutputs >= this.maxEvidenceOutputs || + this.evidenceBytes + bytes > this.maxEvidenceBytes + ) { + this.evidenceLimited = true + deliver({ type: 'limit' }) + break + } + this.evidenceOutputs++ + this.evidenceBytes += bytes + deliver({ + type: 'output', + host, + output: { + ...output, + beef: output.beef.slice(), + ...(output.context === undefined ? {} : { context: output.context.slice() }) + } + }) + } + } + recordFreeformAnswer(): void { this.freeformHosts++ } @@ -615,9 +679,7 @@ class LookupQuerySession { failedHosts: this.failedHosts, rejectedHosts: this.rejectedHosts, freeformHosts: this.freeformHosts, - ...(this.correlationId !== undefined - ? { correlationId: this.correlationId } - : {}) + ...(this.correlationId !== undefined ? { correlationId: this.correlationId } : {}) } } @@ -634,11 +696,7 @@ class LookupQuerySession { this.graceFired = true } } - if ( - this.graceFired && - added && - (this.emittedOnce || !this.waitForAllHosts) - ) { + if (this.graceFired && added && (this.emittedOnce || !this.waitForAllHosts)) { this.emittedOnce = true return this.snapshot(false) } @@ -663,9 +721,7 @@ class LookupQuerySession { } return { snapshot, - stop: - typeof this.softTimeoutMs === 'number' && - this.firstResponseAt !== null + stop: typeof this.softTimeoutMs === 'number' && this.firstResponseAt !== null } } @@ -695,14 +751,8 @@ class LookupQuerySession { } async *progress(): AsyncIterable { - if ( - typeof this.softTimeoutMs === 'number' && - this.softTimeoutMs >= 0 - ) { - this.softTimer = setTimeout( - () => this.push({ kind: 'soft' }), - this.softTimeoutMs - ) + if (typeof this.softTimeoutMs === 'number' && this.softTimeoutMs >= 0) { + this.softTimer = setTimeout(() => this.push({ kind: 'soft' }), this.softTimeoutMs) } try { let stop = false @@ -716,6 +766,7 @@ class LookupQuerySession { this.emittedFinal = true yield finalSnapshot } finally { + this.closed = true if (this.graceTimer !== null) clearTimeout(this.graceTimer) if (this.softTimer !== null) clearTimeout(this.softTimer) } @@ -889,10 +940,7 @@ export default class LookupResolver { private async competentHostsFor(question: LookupQuestion): Promise { let hosts: string[] if (question.service === 'ls_slap') { - hosts = - this.networkPreset === 'local' - ? ['http://localhost:8080'] - : this.slapTrackers + hosts = this.networkPreset === 'local' ? ['http://localhost:8080'] : this.slapTrackers } else if (this.hostOverrides[question.service] != null) { hosts = this.hostOverrides[question.service] } else if (this.networkPreset === 'local') { @@ -911,9 +959,7 @@ export default class LookupResolver { private isSlapRecoveryEligible(service: string): boolean { return ( - service !== 'ls_slap' && - this.hostOverrides[service] == null && - this.networkPreset !== 'local' + service !== 'ls_slap' && this.hostOverrides[service] == null && this.networkPreset !== 'local' ) } @@ -921,10 +967,7 @@ export default class LookupResolver { const competentHosts = await this.competentHostsFor(question) let rankedHosts: string[] try { - rankedHosts = this.prepareHostsForQuery( - competentHosts, - `lookup service ${question.service}` - ) + rankedHosts = this.prepareHostsForQuery(competentHosts, `lookup service ${question.service}`) } catch (error) { if (!this.isSlapRecoveryEligible(question.service)) throw error this.hostsCache.delete(question.service) @@ -935,10 +978,7 @@ export default class LookupResolver { `No competent ${this.networkPreset} hosts found by the SLAP trackers for lookup service: ${question.service}` ) } - rankedHosts = this.prepareHostsForQuery( - fresh, - `lookup service ${question.service}` - ) + rankedHosts = this.prepareHostsForQuery(fresh, `lookup service ${question.service}`) } if (rankedHosts.length < 1) { throw new Error( @@ -948,13 +988,9 @@ export default class LookupResolver { return rankedHosts } - private unreachableNotificationCooldown( - options: LookupQueryOptions | undefined - ): number { + private unreachableNotificationCooldown(options: LookupQueryOptions | undefined): number { const requested = options?.unreachableHostNotificationCooldownMs - return typeof requested === 'number' && - Number.isFinite(requested) && - requested >= 0 + return typeof requested === 'number' && Number.isFinite(requested) && requested >= 0 ? requested : DEFAULT_UNREACHABLE_NOTIFICATION_COOLDOWN_MS } @@ -970,13 +1006,9 @@ export default class LookupResolver { const notificationKey = `${service}\u0000${host}` const now = Date.now() const lastNotificationAt = - this.lastUnreachableNotificationAt.get(notificationKey) ?? - Number.NEGATIVE_INFINITY + this.lastUnreachableNotificationAt.get(notificationKey) ?? Number.NEGATIVE_INFINITY if (now - lastNotificationAt < cooldownMs) return - if ( - this.lastUnreachableNotificationAt.size >= - MAX_NOTIFICATION_DEDUP_ENTRIES - ) { + if (this.lastUnreachableNotificationAt.size >= MAX_NOTIFICATION_DEDUP_ENTRIES) { this.evictOldest(this.lastUnreachableNotificationAt) } this.lastUnreachableNotificationAt.set(notificationKey, now) @@ -1015,19 +1047,10 @@ export default class LookupResolver { return } session.recordFreeformAnswer() - this.captureHostTelemetry( - service, - host, - 'freeform', - Date.now() - hostStartedAt, - correlationId - ) + this.captureHostTelemetry(service, host, 'freeform', Date.now() - hostStartedAt, correlationId) } - private recordLookupHostFailure( - context: LookupHostFailureContext, - error: unknown - ): void { + private recordLookupHostFailure(context: LookupHostFailureContext, error: unknown): void { const { session, service, @@ -1049,13 +1072,7 @@ export default class LookupResolver { error ) if (!semanticRejection) { - this.notifyUnreachableHost( - host, - service, - error, - onUnreachableHost, - notificationCooldownMs - ) + this.notifyUnreachableHost(host, service, error, onUnreachableHost, notificationCooldownMs) } } @@ -1067,12 +1084,12 @@ export default class LookupResolver { options: LookupQueryOptions | undefined ): void { const correlationId = session.correlationId - const notificationCooldownMs = - this.unreachableNotificationCooldown(options) + const notificationCooldownMs = this.unreachableNotificationCooldown(options) for (const host of hosts) { const hostStartedAt = Date.now() void this.lookupHostWithTracking(host, question, timeout) .then(answer => { + if (isOutputListAnswer(answer)) session.receiveEvidence(host, answer, options?.onEvidence) this.recordLookupHostAnswer( session, question.service, @@ -1083,15 +1100,18 @@ export default class LookupResolver { ) }) .catch(error => { - this.recordLookupHostFailure({ - session, - service: question.service, - host, - hostStartedAt, - correlationId, - onUnreachableHost: options?.onUnreachableHost, - notificationCooldownMs - }, error) + this.recordLookupHostFailure( + { + session, + service: question.service, + host, + hostStartedAt, + correlationId, + onUnreachableHost: options?.onUnreachableHost, + notificationCooldownMs + }, + error + ) }) .finally(() => { session.recordDone() @@ -1123,11 +1143,11 @@ export default class LookupResolver { options?.correlationId ?? (this.telemetry.enabled ? this.telemetry.createCorrelationId() : undefined) const session = new LookupQuerySession({ + evidenceLimits: options?.evidenceLimits, hostCount, graceMs: options?.graceMs ?? 80, softTimeoutMs: options?.softTimeoutMs, - waitForAllHosts: - options?.waitForAllHosts ?? options?.holdForUnknownHosts ?? false, + waitForAllHosts: options?.waitForAllHosts ?? options?.holdForUnknownHosts ?? false, correlationId, resolveTxId: (output, now) => this.resolveTxIdForOutput(output, now) }) @@ -1144,13 +1164,7 @@ export default class LookupResolver { } }) - this.startLookupHostQueries( - rankedHosts, - question, - timeout, - session, - options - ) + this.startLookupHostQueries(rankedHosts, question, timeout, session, options) try { for await (const progress of session.progress()) { diff --git a/packages/sdk/src/overlay-tools/__tests/LookupResolver.evidence.test.ts b/packages/sdk/src/overlay-tools/__tests/LookupResolver.evidence.test.ts new file mode 100644 index 000000000..6c10ebcb6 --- /dev/null +++ b/packages/sdk/src/overlay-tools/__tests/LookupResolver.evidence.test.ts @@ -0,0 +1,232 @@ +import LookupResolver, { type LookupFacilitatorAnswer } from '../LookupResolver' +import MerklePath from '../../transaction/MerklePath' +import Transaction from '../../transaction/Transaction' +import P2PKH from '../../script/templates/P2PKH' +import PrivateKey from '../../primitives/PrivateKey' +import Script from '../../script/Script' + +const service = 'ls_identity' + +interface Deferred { + promise: Promise + resolve: (value: T) => void +} + +function deferred(): Deferred { + let resolve!: (value: T) => void + const promise = new Promise(resolvePromise => { + resolve = resolvePromise + }) + return { promise, resolve } +} + +async function sameTransactionReceipts(): Promise<{ bad: number[]; valid: number[] }> { + const key = new PrivateKey(42) + const p2pkh = new P2PKH() + const source = new Transaction() + source.addInput({ + sourceTXID: '00'.repeat(32), + sourceOutputIndex: 0, + unlockingScript: Script.fromASM('OP_TRUE') + }) + source.addOutput({ satoshis: 10, lockingScript: p2pkh.lock(key.toAddress()) }) + source.merklePath = new MerklePath(700_000, [ + [ + { offset: 0, hash: source.id('hex'), txid: true }, + { offset: 1, duplicate: true } + ] + ]) + + const tx = new Transaction() + tx.addInput({ + sourceTransaction: source, + sourceOutputIndex: 0, + unlockingScriptTemplate: p2pkh.unlock(key) + }) + tx.addOutput({ satoshis: 4, lockingScript: p2pkh.lock(key.toAddress()) }) + await tx.sign() + const bad = tx.toBEEF() + + const alternate = Transaction.fromBEEF(bad) + const alternateSource = alternate.inputs[0].sourceTransaction + if (alternateSource === undefined) throw new Error('fixture source is missing') + alternateSource.merklePath = new MerklePath(700_000, [ + [ + { offset: 0, hash: alternateSource.id('hex'), txid: true }, + { offset: 1, hash: '42'.repeat(32) } + ] + ]) + const valid = alternate.toBEEF() + expect(Transaction.fromBEEF(bad).id('hex')).toBe(Transaction.fromBEEF(valid).id('hex')) + return { bad, valid } +} + +function resolverFor( + hosts: string[], + lookup: (host: string) => Promise +): LookupResolver { + return new LookupResolver({ + hostOverrides: { [service]: hosts }, + facilitator: { lookup: async host => await lookup(host) } + }) +} + +describe('LookupResolver additive evidence intake', () => { + it('delivers both owned receipts before legacy first-wins aggregation and isolates callback mutations', async () => { + const { bad, valid } = await sameTransactionReceipts() + const firstHost = 'https://first.invalid-proof.example' + const secondHost = 'https://second.valid-proof.example' + const callbackEvents: Array<{ host: string; beef: number[] }> = [] + const responses: Record = { + [firstHost]: { type: 'output-list', outputs: [{ beef: bad, outputIndex: 0, context: [1] }] }, + [secondHost]: { + type: 'output-list', + outputs: [{ beef: valid, outputIndex: 0, context: [2] }] + } + } + const resolver = resolverFor([firstHost, secondHost], async host => responses[host]) + + const answer = await resolver.query({ service, query: {} }, undefined, { + onEvidence: event => { + if (event.type !== 'output') return + callbackEvents.push({ host: event.host, beef: event.output.beef.slice() }) + event.output.beef.fill(0) + event.output.context?.fill(0) + } + }) + + expect(callbackEvents).toEqual([ + { host: firstHost, beef: bad }, + { host: secondHost, beef: valid } + ]) + expect(answer.outputs).toEqual([{ beef: bad, outputIndex: 0, context: [1] }]) + expect(responses[firstHost]).toEqual({ + type: 'output-list', + outputs: [{ beef: bad, outputIndex: 0, context: [1] }] + }) + expect(responses[secondHost]).toEqual({ + type: 'output-list', + outputs: [{ beef: valid, outputIndex: 0, context: [2] }] + }) + }) + + it('does not emit untrusted receipts for malformed, empty, or freeform answers', async () => { + const malformedHost = 'https://malformed.example' + const emptyHost = 'https://empty.example' + const freeformHost = 'https://freeform.example' + const resolver = resolverFor([malformedHost, emptyHost, freeformHost], async host => { + if (host === malformedHost) + return { type: 'output-list', outputs: [{ beef: [], outputIndex: 0 }] } as never + if (host === emptyHost) return { type: 'output-list', outputs: [] } + return { type: 'freeform', result: { untrusted: true } } + }) + const received: unknown[] = [] + + await expect( + resolver.query({ service, query: {} }, undefined, { + onEvidence: event => received.push(event) + }) + ).resolves.toEqual({ + type: 'output-list', + outputs: [] + }) + expect(received).toEqual([]) + }) + + it('isolates a synchronous callback failure from the legacy answer', async () => { + const { bad } = await sameTransactionReceipts() + const resolver = resolverFor(['https://callback-throws.example'], async () => ({ + type: 'output-list', + outputs: [{ beef: bad, outputIndex: 0 }] + })) + + await expect( + resolver.query({ service, query: {} }, undefined, { + onEvidence: () => { + throw new Error('consumer failure') + } + }) + ).resolves.toEqual({ type: 'output-list', outputs: [{ beef: bad, outputIndex: 0 }] }) + }) + + it('closes evidence delivery when a progressive iterator is closed before a late host responds', async () => { + const { bad, valid } = await sameTransactionReceipts() + const firstHost = 'https://fast.example' + const lateHost = 'https://late.example' + const late = deferred() + const events: string[] = [] + const resolver = resolverFor([firstHost, lateHost], async host => + host === firstHost + ? { type: 'output-list', outputs: [{ beef: bad, outputIndex: 0 }] } + : await late.promise + ) + const iterator = resolver + .query$({ service, query: {} }, undefined, { + graceMs: 0, + onEvidence: event => { + if (event.type === 'output') events.push(event.host) + } + }) + [Symbol.asyncIterator]() + + await expect(iterator.next()).resolves.toMatchObject({ done: false }) + await iterator.return?.() + late.resolve({ type: 'output-list', outputs: [{ beef: valid, outputIndex: 0 }] }) + await new Promise(resolve => setImmediate(resolve)) + expect(events).toEqual([firstHost]) + }) + + it('reports each bounded intake limit once while preserving the legacy aggregation', async () => { + const { bad } = await sameTransactionReceipts() + const manyOutputs = Array.from({ length: 513 }, () => ({ beef: bad.slice(), outputIndex: 0 })) + const countEvents: Array<'output' | 'limit'> = [] + const countResolver = resolverFor(['https://count-limit.example'], async () => ({ + type: 'output-list', + outputs: manyOutputs + })) + + const counted = await countResolver.query({ service, query: {} }, undefined, { + onEvidence: event => countEvents.push(event.type) + }) + expect(countEvents.filter(type => type === 'output')).toHaveLength(512) + expect(countEvents.filter(type => type === 'limit')).toHaveLength(1) + expect(counted.outputs).toEqual([{ beef: bad, outputIndex: 0 }]) + + const bytesEvents: Array<'output' | 'limit'> = [] + const tooLarge = Array.from({ length: 16 * 1024 * 1024 + 1 }, () => 0) + const bytesResolver = resolverFor(['https://bytes-limit.example'], async () => ({ + type: 'output-list', + outputs: [{ beef: tooLarge, outputIndex: 0 }] + })) + await bytesResolver.query({ service, query: {} }, undefined, { + onEvidence: event => bytesEvents.push(event.type) + }) + expect(bytesEvents).toEqual(['limit']) + }) + + it('applies caller-configured evidence limits without changing legacy aggregation', async () => { + const { bad, valid } = await sameTransactionReceipts() + const outputs = [ + { beef: bad, outputIndex: 0 }, + { beef: valid, outputIndex: 0 } + ] + const resolver = resolverFor(['https://configured-limits.example'], async () => ({ + type: 'output-list', + outputs + })) + const limited: Array<'output' | 'limit'> = [] + const answer = await resolver.query({ service, query: {} }, undefined, { + evidenceLimits: { maxOutputs: 1, maxBytes: bad.length * 2 }, + onEvidence: event => limited.push(event.type) + }) + expect(limited).toEqual(['output', 'limit']) + expect(answer.outputs).toEqual([{ beef: bad, outputIndex: 0 }]) + + const admitted: Array<'output' | 'limit'> = [] + await resolver.query({ service, query: {} }, undefined, { + evidenceLimits: { maxOutputs: 2, maxBytes: bad.length + valid.length }, + onEvidence: event => admitted.push(event.type) + }) + expect(admitted).toEqual(['output', 'output']) + }) +}) diff --git a/packages/sdk/src/transaction/ChainTracker.ts b/packages/sdk/src/transaction/ChainTracker.ts index 20552f847..9d2d78f12 100644 --- a/packages/sdk/src/transaction/ChainTracker.ts +++ b/packages/sdk/src/transaction/ChainTracker.ts @@ -23,6 +23,21 @@ * }; */ export default interface ChainTracker { - isValidRootForHeight: (root: string, height: number) => Promise - currentHeight: () => Promise + isValidRootForHeight: (root: string, height: number, signal?: AbortSignal) => Promise + currentHeight: (signal?: AbortSignal) => Promise + /** + * Optional trusted local provider/policy/recovery context. Change this value + * when switching sources or resetting their state. It is not a canonical + * chain snapshot: consumers must still check current canonical dependencies. + * Implementations without cancellable I/O may ignore the optional signals. + */ + getVerificationContext?: () => string | number + /** + * Optional fresh canonical context token from the trusted chain provider. + * Include canonical block/tip identity and any available monotonic reorg or + * reset epoch. Consumers compare tokens around asynchronous verification. + * Two remote tip observations are not an atomic snapshot and cannot detect + * an intervening transition back to the identical tip (ABA). + */ + getVerificationContextToken?: (signal?: AbortSignal) => Promise } diff --git a/packages/sdk/src/transaction/EvidenceScriptWork.ts b/packages/sdk/src/transaction/EvidenceScriptWork.ts new file mode 100644 index 000000000..4c6d37c91 --- /dev/null +++ b/packages/sdk/src/transaction/EvidenceScriptWork.ts @@ -0,0 +1,291 @@ +import type Transaction from './Transaction.js' +import type BdkVerifierInterface from './BdkVerifierInterface.js' +import type { BdkVerifyScriptsParams } from './BdkVerifierInterface.js' +import { sha256 } from '../primitives/Hash.js' +import { toHex, toArray } from '../primitives/utils.js' +import { + evidenceError, + TransactionEvidenceError, + type TransactionEvidenceLimits +} from './TransactionEvidence.js' + +interface InputResult { + valid: boolean + inputTotal: number +} +interface Entry { + key: string + generation: number + owners: Set + promise: Promise + resolve: (valid: boolean) => void + reject: (error: unknown) => void + params: BdkVerifyScriptsParams +} + +/** Binds exact script execution inputs; never an ancestor/chain verdict. */ +function binding(params: BdkVerifyScriptsParams): string { + const tx = params.tx + const sources = tx.inputs.map(input => { + const source = input.sourceTransaction + const output = source?.outputs[input.sourceOutputIndex] + if (source === undefined || output === undefined) + throw new TransactionEvidenceError('invalid-evidence') + return [ + source.id('hex'), + input.sourceOutputIndex, + output.satoshis, + output.lockingScript.toHex() + ] + }) + return toHex( + sha256( + toArray( + JSON.stringify([ + tx.toHex(), + sources, + params.blockHeight, + params.consensus, + params.verifyFlags, + params.memoryLimit + ]), + 'utf8' + ) + ) + ) +} + +/** Internal cache. Only coordinator-owned transactions are bound to its scope below. */ +export class EvidenceScriptWork { + private readonly positives = new Map() + private readonly pending = new Map() + private outstanding = 0 + private generation = 0 + private expiryTimer?: ReturnType + + constructor(private readonly limits: Readonly) {} + + clear(): void { + this.generation++ + this.positives.clear() + // Running non-abortable calls remain charged until their actual completion. + this.pending.clear() + clearTimeout(this.expiryTimer) + } + + private has(key: string): boolean { + const expires = this.positives.get(key) + if (expires !== undefined && expires > Date.now()) return true + this.positives.delete(key) + return false + } + + private remember(key: string): void { + if (this.positives.size >= this.limits.cacheEntries) + this.positives.delete(this.positives.keys().next().value!) + this.positives.set(key, Date.now() + this.limits.cacheAgeMs) + this.scheduleExpiry() + } + + private scheduleExpiry(): void { + clearTimeout(this.expiryTimer) + const expiresAt = Math.min(...this.positives.values()) + if (!Number.isFinite(expiresAt)) return + this.expiryTimer = setTimeout( + () => { + for (const key of this.positives.keys()) this.has(key) + this.scheduleExpiry() + }, + Math.max(1, expiresAt - Date.now()) + ) + this.expiryTimer.unref?.() + } + + inputs( + scope: EvidenceScriptScope, + params: BdkVerifyScriptsParams, + verify: (skipScripts: boolean) => InputResult + ): InputResult { + scope.check() + const key = binding(params) + const hit = this.has(key) + // Even a hit traverses every source, queues ancestry and recomputes input value. + const result = verify(hit) + scope.check() + if (binding(params) !== key) throw new TransactionEvidenceError('invalid-evidence') + if (result.valid && !hit) this.remember(key) + return result + } + + async batch( + scope: EvidenceScriptScope, + params: readonly BdkVerifyScriptsParams[], + backend: BdkVerifierInterface + ): Promise { + scope.check() + const created: Entry[] = [] + const results = params.map(param => { + const key = binding(param) + if (this.has(key)) + return Promise.resolve().then(() => { + scope.check() + if (binding(param) !== key) throw new TransactionEvidenceError('invalid-evidence') + return true + }) + let entry = this.pending.get(key) + if (entry === undefined) { + if (this.outstanding >= this.limits.transactions) + return Promise.reject(new TransactionEvidenceError('limit')) + let resolve: Entry['resolve'] = () => {} + let reject: Entry['reject'] = () => {} + const promise = new Promise((accept, fail) => { + resolve = accept + reject = fail + }) + entry = { + key, + generation: this.generation, + owners: new Set(), + promise, + resolve, + reject, + params: param + } + this.outstanding++ + this.pending.set(key, entry) + created.push(entry) + } + const owned = entry + owned.owners.add(scope) + const abandon = (): void => { + owned.owners.delete(scope) + if (owned.owners.size === 0 && this.pending.get(key) === owned) this.pending.delete(key) + } + scope.signal.addEventListener('abort', abandon, { once: true }) + // The outer attempt handles cancellation. Keep non-abortable backend work + // countable until actual settlement; one owner cannot cancel another. + return owned.promise + .then(valid => { + scope.check() + if (binding(param) !== key) throw new TransactionEvidenceError('invalid-evidence') + return valid === true + }) + .finally(() => { + scope.signal.removeEventListener('abort', abandon) + abandon() + }) + }) + if (created.length > 0) void this.execute(created, backend) + const settled = await Promise.allSettled(results) + const values = settled.map(value => { + if (value.status === 'rejected') { + throw evidenceError(value.reason) + } + return value.value + }) + scope.check() + return values + } + + private async execute(entries: Entry[], backend: BdkVerifierInterface): Promise { + try { + const params = entries.map(entry => entry.params) + if (backend.verifyScriptsBatch === undefined) { + const settled = await Promise.allSettled( + params.map(async param => await backend.verifyScripts(param)) + ) + entries.forEach((entry, index) => { + this.settleEntry(entry, settled[index]) + }) + return + } + const values = await backend.verifyScriptsBatch(params) + if (values.length !== entries.length) throw new TransactionEvidenceError('invalid-evidence') + entries.forEach((entry, index) => { + this.publishEntry(entry, values[index] === true) + }) + } catch (error) { + for (const entry of entries) entry.reject(evidenceError(error)) + } finally { + for (const entry of entries) { + this.outstanding-- + if (this.pending.get(entry.key) === entry) this.pending.delete(entry.key) + } + } + } + + private settleEntry(entry: Entry, result: PromiseSettledResult | undefined): void { + if (result === undefined || result.status === 'rejected') { + entry.reject(evidenceError(result?.reason)) + return + } + this.publishEntry(entry, result.value === true) + } + + private publishEntry(entry: Entry, valid: boolean): void { + if (binding(entry.params) !== entry.key) { + entry.reject(new TransactionEvidenceError('invalid-evidence')) + return + } + let live = false + for (const owner of entry.owners) { + try { + owner.check() + live = true + } catch { + /* stale ownership cannot publish */ + } + } + if (valid && live && entry.generation === this.generation) this.remember(entry.key) + entry.resolve(valid) + } +} + +export interface EvidenceScriptScope { + work: EvidenceScriptWork + signal: AbortSignal + check: () => void +} + +// No host metadata, Transaction field or serialized flag can construct this +// association. Its owner is the coordinator's byte-snapshotted candidate. +const scopes = new WeakMap() + +export async function withEvidenceScriptWork( + tx: Transaction, + scope: EvidenceScriptScope, + verify: () => Promise +): Promise { + scopes.set(tx, scope) + try { + return await verify() + } finally { + scopes.delete(tx) + } +} + +export function evidenceScriptScope(tx: Transaction): EvidenceScriptScope | undefined { + return scopes.get(tx) +} + +export function scopedScriptBackend( + scope: EvidenceScriptScope, + backend: BdkVerifierInterface +): BdkVerifierInterface { + return { + supportsMemoryLimit: backend.supportsMemoryLimit, + shouldVerifyScripts: + backend.shouldVerifyScripts === undefined + ? undefined + : params => { + scope.check() + const key = binding(params) + const ready = backend.shouldVerifyScripts!(params) + scope.check() + if (binding(params) !== key) throw new TransactionEvidenceError('invalid-evidence') + return ready + }, + verifyScripts: async params => (await scope.work.batch(scope, [params], backend))[0], + verifyScriptsBatch: async params => await scope.work.batch(scope, params, backend) + } +} diff --git a/packages/sdk/src/transaction/Transaction.ts b/packages/sdk/src/transaction/Transaction.ts index b50d9f633..5370211f5 100644 --- a/packages/sdk/src/transaction/Transaction.ts +++ b/packages/sdk/src/transaction/Transaction.ts @@ -34,6 +34,11 @@ import TransactionSignature, { import Random from '../primitives/Random.js' import type BdkVerifierInterface from './BdkVerifierInterface.js' import { scriptVerificationBackend } from './ScriptVerificationBackend.js' +import { + evidenceScriptScope, + scopedScriptBackend, + type EvidenceScriptScope +} from './EvidenceScriptWork.js' /** Post-Chronicle height used when an input's source UTXO mined-height is unobtainable. */ const POST_CHRONICLE_HEIGHT_FALLBACK = 943816 @@ -59,6 +64,7 @@ type UnminedTransactionVerificationContext = TransactionVerificationState & { feeModel: FeeModel | undefined selectedVerifier: BdkVerifierInterface | undefined verifierQueue: QueuedScriptVerification[] + scriptWork?: EvidenceScriptScope } /** @@ -1141,10 +1147,7 @@ export default class Transaction { } return } - if ( - !state.verifiedTxids.has(sourceTxid) && - !state.queuedTxids.has(sourceTxid) - ) { + if (!state.verifiedTxids.has(sourceTxid) && !state.queuedTxids.has(sourceTxid)) { state.txQueue.push(sourceTransaction) state.queuedTxids.add(sourceTxid) } @@ -1177,11 +1180,7 @@ export default class Transaction { state.scriptsOnly && input.sourceTXID !== undefined ? input.sourceTXID : sourceTransaction.id('hex') - this.queueSourceTransactionForVerification( - sourceTransaction, - sourceTxid, - state - ) + this.queueSourceTransactionForVerification(sourceTransaction, sourceTxid, state) input.sourceTXID ??= sourceTxid if ( !useVerifier && @@ -1229,9 +1228,7 @@ export default class Transaction { const scriptVerdicts = selectedVerifier.verifyScriptsBatch === undefined ? await Promise.all( - verifierQueue.map( - async params => await selectedVerifier.verifyScripts(params) - ) + verifierQueue.map(async params => await selectedVerifier.verifyScripts(params)) ) : await selectedVerifier.verifyScriptsBatch(verifierQueue) if (scriptVerdicts.length !== verifierQueue.length) { @@ -1270,15 +1267,14 @@ export default class Transaction { } as const const useVerifier = selectedVerifier !== undefined && - (memoryLimit === undefined || - selectedVerifier.supportsMemoryLimit === true) && + (memoryLimit === undefined || selectedVerifier.supportsMemoryLimit === true) && (selectedVerifier.shouldVerifyScripts?.(verifierParams) ?? true) - const inputVerification = this.verifyTransactionInputs( - tx, - useVerifier, - getTxid, - context - ) + const verifyInputs = (skipScripts: boolean): { valid: boolean; inputTotal: number } => + this.verifyTransactionInputs(tx, skipScripts, getTxid, context) + const inputVerification = + !useVerifier && context.scriptWork !== undefined + ? context.scriptWork.work.inputs(context.scriptWork, verifierParams, verifyInputs) + : verifyInputs(useVerifier) if (!inputVerification.valid) return false if (useVerifier) verifierQueue.push(verifierParams) if (this.totalVerifiedOutputs(tx) > inputVerification.inputTotal) return false @@ -1308,7 +1304,12 @@ export default class Transaction { verifier?: BdkVerifierInterface ): Promise { const scriptsOnly = chainTracker === 'scripts only' - const selectedVerifier = verifier ?? scriptVerificationBackend() + const backend = verifier ?? scriptVerificationBackend() + const scriptWork = scriptsOnly ? undefined : evidenceScriptScope(this) + const selectedVerifier = + scriptWork !== undefined && backend !== undefined + ? scopedScriptBackend(scriptWork, backend) + : backend if (!scriptsOnly) this.materializeSourceTXIDs() const verifiedTxids = new Set() const verifiedTransactions = new Set() @@ -1327,7 +1328,8 @@ export default class Transaction { verifiedTxids, feeModel, selectedVerifier, - verifierQueue + verifierQueue, + scriptWork } let queueIndex = 0 @@ -1338,13 +1340,7 @@ export default class Transaction { txid ??= tx.id('hex') return txid } - if ( - this.isTransactionAlreadyVerified( - tx, - getTxid, - verificationContext - ) - ) { + if (this.isTransactionAlreadyVerified(tx, getTxid, verificationContext)) { continue } @@ -1360,11 +1356,7 @@ export default class Transaction { ) { continue } - if (!(await this.verifyUnminedTransaction( - tx, - getTxid, - verificationContext - ))) return false + if (!(await this.verifyUnminedTransaction(tx, getTxid, verificationContext))) return false } await this.verifyQueuedScripts(verifierQueue, selectedVerifier) @@ -1403,9 +1395,10 @@ export default class Transaction { } } - private collectBEEFTransactions( - allowPartial?: boolean - ): { bumps: MerklePath[]; txs: Array<{ tx: Transaction; pathIndex?: number }> } { + private collectBEEFTransactions(allowPartial?: boolean): { + bumps: MerklePath[] + txs: Array<{ tx: Transaction; pathIndex?: number }> + } { const bumps: MerklePath[] = [] const bumpIndexByInstance = new Map() const bumpIndexByRoot = new Map() @@ -1418,7 +1411,14 @@ export default class Transaction { const frame = stack.pop() if (frame == null) continue if (frame.expanded) { - this.appendBEEFTransaction(frame.tx, seenTxids, txs, bumps, bumpIndexByInstance, bumpIndexByRoot) + this.appendBEEFTransaction( + frame.tx, + seenTxids, + txs, + bumps, + bumpIndexByInstance, + bumpIndexByRoot + ) continue } this.scheduleBEEFTransaction(frame.tx, allowPartial, scheduledTxids, stack) diff --git a/packages/sdk/src/transaction/TransactionEvidence.ts b/packages/sdk/src/transaction/TransactionEvidence.ts new file mode 100644 index 000000000..070bbea6c --- /dev/null +++ b/packages/sdk/src/transaction/TransactionEvidence.ts @@ -0,0 +1,163 @@ +import { Beef } from './Beef.js' +import type Transaction from './Transaction.js' +import type LockingScript from '../script/LockingScript.js' +import ScriptResourceLimitError from '../script/ScriptResourceLimitError.js' +import { sha256 } from '../primitives/Hash.js' +import { toHex } from '../primitives/utils.js' + +/** Untrusted transaction evidence. The optional txid is only a consistency hint. */ +export interface TransactionEvidence { + beef: number[] + outputIndex: number + txid?: string +} + +/** Verified transaction inclusion/ancestry; no service relevance or unspentness claim. */ +export interface VerifiedTransactionOutput { + readonly txid: string + readonly outputIndex: number + readonly outpoint: string + readonly lockingScript: LockingScript +} + +export type TransactionEvidenceErrorCode = + 'invalid-evidence' | 'limit' | 'cancelled' | 'timeout' | 'context-changed' | 'disposed' + +/** Bounded, payload-free outcome; rejected receipts never permanently reject a txid. */ +export class TransactionEvidenceError extends Error { + constructor(public readonly code: TransactionEvidenceErrorCode) { + super(`Transaction evidence: ${code}`) + this.name = 'TransactionEvidenceError' + } +} + +/** Keep bounded-work outcomes distinct without exposing errors containing evidence. */ +export function evidenceError(error: unknown): TransactionEvidenceError { + if (error instanceof TransactionEvidenceError) return error + return new TransactionEvidenceError( + error instanceof ScriptResourceLimitError ? 'limit' : 'invalid-evidence' + ) +} + +/** Local admission policy, not consensus limits. Byte limits count serialized bytes. */ +export interface TransactionEvidenceLimits { + candidateBytes: number + retainedBytes: number + transactions: number + inputs: number + scriptBytes: number + scriptMemoryBytes: number + candidatesPerTransaction: number + pendingTransactions: number + concurrentTransactions: number + pendingChainCalls: number + consumers: number + cacheEntries: number + cacheAgeMs: number + attemptTimeoutMs: number + requestTimeoutMs: number +} + +export const defaultTransactionEvidenceLimits: Readonly = Object.freeze({ + candidateBytes: 1024 * 1024, + retainedBytes: 16 * 1024 * 1024, + transactions: 256, + inputs: 4096, + scriptBytes: 256 * 1024, + scriptMemoryBytes: 16 * 1024 * 1024, + candidatesPerTransaction: 8, + pendingTransactions: 32, + concurrentTransactions: 4, + pendingChainCalls: 8, + consumers: 128, + cacheEntries: 128, + cacheAgeMs: 60_000, + attemptTimeoutMs: 5000, + requestTimeoutMs: 15_000 +}) + +/** Internal owned candidate. Never constructed from a host's verification assertion. */ +export interface EvidenceCandidate { + tx: Transaction + txid: string + receipt: string + byteLength: number + outputIndex: number + graphBinding: string +} + +export function parseEvidence( + evidence: TransactionEvidence, + limits: TransactionEvidenceLimits +): EvidenceCandidate { + const outputIndex = evidence.outputIndex + const hint = evidence.txid + if (!Array.isArray(evidence.beef) || !Number.isSafeInteger(outputIndex) || outputIndex < 0) { + throw new TransactionEvidenceError('invalid-evidence') + } + if (evidence.beef.length > limits.candidateBytes) throw new TransactionEvidenceError('limit') + // Snapshot and validate BEFORE either parsing or computing the receipt digest. + const bytes = evidence.beef.slice() + if (bytes.length === 0 || bytes.some(byte => !Number.isInteger(byte) || byte < 0 || byte > 255)) { + throw new TransactionEvidenceError('invalid-evidence') + } + const beef = Beef.fromBinary(bytes) + if (beef.txs.length > limits.transactions) throw new TransactionEvidenceError('limit') + const target = beef.atomicTxid ?? beef.txs.at(-1)?.txid + const tx = target === undefined ? undefined : beef.findAtomicTransaction(target) + if (tx === undefined) throw new TransactionEvidenceError('invalid-evidence') + const txid = tx.id('hex') + if (hint !== undefined && (typeof hint !== 'string' || hint.toLowerCase() !== txid)) { + throw new TransactionEvidenceError('invalid-evidence') + } + if (outputIndex >= tx.outputs.length) throw new TransactionEvidenceError('invalid-evidence') + + // Always walk the COMPLETE unconfirmed graph, including on a positive cache hit. + // A per-ancestor verdict cannot establish graph-wide spend consistency. + const pending = [tx] + const visited = new Set() + const spent = new Set() + let inputs = 0 + let scriptBytes = 0 + while (pending.length > 0) { + const current = pending.pop()! + const id = current.id('hex') + if (visited.has(id)) continue + visited.add(id) + for (const output of current.outputs) scriptBytes += output.lockingScript.toBinary().length + for (const input of current.inputs) scriptBytes += input.unlockingScript?.toBinary().length ?? 0 + inputs += current.inputs.length + if (inputs > limits.inputs || scriptBytes > limits.scriptBytes) + throw new TransactionEvidenceError('limit') + if (current.merklePath != null) continue + if (current.inputs.length === 0) throw new TransactionEvidenceError('invalid-evidence') + for (const input of current.inputs) { + if (input.sourceTransaction == null) throw new TransactionEvidenceError('invalid-evidence') + const sourceTxid = input.sourceTransaction.id('hex') + if (input.sourceTXID !== undefined && input.sourceTXID !== sourceTxid) + throw new TransactionEvidenceError('invalid-evidence') + const outpoint = `${sourceTxid}.${input.sourceOutputIndex}` + if (spent.has(outpoint)) throw new TransactionEvidenceError('invalid-evidence') + spent.add(outpoint) + pending.push(input.sourceTransaction) + } + } + return { + tx, + txid, + receipt: toHex(sha256(bytes)), + byteLength: bytes.length, + outputIndex, + graphBinding: toHex(sha256(tx.toBEEF())) + } +} + +/** Fence all owned transaction/proof bytes, including backend readiness callbacks. */ +export function assertEvidenceUnchanged(candidate: EvidenceCandidate): void { + if ( + candidate.tx.id('hex') !== candidate.txid || + toHex(sha256(candidate.tx.toBEEF())) !== candidate.graphBinding + ) { + throw new TransactionEvidenceError('invalid-evidence') + } +} diff --git a/packages/sdk/src/transaction/TransactionEvidenceCoordinator.ts b/packages/sdk/src/transaction/TransactionEvidenceCoordinator.ts new file mode 100644 index 000000000..921c67158 --- /dev/null +++ b/packages/sdk/src/transaction/TransactionEvidenceCoordinator.ts @@ -0,0 +1,583 @@ +import type ChainTracker from './ChainTracker.js' +import type BdkVerifierInterface from './BdkVerifierInterface.js' +import { scriptVerificationBackend } from './ScriptVerificationBackend.js' +import LockingScript from '../script/LockingScript.js' +import { EvidenceScriptWork, withEvidenceScriptWork } from './EvidenceScriptWork.js' +import { + defaultTransactionEvidenceLimits, + evidenceError, + assertEvidenceUnchanged, + parseEvidence, + TransactionEvidenceError, + type EvidenceCandidate, + type TransactionEvidence, + type TransactionEvidenceLimits, + type VerifiedTransactionOutput +} from './TransactionEvidence.js' + +/** Caller-controlled trust configuration; never populate this from lookup metadata. */ +export interface TransactionEvidenceContext { + chainTracker: ChainTracker + /** Network/genesis identifier or an explicit application chain namespace. */ + chainNamespace: string + /** Semantic verification policy/backend version; change it when policy changes. */ + policyId: string + verifier?: BdkVerifierInterface +} + +export interface TransactionEvidenceCoordinatorOptions extends TransactionEvidenceContext { + limits?: Partial +} + +interface Anchor { + root: string + height: number +} +interface Positive { + scripts: string[] + anchors: Anchor[] + observedHeight?: number + expiresAt: number + bytes: number +} +interface Consumer { + outputIndex: number + resolve: (value: VerifiedTransactionOutput) => void + reject: (error: TransactionEvidenceError) => void + detach: () => void +} +interface Work { + txid: string + revision: number + controller: AbortController + candidates: EvidenceCandidate[] + receipts: Set + consumers: Set + timer: ReturnType + expiresAt: number + running: boolean +} +interface ChainCall { + controller: AbortController + owners: number + promise: Promise +} + +function outcome(error: unknown): TransactionEvidenceError { + return evidenceError(error) +} + +/** + * Bounded, process-local transaction evidence work sharing. This is independent of + * lookup services, certificates and trust ratings. Positive reuse always checks + * canonical anchors again; ChainTracker remains the caller's trusted chain source. + * Synchronous parsing/script execution is byte/memory bounded, not preemptible. + */ +export class TransactionEvidenceCoordinator { + private readonly scriptWork: EvidenceScriptWork + readonly limits: Readonly + private context: TransactionEvidenceContext + private marker: string | number | undefined + private backend: BdkVerifierInterface | undefined + private revision = 0 + private disposed = false + private readonly work = new Map() + private readonly positives = new Map() + private readonly chainCalls = new Map() + private pendingChainCalls = 0 + private activeAttempts = 0 + private consumers = 0 + private retainedBytes = 0 + private expiryTimer?: ReturnType + + constructor(options: TransactionEvidenceCoordinatorOptions) { + this.context = this.validateContext(options) + this.limits = Object.freeze({ ...defaultTransactionEvidenceLimits, ...options.limits }) + for (const value of Object.values(this.limits)) { + if (!Number.isSafeInteger(value) || value <= 0) throw new TransactionEvidenceError('limit') + } + this.scriptWork = new EvidenceScriptWork(this.limits) + this.marker = this.context.chainTracker.getVerificationContext?.() + this.backend = this.context.verifier ?? scriptVerificationBackend() + } + + private validateContext(context: TransactionEvidenceContext): TransactionEvidenceContext { + if ( + context.chainTracker == null || + typeof context.chainTracker.isValidRootForHeight !== 'function' || + typeof context.chainTracker.currentHeight !== 'function' || + context.chainNamespace.length === 0 || + context.policyId.length === 0 + ) { + throw new TransactionEvidenceError('invalid-evidence') + } + return { + chainTracker: context.chainTracker, + chainNamespace: context.chainNamespace, + policyId: context.policyId, + verifier: context.verifier + } + } + + /** Explicit session/network/policy change. Stale in-flight work cannot publish. */ + setContext(context: TransactionEvidenceContext): void { + const next = this.validateContext(context) + this.invalidate(new TransactionEvidenceError('context-changed')) + this.context = next + this.marker = next.chainTracker.getVerificationContext?.() + this.backend = next.verifier ?? scriptVerificationBackend() + } + + dispose(): void { + this.disposed = true + this.invalidate(new TransactionEvidenceError('disposed')) + } + + private invalidate(error: TransactionEvidenceError): void { + this.revision++ + this.scriptWork.clear() + for (const job of this.work.values()) this.finish(job, undefined, error) + for (const txid of this.positives.keys()) this.removePositive(txid) + clearTimeout(this.expiryTimer) + this.expiryTimer = undefined + } + + private synchronize(): void { + if (this.disposed) throw new TransactionEvidenceError('disposed') + const marker = this.context.chainTracker.getVerificationContext?.() + const backend = this.context.verifier ?? scriptVerificationBackend() + if (marker !== this.marker || backend !== this.backend) { + this.invalidate(new TransactionEvidenceError('context-changed')) + this.marker = marker + this.backend = backend + } + } + + private check(job: Work, signal: AbortSignal): void { + this.synchronize() + if (job.revision !== this.revision) throw new TransactionEvidenceError('context-changed') + if (Date.now() >= job.expiresAt) throw new TransactionEvidenceError('timeout') + if (signal.aborted || job.consumers.size === 0) + throw outcome(signal.reason ?? new TransactionEvidenceError('cancelled')) + } + + /** Snapshot intake is synchronous up to the returned Promise's first await. */ + async verify( + evidence: TransactionEvidence, + options: { signal?: AbortSignal } = {} + ): Promise { + this.synchronize() + this.prune() + if (options.signal?.aborted === true) throw new TransactionEvidenceError('cancelled') + if (this.consumers >= this.limits.consumers) throw new TransactionEvidenceError('limit') + let candidate: EvidenceCandidate + try { + candidate = parseEvidence(evidence, this.limits) + } catch (error) { + throw outcome(error) + } + let job = this.work.get(candidate.txid) + if (job === undefined) { + if (this.work.size >= this.limits.pendingTransactions) + throw new TransactionEvidenceError('limit') + const controller = new AbortController() + const created: Work = { + txid: candidate.txid, + revision: this.revision, + controller, + candidates: [], + receipts: new Set(), + consumers: new Set(), + running: false, + timer: setTimeout( + () => this.finish(created, undefined, new TransactionEvidenceError('timeout')), + this.limits.requestTimeoutMs + ), + expiresAt: Date.now() + this.limits.requestTimeoutMs + } + job = created + this.work.set(job.txid, job) + } + if (!job.receipts.has(candidate.receipt)) { + if ( + job.receipts.size >= this.limits.candidatesPerTransaction || + this.retainedBytes + candidate.byteLength > this.limits.retainedBytes + ) { + if (job.consumers.size === 0) + this.finish(job, undefined, new TransactionEvidenceError('limit')) + throw new TransactionEvidenceError('limit') + } + job.receipts.add(candidate.receipt) + job.candidates.push(candidate) + this.retainedBytes += candidate.byteLength + } + const current = job + return await new Promise((resolve, reject) => { + const cancel = (): void => { + this.removeConsumer(current, consumer) + reject(new TransactionEvidenceError('cancelled')) + if (current.consumers.size === 0) + this.finish(current, undefined, new TransactionEvidenceError('cancelled')) + } + const consumer: Consumer = { + outputIndex: candidate.outputIndex, + resolve, + reject, + detach: () => options.signal?.removeEventListener('abort', cancel) + } + current.consumers.add(consumer) + this.consumers++ + options.signal?.addEventListener('abort', cancel, { once: true }) + this.pump() + }) + } + + private removeConsumer(job: Work, consumer: Consumer): void { + if (!job.consumers.delete(consumer)) return + consumer.detach() + this.consumers-- + } + + private finish( + job: Work, + positive?: Positive, + error = new TransactionEvidenceError('invalid-evidence') + ): void { + if (this.work.get(job.txid) !== job) return + this.work.delete(job.txid) + clearTimeout(job.timer) + for (const candidate of job.candidates) this.retainedBytes -= candidate.byteLength + job.candidates = [] + for (const consumer of job.consumers) { + this.removeConsumer(job, consumer) + if (positive === undefined) consumer.reject(error) + else + consumer.resolve({ + txid: job.txid, + outputIndex: consumer.outputIndex, + outpoint: `${job.txid}.${consumer.outputIndex}`, + lockingScript: LockingScript.fromHex(positive.scripts[consumer.outputIndex]) + }) + } + job.controller.abort(error) + } + + private pump(): void { + let running = [...this.work.values()].filter(job => job.running).length + for (const job of this.work.values()) { + if ( + running >= this.limits.concurrentTransactions || + this.activeAttempts >= this.limits.concurrentTransactions + ) + break + if (job.running) continue + job.running = true + running++ + void this.run(job).finally(() => this.pump()) + } + } + + private async run(job: Work): Promise { + let error = new TransactionEvidenceError('invalid-evidence') + try { + const cached = this.positives.get(job.txid) + if (cached !== undefined) { + try { + await this.attempt(job, async signal => await this.recheck(job, cached, signal)) + this.check(job, job.controller.signal) + this.finish(job, cached) + return + } catch (failure) { + error = outcome(failure) + this.removePositive(job.txid) + } + } + while (job.candidates.length > 0 && !job.controller.signal.aborted) { + const candidate = job.candidates.shift()! + try { + const positive = await this.attempt( + job, + async signal => { + const token = await this.contextToken(job, signal) + const anchors = new Map() + let observedHeight: number | undefined + const tracker: ChainTracker = { + isValidRootForHeight: async (root, height) => { + const valid = await this.chainCall( + job, + `root:${height}:${root}`, + signal, + root, + height + ) + if (valid === true) anchors.set(`${height}:${root}`, { root, height }) + return valid === true + }, + currentHeight: async () => { + const height = await this.chainCall(job, 'height', signal) + if (typeof height !== 'number' || !Number.isSafeInteger(height) || height < 0) + throw new TransactionEvidenceError('invalid-evidence') + observedHeight = Math.max(observedHeight ?? 0, height) + return height + } + } + this.check(job, signal) + const valid = await withEvidenceScriptWork( + candidate.tx, + { + work: this.scriptWork, + signal, + check: () => this.check(job, signal) + }, + async () => + await candidate.tx.verify( + tracker, + undefined, + this.limits.scriptMemoryBytes, + this.backend + ) + ) + this.check(job, signal) + assertEvidenceUnchanged(candidate) + if (valid !== true || anchors.size === 0) + throw new TransactionEvidenceError('invalid-evidence') + const scripts = candidate.tx.outputs.map(output => output.lockingScript.toHex()) + const positive: Positive = { + scripts, + anchors: [...anchors.values()], + observedHeight, + expiresAt: Date.now() + this.limits.cacheAgeMs, + bytes: + scripts.reduce((total, script) => total + script.length / 2, 0) + + anchors.size * 40 + } + await this.recheck(job, positive, signal, token) + assertEvidenceUnchanged(candidate) + return positive + }, + () => { + this.retainedBytes -= candidate.byteLength + } + ) + this.check(job, job.controller.signal) + this.cache(job.txid, positive) + this.finish(job, positive) + return + } catch (failure) { + error = outcome(failure) + } + } + } catch (failure) { + error = outcome(failure) + } + this.finish(job, undefined, error) + } + + private async attempt( + job: Work, + operation: (signal: AbortSignal) => Promise, + settled?: () => void + ): Promise { + if (this.activeAttempts >= this.limits.concurrentTransactions) { + settled?.() + throw new TransactionEvidenceError('limit') + } + this.activeAttempts++ + const controller = new AbortController() + const deadline = Date.now() + this.limits.attemptTimeoutMs + const abort = (): void => controller.abort(job.controller.signal.reason) + job.controller.signal.addEventListener('abort', abort, { once: true }) + const timer = setTimeout( + () => controller.abort(new TransactionEvidenceError('timeout')), + this.limits.attemptTimeoutMs + ) + let rejectAbort: () => void = () => {} + const cancelled = new Promise((_resolve, reject) => { + rejectAbort = () => reject(outcome(controller.signal.reason)) + controller.signal.addEventListener('abort', rejectAbort, { once: true }) + }) + // Keep non-abortable backend work counted until its actual Promise settles. + const pending = operation(controller.signal) + .then(value => { + if (Date.now() >= deadline) throw new TransactionEvidenceError('timeout') + return value + }) + .finally(() => { + settled?.() + this.activeAttempts-- + this.pump() + }) + try { + return await Promise.race([pending, cancelled]) + } finally { + clearTimeout(timer) + job.controller.signal.removeEventListener('abort', abort) + controller.signal.removeEventListener('abort', rejectAbort) + } + } + + private async chainCall( + job: Work, + key: string, + signal: AbortSignal, + root?: string, + height?: number + ): Promise { + this.check(job, signal) + const scopedKey = `${job.revision}:${key}` + let call = this.chainCalls.get(scopedKey) + if (call === undefined) { + if (this.pendingChainCalls >= this.limits.pendingChainCalls) + throw new TransactionEvidenceError('limit') + const controller = new AbortController() + const tracker = this.context.chainTracker + const created: ChainCall = { controller, owners: 0, promise: Promise.resolve(false) } + this.pendingChainCalls++ + created.promise = Promise.resolve() + .then(async () => { + if (controller.signal.aborted) throw new TransactionEvidenceError('cancelled') + if (key === 'context-token') + return await tracker.getVerificationContextToken!(controller.signal) + return root === undefined + ? await tracker.currentHeight(controller.signal) + : await tracker.isValidRootForHeight(root, height!, controller.signal) + }) + .finally(() => { + this.pendingChainCalls-- + if (this.chainCalls.get(scopedKey) === created) this.chainCalls.delete(scopedKey) + }) + call = created + this.chainCalls.set(scopedKey, created) + } + call.owners++ + const owned = call + let abort: () => void = () => {} + const cancelled = new Promise((_resolve, reject) => { + abort = () => reject(outcome(signal.reason)) + signal.addEventListener('abort', abort, { once: true }) + }) + try { + const value = await Promise.race([owned.promise, cancelled]) + this.check(job, signal) + if (root !== undefined && value !== true) this.invalidateAnchor(root, height!) + return value + } finally { + signal.removeEventListener('abort', abort) + owned.owners-- + if (owned.owners === 0) { + if (this.chainCalls.get(scopedKey) === owned) this.chainCalls.delete(scopedKey) + owned.controller.abort() + } + } + } + + private async contextToken(job: Work, signal: AbortSignal): Promise { + if (this.context.chainTracker.getVerificationContextToken === undefined) return undefined + const token = await this.chainCall(job, 'context-token', signal) + if (typeof token !== 'string' || token.length === 0) + throw new TransactionEvidenceError('invalid-evidence') + if (token.length > 4096) throw new TransactionEvidenceError('limit') + return token + } + + private async recheck( + job: Work, + positive: Positive, + signal: AbortSignal, + token?: string + ): Promise { + const before = token ?? (await this.contextToken(job, signal)) + for (const anchor of positive.anchors) { + if ( + (await this.chainCall( + job, + `root:${anchor.height}:${anchor.root}`, + signal, + anchor.root, + anchor.height + )) !== true + ) { + throw new TransactionEvidenceError('invalid-evidence') + } + } + if (positive.observedHeight !== undefined) { + const height = await this.chainCall(job, 'height', signal) + if (typeof height !== 'number' || height < positive.observedHeight) + throw new TransactionEvidenceError('invalid-evidence') + } + if ((await this.contextToken(job, signal)) !== before) + throw new TransactionEvidenceError('context-changed') + this.check(job, signal) + } + + private invalidateAnchor(root: string, height: number): void { + this.scriptWork.clear() + for (const [txid, positive] of this.positives) { + if (positive.anchors.some(anchor => anchor.root === root && anchor.height === height)) + this.removePositive(txid) + } + } + + private removePositive(txid: string): void { + const positive = this.positives.get(txid) + if (positive === undefined) return + this.retainedBytes -= positive.bytes + this.positives.delete(txid) + } + + private cache(txid: string, positive: Positive): void { + this.removePositive(txid) + this.prune() + while ( + this.positives.size > 0 && + (this.positives.size >= this.limits.cacheEntries || + this.retainedBytes + positive.bytes > this.limits.retainedBytes) + ) { + this.removePositive(this.positives.keys().next().value!) + } + if (this.retainedBytes + positive.bytes <= this.limits.retainedBytes) { + this.positives.set(txid, positive) + this.retainedBytes += positive.bytes + this.scheduleExpiry() + } + } + + private prune(): void { + for (const [txid, positive] of this.positives) + if (positive.expiresAt <= Date.now()) this.removePositive(txid) + } + + private scheduleExpiry(): void { + clearTimeout(this.expiryTimer) + const expiresAt = Math.min(...[...this.positives.values()].map(value => value.expiresAt)) + if (!Number.isFinite(expiresAt)) return + this.expiryTimer = setTimeout( + () => { + this.prune() + this.scheduleExpiry() + }, + Math.max(1, expiresAt - Date.now()) + ) + this.expiryTimer.unref?.() + } + + /** Payload-free local diagnostics; pending calls include abandoned, non-abortable I/O. */ + getStats(): { + pendingTransactions: number + consumers: number + cachedTransactions: number + retainedBytes: number + pendingChainCalls: number + activeAttempts: number + } { + this.prune() + return { + pendingTransactions: this.work.size, + consumers: this.consumers, + cachedTransactions: this.positives.size, + retainedBytes: this.retainedBytes, + pendingChainCalls: this.pendingChainCalls, + activeAttempts: this.activeAttempts + } + } +} diff --git a/packages/sdk/src/transaction/__tests/TransactionEvidenceCoordinator.test.ts b/packages/sdk/src/transaction/__tests/TransactionEvidenceCoordinator.test.ts new file mode 100644 index 000000000..456956973 --- /dev/null +++ b/packages/sdk/src/transaction/__tests/TransactionEvidenceCoordinator.test.ts @@ -0,0 +1,1085 @@ +import ChainTracker from '../ChainTracker' +import MerklePath from '../MerklePath' +import Transaction from '../Transaction' +import { TransactionEvidenceError } from '../TransactionEvidence' +import { TransactionEvidenceCoordinator } from '../TransactionEvidenceCoordinator' +import type BdkVerifierInterface from '../BdkVerifierInterface' +import type { BdkVerifyScriptsParams } from '../BdkVerifierInterface' +import P2PKH from '../../script/templates/P2PKH' +import PrivateKey from '../../primitives/PrivateKey' +import Script from '../../script/Script' +import Spend from '../../script/Spend' +import ScriptResourceLimitError from '../../script/ScriptResourceLimitError' + +const height = 700_000 + +interface Deferred { + promise: Promise + resolve: (value: T) => void + reject: (error: unknown) => void +} + +function deferred(): Deferred { + let resolve!: (value: T) => void + let reject!: (error: unknown) => void + const promise = new Promise((resolvePromise, rejectPromise) => { + resolve = resolvePromise + reject = rejectPromise + }) + return { promise, resolve, reject } +} + +class LocalChainTracker implements ChainTracker { + readonly roots = new Set() + readonly calls: Array<{ root: string; height: number }> = [] + context = 0 + current = height + 101 + gate: Promise | undefined + onRootCall: (() => void) | undefined + aborts = 0 + + async currentHeight(): Promise { + return this.current + } + + async isValidRootForHeight( + root: string, + blockHeight: number, + signal?: AbortSignal + ): Promise { + this.calls.push({ root, height: blockHeight }) + this.onRootCall?.() + if (signal != null) + signal.addEventListener( + 'abort', + () => { + this.aborts++ + }, + { once: true } + ) + if (this.gate !== undefined) await this.gate + if (signal?.aborted === true) throw new TransactionEvidenceError('cancelled') + return blockHeight === height && this.roots.has(root) + } + + getVerificationContext(): number { + return this.context + } +} + +class TokenChainTracker extends LocalChainTracker { + readonly rootsByHeight = new Map>() + readonly rootChecks = new Map() + token = 'tip-a' + tokenGate: Promise | undefined + tokenCalls = 0 + tokenAborts = 0 + tokenFailure: Error | undefined + onAnchorCheck: ((height: number, count: number) => void) | undefined + + addRoot(root: string, blockHeight: number): void { + const roots = this.rootsByHeight.get(blockHeight) ?? new Set() + roots.add(root) + this.rootsByHeight.set(blockHeight, roots) + } + + removeRoot(root: string, blockHeight: number): void { + this.rootsByHeight.get(blockHeight)?.delete(root) + } + + async isValidRootForHeight( + root: string, + blockHeight: number, + signal?: AbortSignal + ): Promise { + this.calls.push({ root, height: blockHeight }) + this.onRootCall?.() + if (signal != null) + signal.addEventListener( + 'abort', + () => { + this.aborts++ + }, + { once: true } + ) + if (this.gate !== undefined) await this.gate + if (signal?.aborted === true) throw new TransactionEvidenceError('cancelled') + const count = (this.rootChecks.get(blockHeight) ?? 0) + 1 + this.rootChecks.set(blockHeight, count) + const valid = this.rootsByHeight.get(blockHeight)?.has(root) === true + this.onAnchorCheck?.(blockHeight, count) + return valid + } + + async getVerificationContextToken(signal?: AbortSignal): Promise { + this.tokenCalls++ + if (signal != null) + signal.addEventListener( + 'abort', + () => { + this.tokenAborts++ + }, + { once: true } + ) + if (this.tokenGate !== undefined) await this.tokenGate + if (signal?.aborted === true) throw new TransactionEvidenceError('cancelled') + if (this.tokenFailure !== undefined) throw this.tokenFailure + return this.token + } +} + +async function fixture(): Promise<{ + tracker: LocalChainTracker + tx: Transaction + evidence: number[] +}> { + const key = new PrivateKey(42) + const p2pkh = new P2PKH() + const tracker = new LocalChainTracker() + const source = new Transaction() + source.addInput({ + sourceTXID: '00'.repeat(32), + sourceOutputIndex: 0, + unlockingScript: Script.fromASM('OP_TRUE') + }) + source.addOutput({ satoshis: 10, lockingScript: p2pkh.lock(key.toAddress()) }) + source.merklePath = new MerklePath(height, [ + [ + { offset: 0, hash: source.id('hex'), txid: true }, + { offset: 1, duplicate: true } + ] + ]) + tracker.roots.add(source.merklePath.computeRoot(source.id('hex'))) + + const tx = new Transaction() + tx.addInput({ + sourceTransaction: source, + sourceOutputIndex: 0, + unlockingScriptTemplate: p2pkh.unlock(key) + }) + tx.addOutput({ satoshis: 4, lockingScript: p2pkh.lock(key.toAddress()) }) + tx.addOutput({ satoshis: 4, lockingScript: p2pkh.lock(key.toAddress()) }) + await tx.sign() + return { tracker, tx, evidence: tx.toBEEF() } +} + +async function sharedAncestorFixture(): Promise<{ + tracker: LocalChainTracker + ancestor: Transaction + first: Transaction + second: Transaction + firstEvidence: number[] + secondEvidence: number[] +}> { + const key = new PrivateKey(43) + const p2pkh = new P2PKH() + const tracker = new LocalChainTracker() + const confirmed = new Transaction() + confirmed.addInput({ + sourceTXID: '00'.repeat(32), + sourceOutputIndex: 0, + unlockingScript: Script.fromASM('OP_TRUE') + }) + confirmed.addOutput({ satoshis: 20, lockingScript: p2pkh.lock(key.toAddress()) }) + confirmed.merklePath = new MerklePath(height, [ + [ + { offset: 0, hash: confirmed.id('hex'), txid: true }, + { offset: 1, duplicate: true } + ] + ]) + tracker.roots.add(confirmed.merklePath.computeRoot(confirmed.id('hex'))) + + const ancestor = new Transaction() + ancestor.addInput({ + sourceTransaction: confirmed, + sourceOutputIndex: 0, + unlockingScriptTemplate: p2pkh.unlock(key) + }) + ancestor.addOutput({ satoshis: 9, lockingScript: p2pkh.lock(key.toAddress()) }) + ancestor.addOutput({ satoshis: 9, lockingScript: p2pkh.lock(key.toAddress()) }) + await ancestor.sign() + + const createChild = async (outputIndex: number): Promise => { + const child = new Transaction() + child.addInput({ + sourceTransaction: ancestor, + sourceOutputIndex: outputIndex, + unlockingScriptTemplate: p2pkh.unlock(key) + }) + child.addOutput({ satoshis: 8, lockingScript: p2pkh.lock(key.toAddress()) }) + await child.sign() + return child + } + const first = await createChild(0) + const second = await createChild(1) + return { + tracker, + ancestor, + first, + second, + firstEvidence: first.toBEEF(), + secondEvidence: second.toBEEF() + } +} + +async function twoAnchorFixture(): Promise<{ + tracker: TokenChainTracker + tx: Transaction + evidence: number[] + roots: string[] +}> { + const key = new PrivateKey(44) + const p2pkh = new P2PKH() + const tracker = new TokenChainTracker() + const roots: string[] = [] + const sources: Transaction[] = [] + for (const blockHeight of [200, 100]) { + const source = new Transaction() + source.addInput({ + sourceTXID: blockHeight.toString(16).padStart(64, '0'), + sourceOutputIndex: 0, + unlockingScript: Script.fromASM('OP_TRUE') + }) + source.addOutput({ satoshis: 10, lockingScript: p2pkh.lock(key.toAddress()) }) + source.merklePath = new MerklePath(blockHeight, [ + [ + { offset: 0, hash: source.id('hex'), txid: true }, + { offset: 1, duplicate: true } + ] + ]) + const root = source.merklePath.computeRoot(source.id('hex')) + tracker.addRoot(root, blockHeight) + roots.push(root) + sources.push(source) + } + const tx = new Transaction() + for (const source of sources) { + tx.addInput({ + sourceTransaction: source, + sourceOutputIndex: 0, + unlockingScriptTemplate: p2pkh.unlock(key) + }) + } + tx.addOutput({ satoshis: 18, lockingScript: p2pkh.lock(key.toAddress()) }) + await tx.sign() + return { tracker, tx, evidence: tx.toBEEF(), roots } +} + +function verifyParamsInJavaScript(params: BdkVerifyScriptsParams): boolean { + const sigHashCache = { hashOutputsSingle: new Map() } + for (const [inputIndex, input] of params.tx.inputs.entries()) { + const source = input.sourceTransaction + const sourceOutput = source?.outputs[input.sourceOutputIndex] + if (source === undefined || sourceOutput === undefined || input.unlockingScript === undefined) + return false + const valid = new Spend({ + sourceTXID: input.sourceTXID ?? source.id('hex'), + sourceOutputIndex: input.sourceOutputIndex, + lockingScript: sourceOutput.lockingScript, + sourceSatoshis: sourceOutput.satoshis ?? 0, + transactionVersion: params.tx.version, + otherInputs: [], + allInputs: params.tx.inputs, + unlockingScript: input.unlockingScript, + inputSequence: input.sequence ?? 0xffffffff, + inputIndex, + outputs: params.tx.outputs, + lockTime: params.tx.lockTime, + memoryLimit: params.memoryLimit, + sigHashCache + }).validateJavaScript() + if (!valid) return false + } + return true +} + +function alternateReceipt(evidence: number[], marker: number): number[] { + const alternate = Transaction.fromBEEF(evidence) + const source = alternate.inputs[0].sourceTransaction + if (source === undefined) throw new Error('fixture source is missing') + source.merklePath = new MerklePath(height, [ + [ + { offset: 0, hash: source.id('hex'), txid: true }, + { offset: 1, hash: marker.toString(16).padStart(64, '0') } + ] + ]) + return alternate.toBEEF() +} + +function coordinator( + tracker: LocalChainTracker, + limits = {}, + verifier?: BdkVerifierInterface +): TransactionEvidenceCoordinator { + return new TransactionEvidenceCoordinator({ + chainTracker: tracker, + chainNamespace: 'local-canonical-chain', + policyId: 'p2pkh-consensus', + limits, + verifier + }) +} + +async function expectCode( + promise: Promise, + code: TransactionEvidenceError['code'] +): Promise { + await expect(promise).rejects.toMatchObject({ code }) +} + +describe('TransactionEvidenceCoordinator', () => { + it('coalesces concurrent output requests for one signed transaction into one full verification', async () => { + const { tracker, tx, evidence } = await fixture() + const verify = jest.spyOn(Transaction.prototype, 'verify') + const verifier: BdkVerifierInterface = { + supportsMemoryLimit: true, + verifyScripts: jest.fn(async () => true) + } + const subject = coordinator(tracker, {}, verifier) + + const [first, second] = await Promise.all([ + subject.verify({ beef: evidence, outputIndex: 0, txid: tx.id('hex') }), + subject.verify({ beef: evidence, outputIndex: 1, txid: tx.id('hex') }) + ]) + + expect(first).toMatchObject({ + txid: tx.id('hex'), + outputIndex: 0, + outpoint: `${tx.id('hex')}.0` + }) + expect(second).toMatchObject({ + txid: tx.id('hex'), + outputIndex: 1, + outpoint: `${tx.id('hex')}.1` + }) + expect(first.lockingScript.toHex()).toBe(tx.outputs[0].lockingScript.toHex()) + expect(tracker.calls).toContainEqual({ root: [...tracker.roots][0], height }) + expect(verify).toHaveBeenCalledTimes(1) + expect(verifier.verifyScripts).toHaveBeenCalledTimes(1) + expect(subject.getStats()).toMatchObject({ + pendingTransactions: 0, + consumers: 0, + activeAttempts: 0 + }) + verify.mockRestore() + }) + + it('does not permanently poison a transaction key after an invalid canonical root', async () => { + const { tracker, tx, evidence } = await fixture() + const root = [...tracker.roots][0] + tracker.roots.clear() + const subject = coordinator(tracker) + + await expectCode(subject.verify({ beef: evidence, outputIndex: 0 }), 'invalid-evidence') + tracker.roots.add(root) + await expect(subject.verify({ beef: evidence, outputIndex: 0 })).resolves.toMatchObject({ + txid: tx.id('hex') + }) + }) + + it('retains a distinct same-target receipt when a later proof repairs an earlier failed root', async () => { + const { tracker, evidence } = await fixture() + const alternate = Transaction.fromBEEF(evidence) + const source = alternate.inputs[0].sourceTransaction + if (source === undefined) throw new Error('fixture source is missing') + source.merklePath = new MerklePath(height, [ + [ + { offset: 0, hash: source.id('hex'), txid: true }, + { offset: 1, hash: '42'.repeat(32) } + ] + ]) + tracker.roots.clear() + tracker.roots.add(source.merklePath.computeRoot(source.id('hex'))) + const subject = coordinator(tracker) + + const [fromBadReceipt, fromGoodReceipt] = await Promise.all([ + subject.verify({ beef: evidence, outputIndex: 0 }), + subject.verify({ beef: alternate.toBEEF(), outputIndex: 1 }) + ]) + + expect(fromBadReceipt).toMatchObject({ outputIndex: 0 }) + expect(fromGoodReceipt).toMatchObject({ outputIndex: 1 }) + }) + + it('lets one consumer cancel while a coalesced consumer still receives the verified output', async () => { + const { tracker, evidence } = await fixture() + const release = deferred() + tracker.gate = release.promise + const subject = coordinator(tracker) + const abort = new AbortController() + const entered = deferred() + tracker.onRootCall = () => entered.resolve() + const cancelled = subject.verify({ beef: evidence, outputIndex: 0 }, { signal: abort.signal }) + const successful = subject.verify({ beef: evidence, outputIndex: 1 }) + + await entered.promise + abort.abort() + await expectCode(cancelled, 'cancelled') + // The shared chain call still has a live owner, so this cancellation cannot abort it. + expect(tracker.aborts).toBe(0) + release.resolve() + await expect(successful).resolves.toMatchObject({ outputIndex: 1 }) + }) + + it('aborts the last owner, leaves no late cache publication, and counts non-abortable work until it settles', async () => { + const { tracker, evidence } = await fixture() + const release = deferred() + tracker.gate = release.promise + const subject = coordinator(tracker) + const abort = new AbortController() + const entered = deferred() + tracker.onRootCall = () => entered.resolve() + const request = subject.verify({ beef: evidence, outputIndex: 0 }, { signal: abort.signal }) + + await entered.promise + abort.abort() + await expectCode(request, 'cancelled') + expect(tracker.aborts).toBe(1) + expect(subject.getStats().pendingChainCalls).toBe(1) + release.resolve() + await new Promise(resolve => setImmediate(resolve)) + expect(subject.getStats()).toMatchObject({ + cachedTransactions: 0, + pendingChainCalls: 0, + activeAttempts: 0 + }) + }) + + it('fences in-flight and cached work when namespace or policy context changes', async () => { + const { tracker, evidence } = await fixture() + const release = deferred() + tracker.gate = release.promise + const subject = coordinator(tracker) + const previous = subject.verify({ beef: evidence, outputIndex: 0 }) + + subject.setContext({ + chainTracker: tracker, + chainNamespace: 'replacement-chain', + policyId: 'replacement-policy' + }) + await expectCode(previous, 'context-changed') + release.resolve() + await expect(subject.verify({ beef: evidence, outputIndex: 0 })).resolves.toMatchObject({ + outputIndex: 0 + }) + }) + + it('rechecks cached canonical dependencies on a reorganization and retains a valid cache at a new tip', async () => { + const { tracker, evidence } = await fixture() + const root = [...tracker.roots][0] + const verify = jest.spyOn(Transaction.prototype, 'verify') + const verifier: BdkVerifierInterface = { + supportsMemoryLimit: true, + verifyScripts: jest.fn(async () => true) + } + const subject = coordinator(tracker, {}, verifier) + + await subject.verify({ beef: evidence, outputIndex: 0 }) + expect(verify).toHaveBeenCalledTimes(1) + expect(verifier.verifyScripts).toHaveBeenCalledTimes(1) + tracker.current++ + await expect(subject.verify({ beef: evidence, outputIndex: 0 })).resolves.toMatchObject({ + outputIndex: 0 + }) + expect(verify).toHaveBeenCalledTimes(1) + expect(verifier.verifyScripts).toHaveBeenCalledTimes(1) + tracker.roots.clear() + await expectCode(subject.verify({ beef: evidence, outputIndex: 0 }), 'invalid-evidence') + expect(verify).toHaveBeenCalledTimes(2) + // The rejected canonical dependency stops before target script execution. + expect(verifier.verifyScripts).toHaveBeenCalledTimes(1) + expect(tracker.calls).toContainEqual({ root, height }) + verify.mockRestore() + }) + + it('times out an attempt without poisoning a later valid receipt', async () => { + jest.useFakeTimers() + try { + const { tracker, evidence } = await fixture() + const release = deferred() + const entered = deferred() + tracker.gate = release.promise + tracker.onRootCall = () => entered.resolve() + const subject = coordinator(tracker, { attemptTimeoutMs: 10, requestTimeoutMs: 100 }) + const timedOut = subject.verify({ beef: evidence, outputIndex: 0 }) + + await entered.promise + await jest.advanceTimersByTimeAsync(11) + await expectCode(timedOut, 'timeout') + release.resolve() + await jest.runOnlyPendingTimersAsync() + tracker.gate = undefined + await expect(subject.verify({ beef: evidence, outputIndex: 0 })).resolves.toMatchObject({ + outputIndex: 0 + }) + } finally { + jest.useRealTimers() + } + }) + + it('drops an in-flight result when the tracker changes its verification context', async () => { + const { tracker, evidence } = await fixture() + const release = deferred() + tracker.gate = release.promise + const subject = coordinator(tracker) + const request = subject.verify({ beef: evidence, outputIndex: 0 }) + + tracker.context++ + release.resolve() + await expectCode(request, 'context-changed') + expect(subject.getStats().cachedTransactions).toBe(0) + }) + + it('snapshots bytes before deriving its key and rejects a mismatched txid hint', async () => { + const { tracker, tx, evidence } = await fixture() + const subject = coordinator(tracker) + const mutable = evidence.slice() + const request = subject.verify({ beef: mutable, outputIndex: 0, txid: tx.id('hex') }) + mutable.fill(0) + await expect(request).resolves.toMatchObject({ txid: tx.id('hex') }) + await expectCode( + subject.verify({ beef: evidence, outputIndex: 0, txid: '00'.repeat(32) }), + 'invalid-evidence' + ) + }) + + it('enforces finite admission, consumer, and cache-expiry limits', async () => { + const { tracker, evidence } = await fixture() + const tooSmall = coordinator(tracker, { candidateBytes: 1 }) + await expectCode(tooSmall.verify({ beef: evidence, outputIndex: 0 }), 'limit') + + const gate = deferred() + tracker.gate = gate.promise + const oneConsumer = coordinator(tracker, { consumers: 1 }) + const first = oneConsumer.verify({ beef: evidence, outputIndex: 0 }) + await expectCode(oneConsumer.verify({ beef: evidence, outputIndex: 1 }), 'limit') + gate.resolve() + await expect(first).resolves.toMatchObject({ outputIndex: 0 }) + + jest.useFakeTimers() + try { + const verify = jest.spyOn(Transaction.prototype, 'verify') + const expiring = coordinator(tracker, { cacheAgeMs: 1 }) + await expiring.verify({ beef: evidence, outputIndex: 0 }) + await jest.advanceTimersByTimeAsync(2) + await expiring.verify({ beef: evidence, outputIndex: 0 }) + expect(verify).toHaveBeenCalledTimes(2) + verify.mockRestore() + } finally { + jest.useRealTimers() + } + }) + + it('keeps a successful shared ancestor when a sibling non-batch script execution rejects', async () => { + const { tracker, ancestor, first, second, firstEvidence, secondEvidence } = + await sharedAncestorFixture() + const entered = deferred() + const release = deferred() + const verifier: BdkVerifierInterface = { + supportsMemoryLimit: true, + verifyScripts: async params => { + if (params.tx.id('hex') === ancestor.id('hex')) { + entered.resolve() + await release.promise + return true + } + if (params.tx.id('hex') === first.id('hex')) { + throw new ScriptResourceLimitError('stack', 1, 2) + } + return verifyParamsInJavaScript(params) + } + } + const subject = coordinator(tracker, {}, verifier) + const failed = subject.verify({ beef: firstEvidence, outputIndex: 0 }) + await entered.promise + const succeeded = subject.verify({ beef: secondEvidence, outputIndex: 0 }) + release.resolve() + + await expectCode(failed, 'limit') + await expect(succeeded).resolves.toMatchObject({ + txid: second.id('hex'), + outputIndex: 0 + }) + }) + + it('rejects every created batch entry when the backend batch call itself fails', async () => { + const { tracker, firstEvidence, secondEvidence } = await sharedAncestorFixture() + const entered = deferred() + const release = deferred() + const verifier: BdkVerifierInterface = { + supportsMemoryLimit: true, + verifyScripts: async () => true, + verifyScriptsBatch: async () => { + entered.resolve() + await release.promise + throw new ScriptResourceLimitError('stack', 1, 2) + } + } + const subject = coordinator(tracker, {}, verifier) + const first = subject.verify({ beef: firstEvidence, outputIndex: 0 }) + await entered.promise + const second = subject.verify({ beef: secondEvidence, outputIndex: 0 }) + release.resolve() + + await expectCode(first, 'limit') + await expectCode(second, 'limit') + }) + + it('runs a signed shared ancestor once while each distinct child still validates its own input', async () => { + const { tracker, first, second, firstEvidence, secondEvidence } = await sharedAncestorFixture() + const validate = jest.spyOn(Spend.prototype, 'validateJavaScript') + const subject = coordinator(tracker) + + await Promise.all([ + subject.verify({ beef: firstEvidence, outputIndex: 0 }), + subject.verify({ beef: secondEvidence, outputIndex: 0 }) + ]) + + expect(first.id('hex')).not.toBe(second.id('hex')) + expect(validate).toHaveBeenCalledTimes(3) + validate.mockRestore() + }) + + it('preserves backend batches while sharing the in-flight signed ancestor exactly once', async () => { + const { tracker, ancestor, first, second, firstEvidence, secondEvidence } = + await sharedAncestorFixture() + const validate = jest.spyOn(Spend.prototype, 'validateJavaScript') + const verifyScripts = jest.fn(async () => { + throw new Error('batch path required') + }) + const verifyScriptsBatch = jest.fn(async (params: readonly BdkVerifyScriptsParams[]) => + params.map(verifyParamsInJavaScript) + ) + const verifier: BdkVerifierInterface = { + supportsMemoryLimit: true, + verifyScripts, + verifyScriptsBatch + } + const subject = coordinator(tracker, {}, verifier) + + await Promise.all([ + subject.verify({ beef: firstEvidence, outputIndex: 0 }), + subject.verify({ beef: secondEvidence, outputIndex: 0 }) + ]) + + const submitted = verifyScriptsBatch.mock.calls.flatMap(([params]) => + params.map(param => param.tx.id('hex')) + ) + expect(verifyScripts).not.toHaveBeenCalled() + expect(verifyScriptsBatch.mock.calls.some(([params]) => params.length > 1)).toBe(true) + expect(submitted.filter(txid => txid === ancestor.id('hex'))).toHaveLength(1) + expect(submitted).toEqual(expect.arrayContaining([first.id('hex'), second.id('hex')])) + expect(validate).toHaveBeenCalledTimes(3) + validate.mockRestore() + }) + + it('keeps a shared ancestor alive when one distinct-target owner cancels', async () => { + const { tracker, ancestor, firstEvidence, secondEvidence } = await sharedAncestorFixture() + const entered = deferred() + const release = deferred() + const batches: string[][] = [] + const verifier: BdkVerifierInterface = { + supportsMemoryLimit: true, + verifyScripts: async () => false, + verifyScriptsBatch: async params => { + batches.push(params.map(param => param.tx.id('hex'))) + entered.resolve() + await release.promise + return params.map(verifyParamsInJavaScript) + } + } + const subject = coordinator(tracker, {}, verifier) + const abort = new AbortController() + const cancelled = subject.verify( + { beef: firstEvidence, outputIndex: 0 }, + { signal: abort.signal } + ) + const successful = subject.verify({ beef: secondEvidence, outputIndex: 0 }) + + await entered.promise + abort.abort() + await expectCode(cancelled, 'cancelled') + release.resolve() + await expect(successful).resolves.toMatchObject({ outputIndex: 0 }) + expect(batches.flat().filter(txid => txid === ancestor.id('hex'))).toHaveLength(1) + }) + + it('does not publish a late backend completion across a policy context change', async () => { + const { tracker, firstEvidence } = await sharedAncestorFixture() + const entered = deferred() + const release = deferred() + const verifier: BdkVerifierInterface = { + supportsMemoryLimit: true, + verifyScripts: async () => false, + verifyScriptsBatch: async params => { + entered.resolve() + await release.promise + return params.map(verifyParamsInJavaScript) + } + } + const subject = coordinator(tracker, {}, verifier) + const request = subject.verify({ beef: firstEvidence, outputIndex: 0 }) + + await entered.promise + subject.setContext({ + chainTracker: tracker, + chainNamespace: 'local-canonical-chain', + policyId: 'replacement-policy', + verifier + }) + await expectCode(request, 'context-changed') + release.resolve() + await new Promise(resolve => setImmediate(resolve)) + expect(subject.getStats()).toMatchObject({ cachedTransactions: 0, activeAttempts: 0 }) + await expect(subject.verify({ beef: firstEvidence, outputIndex: 0 })).resolves.toMatchObject({ + outputIndex: 0 + }) + }) + + it('rejects backend mutation of an owned verification parameter and accepts a later clean receipt', async () => { + const { tracker, firstEvidence } = await sharedAncestorFixture() + const entered = deferred() + const release = deferred() + let mutate = true + const verifier: BdkVerifierInterface = { + supportsMemoryLimit: true, + verifyScripts: async () => false, + verifyScriptsBatch: async params => { + entered.resolve() + await release.promise + if (mutate) { + const source = params[0].tx.inputs[0].sourceTransaction + if (source === undefined) throw new Error('fixture source is missing') + source.outputs[0].satoshis++ + } + return params.map(verifyParamsInJavaScript) + } + } + const subject = coordinator(tracker, {}, verifier) + const mutated = subject.verify({ beef: firstEvidence, outputIndex: 0 }) + + await entered.promise + release.resolve() + await expectCode(mutated, 'invalid-evidence') + mutate = false + await expect(subject.verify({ beef: firstEvidence, outputIndex: 0 })).resolves.toMatchObject({ + outputIndex: 0 + }) + }) + + it('does not free a non-abortable backend slot until its cancelled work actually settles', async () => { + const { tracker, firstEvidence, secondEvidence } = await sharedAncestorFixture() + const entered = deferred() + const release = deferred() + let calls = 0 + const verifier: BdkVerifierInterface = { + supportsMemoryLimit: true, + verifyScripts: async () => false, + verifyScriptsBatch: async params => { + calls++ + entered.resolve() + await release.promise + return params.map(verifyParamsInJavaScript) + } + } + const subject = coordinator(tracker, { concurrentTransactions: 1 }, verifier) + const abort = new AbortController() + const cancelled = subject.verify( + { beef: firstEvidence, outputIndex: 0 }, + { signal: abort.signal } + ) + + await entered.promise + abort.abort() + await expectCode(cancelled, 'cancelled') + const pending = subject.verify({ beef: secondEvidence, outputIndex: 0 }) + expect(subject.getStats()).toMatchObject({ pendingTransactions: 1, activeAttempts: 1 }) + expect(calls).toBe(1) + release.resolve() + await expect(pending).resolves.toMatchObject({ outputIndex: 0 }) + expect(calls).toBeGreaterThanOrEqual(2) + }) + + it('preflights approved duplicate and conflicting ancestry before warmed script work can be reused', async () => { + const { tracker, ancestor, firstEvidence, secondEvidence } = await sharedAncestorFixture() + const confirmed = ancestor.inputs[0].sourceTransaction + if (confirmed === undefined) throw new Error('fixture root is missing') + const key = new PrivateKey(43) + const p2pkh = new P2PKH() + const subject = coordinator(tracker) + const verify = jest.spyOn(Transaction.prototype, 'verify') + const validate = jest.spyOn(Spend.prototype, 'validateJavaScript') + await subject.verify({ beef: firstEvidence, outputIndex: 0 }) + const warmedVerifyCalls = verify.mock.calls.length + const warmedScriptCalls = validate.mock.calls.length + + const duplicate = new Transaction() + for (let index = 0; index < 2; index++) { + duplicate.addInput({ + sourceTransaction: confirmed, + sourceOutputIndex: 0, + unlockingScriptTemplate: p2pkh.unlock(key) + }) + } + duplicate.addOutput({ satoshis: 19, lockingScript: p2pkh.lock(key.toAddress()) }) + await duplicate.sign() + + const createConflictingParent = async (satoshis: number): Promise => { + const parent = new Transaction() + parent.addInput({ + sourceTransaction: confirmed, + sourceOutputIndex: 0, + unlockingScriptTemplate: p2pkh.unlock(key) + }) + parent.addOutput({ satoshis, lockingScript: p2pkh.lock(key.toAddress()) }) + await parent.sign() + return parent + } + const left = await createConflictingParent(9) + const right = await createConflictingParent(8) + const joined = new Transaction() + for (const sourceTransaction of [left, right]) { + joined.addInput({ + sourceTransaction, + sourceOutputIndex: 0, + unlockingScriptTemplate: p2pkh.unlock(key) + }) + } + joined.addOutput({ satoshis: 15, lockingScript: p2pkh.lock(key.toAddress()) }) + await joined.sign() + + await expectCode( + subject.verify({ beef: duplicate.toBEEF(), outputIndex: 0 }), + 'invalid-evidence' + ) + await expectCode(subject.verify({ beef: joined.toBEEF(), outputIndex: 0 }), 'invalid-evidence') + expect(verify).toHaveBeenCalledTimes(warmedVerifyCalls) + expect(validate).toHaveBeenCalledTimes(warmedScriptCalls) + await subject.verify({ beef: secondEvidence, outputIndex: 0 }) + expect(validate).toHaveBeenCalledTimes(warmedScriptCalls + 1) + verify.mockRestore() + validate.mockRestore() + }) + + it('rejects changed source bytes before a warmed target cache can be selected by a txid hint', async () => { + const { tracker, tx, evidence } = await fixture() + const subject = coordinator(tracker) + const verify = jest.spyOn(Transaction.prototype, 'verify') + await subject.verify({ beef: evidence, outputIndex: 0 }) + const altered = Transaction.fromBEEF(evidence) + const source = altered.inputs[0].sourceTransaction + if (source === undefined) throw new Error('fixture source is missing') + source.outputs[0].satoshis++ + + await expectCode( + subject.verify({ beef: altered.toBEEF(), outputIndex: 0, txid: tx.id('hex') }), + 'invalid-evidence' + ) + expect(verify).toHaveBeenCalledTimes(1) + await subject.verify({ beef: evidence, outputIndex: 0, txid: tx.id('hex') }) + expect(verify).toHaveBeenCalledTimes(1) + verify.mockRestore() + }) + + it('bounds candidates, complete graph shape, pending chain calls, and independent contexts', async () => { + const { tracker, evidence } = await fixture() + await expectCode( + coordinator(tracker, { transactions: 1 }).verify({ beef: evidence, outputIndex: 0 }), + 'limit' + ) + await expectCode( + coordinator(tracker, { inputs: 1 }).verify({ beef: evidence, outputIndex: 0 }), + 'limit' + ) + await expectCode( + coordinator(tracker, { scriptBytes: 1 }).verify({ beef: evidence, outputIndex: 0 }), + 'limit' + ) + + const release = deferred() + const entered = deferred() + tracker.gate = release.promise + tracker.onRootCall = () => entered.resolve() + const candidateLimited = coordinator(tracker, { candidatesPerTransaction: 8 }) + const controllers = Array.from({ length: 8 }, () => new AbortController()) + const requests = controllers.map((controller, index) => + candidateLimited.verify( + { beef: index === 0 ? evidence : alternateReceipt(evidence, index), outputIndex: 0 }, + { signal: controller.signal } + ) + ) + await entered.promise + await expectCode( + candidateLimited.verify({ beef: alternateReceipt(evidence, 9), outputIndex: 0 }), + 'limit' + ) + controllers.forEach(controller => controller.abort()) + await Promise.all(requests.map(async request => await expectCode(request, 'cancelled'))) + release.resolve() + + const second = await sharedAncestorFixture() + for (const root of second.tracker.roots) tracker.roots.add(root) + const chainRelease = deferred() + const chainEntered = deferred() + tracker.gate = chainRelease.promise + tracker.onRootCall = () => chainEntered.resolve() + const chainLimited = coordinator(tracker, { pendingChainCalls: 1 }) + const firstRequest = chainLimited.verify({ beef: evidence, outputIndex: 0 }) + await chainEntered.promise + await expectCode(chainLimited.verify({ beef: second.firstEvidence, outputIndex: 0 }), 'limit') + expect(chainLimited.getStats().pendingChainCalls).toBe(1) + chainRelease.resolve() + await expect(firstRequest).resolves.toMatchObject({ outputIndex: 0 }) + + const contexts = coordinator(tracker) + const contextVerify = jest.spyOn(Transaction.prototype, 'verify') + await contexts.verify({ beef: evidence, outputIndex: 0 }) + const replacement = new LocalChainTracker() + for (const root of tracker.roots) replacement.roots.add(root) + contexts.setContext({ + chainTracker: replacement, + chainNamespace: 'independent-network', + policyId: 'independent-policy' + }) + await contexts.verify({ beef: evidence, outputIndex: 0 }) + expect(contextVerify).toHaveBeenCalledTimes(2) + contextVerify.mockRestore() + }) + + it('rejects a two-height result when its token changes after the final H200 check and before H100 completes', async () => { + const { tracker, evidence, roots } = await twoAnchorFixture() + tracker.onAnchorCheck = (blockHeight, count) => { + if (blockHeight === 200 && count === 2) { + tracker.removeRoot(roots[0], 200) + tracker.token = 'tip-b' + } + } + const subject = coordinator(tracker) + + await expectCode(subject.verify({ beef: evidence, outputIndex: 0 }), 'context-changed') + expect(tracker.rootChecks.get(200)).toBe(2) + expect(tracker.rootChecks.get(100)).toBe(2) + expect(subject.getStats().cachedTransactions).toBe(0) + }) + + it('rejects an in-flight same-height root when the trusted token changes', async () => { + const { tracker, evidence } = await twoAnchorFixture() + const release = deferred() + const entered = deferred() + tracker.gate = release.promise + tracker.onRootCall = () => entered.resolve() + const subject = coordinator(tracker) + const request = subject.verify({ beef: evidence, outputIndex: 0 }) + + await entered.promise + tracker.token = 'tip-b' + release.resolve() + await expectCode(request, 'context-changed') + expect(subject.getStats().cachedTransactions).toBe(0) + }) + + it('keeps cached crypto work when a new canonical tip is already stable before the read begins', async () => { + const { tracker, evidence } = await twoAnchorFixture() + const verify = jest.spyOn(Transaction.prototype, 'verify') + const subject = coordinator(tracker) + + await subject.verify({ beef: evidence, outputIndex: 0 }) + tracker.token = 'tip-b' + await expect(subject.verify({ beef: evidence, outputIndex: 0 })).resolves.toMatchObject({ + outputIndex: 0 + }) + expect(verify).toHaveBeenCalledTimes(1) + expect(tracker.tokenCalls).toBeGreaterThanOrEqual(4) + verify.mockRestore() + }) + + it('fails closed when token acquisition fails, then permits a clean later recovery', async () => { + const { tracker, evidence } = await twoAnchorFixture() + tracker.tokenFailure = new Error('trusted provider unavailable') + const subject = coordinator(tracker) + + await expectCode(subject.verify({ beef: evidence, outputIndex: 0 }), 'invalid-evidence') + tracker.tokenFailure = undefined + await expect(subject.verify({ beef: evidence, outputIndex: 0 })).resolves.toMatchObject({ + outputIndex: 0 + }) + }) + + it('keeps token I/O counted after cancellation and never publishes its late completion', async () => { + const { tracker, evidence } = await twoAnchorFixture() + const release = deferred() + const entered = deferred() + tracker.tokenGate = release.promise + const originalToken = tracker.getVerificationContextToken.bind(tracker) + tracker.getVerificationContextToken = async signal => { + entered.resolve() + return await originalToken(signal) + } + const subject = coordinator(tracker) + const abort = new AbortController() + const request = subject.verify({ beef: evidence, outputIndex: 0 }, { signal: abort.signal }) + + await entered.promise + abort.abort() + await expectCode(request, 'cancelled') + expect(subject.getStats().pendingChainCalls).toBe(1) + release.resolve() + await new Promise(resolve => setImmediate(resolve)) + expect(tracker.tokenAborts).toBe(1) + expect(subject.getStats()).toMatchObject({ cachedTransactions: 0, pendingChainCalls: 0 }) + }) + + it('rejects a shouldVerifyScripts mutation before publication and accepts a clean retry', async () => { + const { tracker, firstEvidence } = await sharedAncestorFixture() + let mutate = true + const verifier: BdkVerifierInterface = { + supportsMemoryLimit: true, + shouldVerifyScripts: params => { + if (mutate) { + const source = params.tx.inputs[0]?.sourceTransaction + if (source === undefined) throw new Error('fixture source is missing') + source.outputs[0].satoshis++ + } + return true + }, + verifyScripts: async () => true + } + const subject = coordinator(tracker, {}, verifier) + + await expectCode(subject.verify({ beef: firstEvidence, outputIndex: 0 }), 'invalid-evidence') + mutate = false + await expect(subject.verify({ beef: firstEvidence, outputIndex: 0 })).resolves.toMatchObject({ + outputIndex: 0 + }) + }) + + it.each([ + ['the JavaScript interpreter', undefined], + [ + 'a single backend call', + { + supportsMemoryLimit: true, + verifyScripts: async () => { + throw new ScriptResourceLimitError('stack', 1, 2) + } + } satisfies BdkVerifierInterface + ], + [ + 'a batch backend call', + { + supportsMemoryLimit: true, + verifyScripts: async () => true, + verifyScriptsBatch: async () => { + throw new ScriptResourceLimitError('stack', 1, 2) + } + } satisfies BdkVerifierInterface + ] + ])( + 'maps script resource exhaustion from %s to the local limit error', + async (_path, verifier) => { + const { tracker, evidence } = await fixture() + const subject = coordinator(tracker, { scriptMemoryBytes: 1 }, verifier) + await expectCode(subject.verify({ beef: evidence, outputIndex: 0 }), 'limit') + } + ) +}) diff --git a/packages/sdk/src/transaction/index.ts b/packages/sdk/src/transaction/index.ts index 9950f03ef..accd1dbcd 100644 --- a/packages/sdk/src/transaction/index.ts +++ b/packages/sdk/src/transaction/index.ts @@ -1,16 +1,30 @@ export { default as Transaction } from './Transaction.js' -export type { default as BdkVerifierInterface, BdkVerifyScriptsParams } from './BdkVerifierInterface.js' +export type { + default as BdkVerifierInterface, + BdkVerifyScriptsParams +} from './BdkVerifierInterface.js' export * from './ScriptVerificationBackend.js' export { default as MerklePath } from './MerklePath.js' export type { default as TransactionInput } from './TransactionInput.js' export type { default as TransactionOutput } from './TransactionOutput.js' -export type { - Broadcaster, - BroadcastFailure, - BroadcastResponse -} from './Broadcaster.js' +export type { Broadcaster, BroadcastFailure, BroadcastResponse } from './Broadcaster.js' export { isBroadcastResponse, isBroadcastFailure } from './Broadcaster.js' export type { default as ChainTracker } from './ChainTracker.js' +export { TransactionEvidenceCoordinator } from './TransactionEvidenceCoordinator.js' +export type { + TransactionEvidenceContext, + TransactionEvidenceCoordinatorOptions +} from './TransactionEvidenceCoordinator.js' +export { + TransactionEvidenceError, + defaultTransactionEvidenceLimits +} from './TransactionEvidence.js' +export type { + TransactionEvidence, + VerifiedTransactionOutput, + TransactionEvidenceLimits, + TransactionEvidenceErrorCode +} from './TransactionEvidence.js' export { default as BeefTx } from './BeefTx.js' export * from './Beef.js' export { default as BeefParty } from './BeefParty.js' diff --git a/packages/wallet/wallet-toolbox/CHANGELOG.md b/packages/wallet/wallet-toolbox/CHANGELOG.md index a2ff7a339..c0a8cf2ff 100644 --- a/packages/wallet/wallet-toolbox/CHANGELOG.md +++ b/packages/wallet/wallet-toolbox/CHANGELOG.md @@ -7,15 +7,18 @@ attention to changes that materially alter behavior or extend functionality. ## wallet-toolbox (unreleased) - Secure final overlay identity discovery with independently verified transaction - evidence, byte-derived txid/hint checks, output bounds, the subject-signed - identity envelope, and explicit certificate success. Cached overlay responses - retain evidence only and repeat verification before returning identities. - Configure the existing wallet Services ChainTracker; direct `identityUtils` - callers must pass it explicitly. Local contacts, permission handling, wallet - RPC shapes, and current pagination behavior are retained. This security fix is - a patch in the full, browser, and mobile packages (2.11.1). No storage migration - is required. See [identity verification](docs/identity-verification.md) for the - supported token contract and remaining chain-cache/discovery limits. + evidence, byte-derived txid/hint checks, complete unconfirmed-graph + consistency, bounded resolver intake, the standard subject-signed identity + envelope, and explicit certificate success. C02/C03 add the exported SDK + evidence coordinator, optional chain-provider context markers, bounded + transaction/script work sharing, and revalidation of canonical anchors on + positive-cache use. Cached overlay receipts and decrypted certificates remain + wallet-owned, bounded, and separate from local contacts and trust policy. + Existing permission negotiation, pagination, wallet RPC shapes, and resolver + call forms remain characterized as compatible. See [identity verification](docs/identity-verification.md) + for the current contracts and limits. Package and packed-consumer validation + for the current source remains pending release review; historical C01 bundle + measurements are retained in that guide. - Keep Argon2id-backed UMP v3 wallets available in React Native and other runtimes without WebAssembly by falling back to an asynchronously yielding, diff --git a/packages/wallet/wallet-toolbox/README.md b/packages/wallet/wallet-toolbox/README.md index e140f2c1b..92f1fbd7b 100644 --- a/packages/wallet/wallet-toolbox/README.md +++ b/packages/wallet/wallet-toolbox/README.md @@ -426,14 +426,19 @@ does not apply schema changes. ### Overlay identity verification -Final identity discovery verifies transaction evidence against the wallet's -existing `Services.getChainTracker()` configuration before validating the -subject-signed identity output and certificate. Cached responses are reverified -before use. Direct `identityUtils` callers must supply a canonical `ChainTracker`; -missing context or invalid evidence produces no overlay identities. Local -contacts retain their separate policy. Inclusion does not establish unspentness -or freshness. See [identity verification](docs/identity-verification.md) for -configuration, compatibility, and remaining verification lifecycle limits. +Final identity discovery copies bounded resolver receipts, verifies their +transaction graph and canonical anchors with the wallet's existing +`Services.getChainTracker()`, and then validates the standard subject-signed +identity envelope and certificate. Transaction and certificate reuse remain +bounded and canonical evidence is rechecked before cached results are used; +fresh provider tokens bracket asynchronous anchor checks where the configured +tracker supplies them. Invalid candidate evidence is dropped, while typed +limit/timeout outcomes propagate to the caller. +Direct `identityUtils` callers must supply a canonical `ChainTracker`; missing +context or invalid evidence produces no overlay identities. Local contacts +retain their separate policy. Inclusion does not establish unspentness or +freshness. See [identity verification](docs/identity-verification.md) for +current C01/C02/C03 contracts, compatibility characterization, and limits. ```bash git clone https://github.com/bsv-blockchain/ts-stack.git diff --git a/packages/wallet/wallet-toolbox/client/README.md b/packages/wallet/wallet-toolbox/client/README.md index de07e7257..a3f935f25 100644 --- a/packages/wallet/wallet-toolbox/client/README.md +++ b/packages/wallet/wallet-toolbox/client/README.md @@ -126,12 +126,14 @@ CSP is an application and deployment concern rather than a package-level access ## Contributor checks -Final overlay identity discovery uses the configured wallet Services ChainTracker -to verify transaction evidence before returning certificate results, including -on response-cache hits. Local contacts retain their separate policy. See the -[identity verification guide](../docs/identity-verification.md) for configuration, -compatibility, and chain-cache limits; inclusion is not proof of freshness or -unspentness. +Final overlay identity discovery copies bounded resolver receipts, verifies the +complete transaction graph and canonical anchors with the configured wallet +Services ChainTracker, and validates the standard subject-signed certificate +envelope. Cached transaction evidence is rechecked before use; local contacts +retain their separate policy. See the [identity verification guide](../docs/identity-verification.md) +for C01/C02/C03 compatibility characterization and limits. Inclusion is not +proof of freshness or unspentness. Current package and packed-consumer +validation remains pending the release review. From the repository root, build the SDK and package before running the installed-consumer browser gate: diff --git a/packages/wallet/wallet-toolbox/docs/identity-verification.md b/packages/wallet/wallet-toolbox/docs/identity-verification.md index 40d036a81..fc2ddc437 100644 --- a/packages/wallet/wallet-toolbox/docs/identity-verification.md +++ b/packages/wallet/wallet-toolbox/docs/identity-verification.md @@ -1,106 +1,194 @@ # Identity overlay verification -This page records the bounded C01 source contract for identity results returned by the wallet toolbox. It is an inventory of the current implementation and its compatibility edges. It does not describe completion of the wider overlay reliability plan. +This page records the current C01, C02, and C03 source contract for identity +results returned by Wallet Toolbox. The implementation and its tests remain +under review; this page is not a claim that the wider verification plan or +release gates have completed. ## Evidence boundary -An overlay lookup result is a host-supplied `LookupAnswer`. The resolver permits an output to carry a transaction id hint, but the hint is metadata rather than chain authority. C01 treats each output as untrusted evidence and verifies the BEEF bytes and selected output with the wallet's configured `ChainTracker` before decoding an identity certificate. - -The shared transaction seam is [`verifyOverlayOutput`](../src/utility/verifyOverlayOutput.ts). It owns a copy of the BEEF bytes, derives the transaction id from those bytes, and rejects a supplied hint unless it matches that derived id. It checks the selected output and verifies the transaction graph through the caller's tracker. An unconfirmed branch must have ancestry; an input-free unconfirmed leaf cannot anchor an identity result. The preflight also rejects duplicate inputs and conflicting spends across distinct unconfirmed ancestors, while allowing a shared transaction to be traversed once. This is consistency of the supplied graph, not an external unspentness check. The generic helper has no network default, certificate policy, verdict cache, or identity-specific locking-key rule. - -The wallet obtains the tracker through [`WalletServicesOptions.chainTracker`](../src/sdk/WalletServices.interfaces.ts) and [`Services.getChainTracker`](../src/services/Services.ts). A configured tracker is the wallet's chain authority. The utility functions [`queryOverlay`](../src/utility/identityUtils.ts), [`parseResults`](../src/utility/identityUtils.ts), and [`parseResults$`](../src/utility/identityUtils.ts) accept an explicit optional tracker; when it is missing they fail closed with an empty result. This keeps a caller from accidentally treating an overlay response as verified. - -The identity parser then decodes the selected output as PushDrop, requires the subject-signed field payload to verify, requires `VerifiableCertificate.verify()` to return `true`, decrypts the public keyring, and requires nonempty decrypted fields. A cryptographic signature failure is already surfaced as an exception by [`ProtoWallet.verifySignature`](../../../sdk/src/wallet/ProtoWallet.ts), which the parser drops with the candidate. The earlier ignored boolean from certificate verification is therefore not an invalid-certificate bypass; C01 makes the success condition explicit while adding the missing transaction evidence checks. Candidate errors are not logged because parser/decryption exceptions may contain identity data. - -## Standard identity envelope - -The interoperable envelope is the one emitted by the default [`IdentityClientOptions`](../../../sdk/src/identity/types/index.ts): protocol ID `[1, 'identity']`, key ID `'1'`, token amount `1`, and output index `0`. [`IdentityClient.publiclyRevealAttributes`](../../../sdk/src/identity/IdentityClient.ts) creates the PushDrop output and broadcasts it to `tm_identity`. - -The current [`IdentityTopicManager`](../../../../packages/overlays/topics/src/identity/IdentityTopicManager.ts) verifies the same subject-signed PushDrop fields with protocol `[1, 'identity']` and key ID `'1'`, then checks certificate validity and nonempty decrypted attributes. C01 matches that actual server contract. `IdentityClientOptions` exposes custom protocol and key values, but the current topic manager does not accept arbitrary values: a non-default protocol ID or key ID is a compatibility hazard and can cause the topic to reject the output. C01 does not widen topic acceptance. - -There is no existing topic contract for comparing an identity certificate to an arbitrary `lockingPublicKey`, so C01 adds no such equality rule. The subject-signed envelope, certificate signature, successful decryption, and trusted-certifier policy remain the relevant checks. +An overlay result is a host-supplied `LookupAnswer`. A host TXID hint and +`context` are metadata, never chain authority. [`queryOverlayEvidence`](../src/utility/identityUtils.ts) +copies the BEEF and context bytes received through the additive resolver +`onEvidence` callback. Resolver callback intake defaults to 512 outputs and +16 MiB per query and accepts `evidenceLimits: { maxOutputs?, maxBytes? }` as +configurable local policy. It uses a 300 ms identity grace window. Older or +custom resolvers that do not invoke the callback remain usable through the +legacy output-list fallback. + +Direct `queryOverlayEvidence` callers can configure +`{ candidateBytes?, retainedBytes?, outputs? }`. To admit larger valid +evidence, pass matching byte limits to both this intake option and the third +`IdentityEvidenceVerifier` constructor argument (`TransactionEvidenceLimits`). +The resolver and verifier limits are separate admission boundaries and are not +consensus limits. + +[`TransactionEvidenceCoordinator`](../../../sdk/src/transaction/TransactionEvidenceCoordinator.ts) +parses the owned bytes, derives the selected transaction ID, compares any +supplied hint, checks the selected output, validates the complete unconfirmed +graph, and performs fresh canonical-root checks through the caller's +`ChainTracker`. It checks source transaction/value/script bindings, source TXID +consistency, duplicate spent outpoints, input/script limits, and rejects an +unconfirmed zero-input leaf. A successful result identifies a transaction +output and locking script; it does not establish service relevance, current +unspentness, ownership, or freshness. + +The coordinator's `chainNamespace` and `policyId` are explicit caller-owned +trust context. An optional synchronous +`ChainTracker.getVerificationContext(): string | number` is a provider, +policy, or recovery-generation marker. The optional +`getVerificationContextToken(signal?)` obtains a fresh trusted tip/context +token and brackets canonical-root and height checks. Chaintracks and local +adapters include monotonic reorganization/reset epochs and fence reset entry +and failure paths. `LocalChainTracker` tokens cover only the providers +participating in the current attempt: remote-only uses fallback identity, +local-primary uses local identity. An unused provider cannot stand in, and a +missing participating identity fails closed. Built-in remote ChainTracks +clients advertise reorg-event capability explicitly; method presence is not +capability. `Services.getChainTracker()` publishes one coalesced wrapper +before yielding. A remote tip equality read cannot detect an unobserved ABA +transition, and a token over multiple sources is not an atomic multi-source +snapshot. Canonical roots and observed heights remain dependencies that are +rechecked on each use, including positive-cache reuse. Existing trackers may +omit either optional method and may ignore optional abort signals. + +`EvidenceScriptWork` in the SDK now shares bounded script work by a binding that +covers the actual transaction, all input source-output bytes and values, and +script policy/backend parameters. The binding is transaction-wide rather than +an input-index-only key. A cache hit can skip cryptographic script execution +only. It still performs source/value binding, complete graph traversal, +canonical chain calls, and policy/context checks. Non-batch individual +executions preserve each entry's fulfilled or rejected result so a failed +sibling cannot reject a successful shared ancestor. Only a backend batch-level +failure rejects the whole batch. Non-abortable backend work remains counted +until its actual promise settles, and in-flight owners are isolated. A normal +block arrival before the next reuse does not erase exact cryptographic script +work when its transaction/source/policy binding remains valid; canonical roots +and fresh context are still checked. + +## Identity envelope + +The interoperable identity output is the standard subject-signed PushDrop +created by [`IdentityClient.publiclyRevealAttributes`](../../../sdk/src/identity/IdentityClient.ts): +protocol ID `[1, 'identity']`, key ID `'1'`, token amount `1`, and output index +`0`. [`IdentityTopicManager`](../../../../packages/overlays/topics/src/identity/IdentityTopicManager.ts) +checks the same protocol and key, then certificate validity and nonempty +attributes. `IdentityClientOptions` exposes custom protocol and key values, but +the current server topic does not accept arbitrary values; custom values remain +a compatibility characterization item and C01 does not widen server +acceptance. + +The parser checks the subject signature, requires `VerifiableCertificate.verify()` +to return `true`, decrypts the fields, and requires nonempty decrypted fields. +`ProtoWallet.verifySignature` throws on a cryptographic failure today; the +parser catches and rejects that candidate. C01 does not claim an invalid +certificate can bypass this throw. There is no existing topic contract for an +arbitrary `lockingPublicKey` equality rule, so none is added. ## Cache and contact boundaries -The wallet's overlay evidence cache is a two-minute response cache in [`Wallet.ts`](../src/Wallet.ts). Cached BEEF is revalidated on every use with the current tracker and certificate checks. If any candidate is rejected, the query entry is evicted so a later call can fetch fresh evidence. The cache is not a chain verdict and does not change local contact behavior. - -Local contacts are a separate wallet-owned source. [`Wallet.ts`](../src/Wallet.ts) synthesizes contact results with the local contact's subject/certifier relationship and local trust data. A contact hit may short-circuit the overlay path as before; it must retain local-contact provenance and must not be presented as an overlay SPV result. Contact lookup failures fall through to the network path. - -## Compatibility inventory and limits - -The public identity path spans the SDK wallet interfaces and clients, JSON and binary wallet transports, toolbox wallet managers, and [`IdentityClient`](../../../sdk/src/identity/IdentityClient.ts). C01 preserves those Promise method shapes and the `parseResults$` async-iterable shape. It does not change permission negotiation or pagination. The interface documentation says `seekPermission` defaults true, while the validator currently applies a false default; see [`Wallet.interfaces.ts`](../../../sdk/src/wallet/Wallet.interfaces.ts) and [`validationHelpers.ts`](../../../sdk/src/wallet/validationHelpers.ts). The wallet's current overlay calls also do not forward validated `limit` and `offset`, although the identity lookup service accepts them. These are compatibility characterization items for W00/W02, outside this C01 document. - -The resolver currently validates only the shape of a txid hint in [`LookupResolver.ts`](../../../sdk/src/overlay-tools/LookupResolver.ts). Its aggregation deduplicates by the hinted or derived txid and output index, keeping the first answer, and its fast path trusts a nonempty hint ([`LookupResolver.ts`](../../../sdk/src/overlay-tools/LookupResolver.ts), [`LookupResolver.ts`](../../../sdk/src/overlay-tools/LookupResolver.ts)). C02 owns the first-wins raw resolver suppression and pending full txid sharing work. C01 verifies the evidence that reaches the wallet; it cannot recover an alternate candidate discarded before parsing. - -The existing `ChaintracksChainTracker` still has its height-keyed root-cache and reorganization lifecycle. C01 makes no reorganization-safety claim; C03 covers that Chaintracks cache limitation and lifecycle work. C01 adds no workers, shared transaction jobs, response/graph budgets, or whole-attempt deadline. Large proofs still incur parsing and verification work on the calling runtime. These limits and canonical-context fencing require the later coordinator/runtime slices; no latency, reorganization safety, or deployment claim is made here. - -## Requirement and verification mapping - -The scoped source work supports requirement V1 (independent transaction evidence), the byte-binding portion of V2 (BEEF-derived txid must match any hint), and V5 (certificate success and trust policy). The planned characterization cases are T04 (false txid hint), T07 (confirmed/unconfirmed ancestry, scripts, values, and graph-internal conflicts), T09 (output/envelope/certificate validity), T10 (permission, cache, and local contacts), and T13 (Promise and pagination compatibility). This mapping is evidence for the C01 slice only; it is not a claim that the full verification plan has passed. - -## Configuration and migration - -This is a patch security correction in the 2.11.1 full, browser, and mobile -packages; the aggregate release-note candidate remains minor relative to the -recorded 2.10.4 published baseline. Wallet RPC and stored data need no migration. -Wallet builders keep their existing `Services` chain configuration. Its -`getChainTracker()` selects `options.chainTracker`, or wraps the configured -`options.chaintracks`; a configuration/availability error never becomes an -acceptance verdict. Use a chain source maintained independently of overlay hosts. - -Direct utility callers previously supplied only an answer. They must now pass -their canonical chain source: +The wallet keeps three distinct boundaries: + +- Transaction evidence is revalidated through the current chain tracker on + every use. Coordinator positive results are bounded by configurable generic + SDK limits, including 128 entries, 16 MiB retained bytes, and 60 seconds. +- Decrypted certificate JSON is wallet-owned and copied on return. Its cache is + bounded at 2 MiB, 128 entries, and 60 seconds. +- Raw overlay lookup receipts are copied into a wallet cache bounded at 32 + queries, 16 MiB, and 120 seconds. A rejected or incomplete parse evicts that + query entry so a later call can fetch again. + +The wallet obtains the tracker from `getServices().getChainTracker()` and +reuses the configured instance. Direct [`queryOverlay`](../src/utility/identityUtils.ts), +`parseResults`, and `parseResults$` calls fail closed when no tracker is +provided. [`verifyOverlayOutput`](../src/utility/verifyOverlayOutput.ts) +requires an explicit tracker. Local contacts remain a separate wallet-owned +source and retain their existing short-circuit and trust behavior; contact +results must retain local provenance and are not overlay SPV results. Invalid +candidate evidence is dropped, while typed `limit` and `timeout` outcomes +propagate to the wallet caller instead of becoming a definitive empty result. + +## Compatibility characterization + +C01, C02, and C03 preserve existing Promise and `parseResults$` async-iterable +shapes. The resolver's `onEvidence` callback is optional and additive. Legacy +resolver answers continue to aggregate by TXID/output index with first-wins +suppression; a host hint is only a resolver fast path and must be re-derived +and checked by security-sensitive consumers. The callback is the bounded path +for consumers that need each host receipt before that suppression. Existing +2-second lookup and 5-second tracker-wait defaults remain unchanged. + +Permission negotiation and pagination are unchanged. The interface +characterization still records that documentation says `seekPermission` +defaults to true while the validator currently applies false. Identity lookup +also retains the existing limit/offset validation and forwarding +characterization; this evidence work does not silently change either contract. + +The standard topic envelope characterization and custom `IdentityClientOptions` +behavior above remain required for interoperability. No arbitrary locking-key +equality rule is implied. Chaintracks and local adapters now expose monotonic +reorganization/reset epochs and fence reset lifecycle races, including reset +ownership checks after disposal and before a destructive hook. Remote tip +equality still cannot detect an unobserved ABA transition, and no adapter token +provides an atomic multi-source snapshot. C03 therefore makes no general +reorganization-safety claim. + +## Usage + +Wallet callers use the existing Services configuration: ```ts const tracker = await wallet.getServices().getChainTracker() const certificates = await parseResults(answer, tracker) ``` -Omitting the optional argument remains source-compatible but fails closed. -There is no bypass toggle. Failed candidate evidence evicts a wallet response -cache entry; failures never establish a permanent negative verdict for a txid. -Untrusted cached bytes may be shared within this wallet, but decrypted result -objects are rebuilt on each call and then passed through the existing trust -settings. The existing two-minute trust-settings snapshot policy is unchanged. +Omitting the utility tracker remains source-compatible but returns no verified +overlay identities. There is no bypass toggle. Wallet RPC shapes, stored data, +permission defaults, and pagination behavior require no migration from this +source work. + +On browser and React Native runtimes, `parseResults$` cooperatively yields +between certificates. This is a current-runtime scheduling behavior, with no +worker, throughput, latency, resource-isolation, or deployment guarantee. +C04/C05 work and any whole-plan completion claim remain future scope. + +## Requirement and verification mapping + +The scoped source supports V1 (independent transaction evidence), the +byte-binding portion of V2 (BEEF-derived TXID versus any hint), and V5 +(certificate success and trust policy). T04, T07, T09, T10, and T13 remain the +relevant characterization cases for false hints, graph and anchor checks, +envelope/certificate validity, permissions/cache/contacts, and Promise/ +pagination compatibility. The synthetic shared-ancestor fixture is 556 BEEF +bytes with three reachable transactions, three inputs, and 314 serialized +script bytes; two child graphs produce three actual script executions. Current +tests are evidence for these slices only; validation is not final until the +required package and consumer checks pass. -## Package size review +## Historical C01 bundle measurements -On 8 September 2026, the originating review task -`01a081b5-26d4-7ad1-8d85-243fe238d595` explicitly approved these measured C01 -budget adjustments under -[`governance/browser-artifact-policy.json`](../../../../governance/browser-artifact-policy.json). -The policy requires a versioned source change, composition evidence, and explicit -review. All three published artifacts advance from 2.11.0 to 2.11.1. Mandatory -transaction, graph, and identity checks remain in the portable bundles. +The following measurements are retained as historical C01 evidence for the +2.11.1 artifacts. They are measurements only, not passing platform gates and +not a release decision. C02/C03 source changes require fresh package and +consumer validation. Measurements used Node 24.15.0 and pnpm 10.33.2 on the same macOS host, with base commit `2bc799a8d8e535242e6de2d305f426ce3975ea7b` extracted into a temporary -source tree and built against the same unchanged SDK and dependency graph. -`pnpm build` ran in each base/current client and mobile package. A temporary -copy of the platform checker printed every size instead of evaluating budgets: -`node /tmp/c01-measure-baseline.mjs browser`, -`node /tmp/c01-measure-baseline.mjs mobile`, -`node /tmp/c01-measure-platform.mjs browser`, and -`node /tmp/c01-measure-platform.mjs mobile`. -These were **measurements only, not passing platform gates**. The original -platform checker was not modified. +source tree and the same SDK/dependency graph. A temporary copy of the +platform checker printed each size instead of evaluating budgets. The original +checker was not modified. Each cell lists raw / gzip / Brotli bytes: -| Consumer | Base | C01 | Reviewed maximum | -| -------- | --------------------------------- | --------------------------------- | --------------------------------- | -| Vite | 1,692,309 / 399,380 / 312,207 | 1,694,805 / 400,062 / 312,426 | 1,696,000 / 401,000 / 314,000 | -| esbuild | 1,320,184 / 363,792 / 291,500 | 1,322,211 / 364,400 / 291,926 | 1,324,000 / 365,000 / 293,000 | -| Metro | 1,747,262 / 443,100 / 343,780 | 1,749,640 / 443,811 / 343,927 | 1,751,000 / 455,000 / 360,000 | +| Consumer | Base | C01 | Reviewed maximum | +| -------- | --------------------------------: | --------------------------------: | --------------------------------: | +| Vite | 1,692,309 / 399,380 / 312,207 | 1,694,805 / 400,062 / 312,426 | 1,696,000 / 401,000 / 314,000 | +| esbuild | 1,320,184 / 363,792 / 291,500 | 1,322,211 / 364,400 / 291,926 | 1,324,000 / 365,000 / 293,000 | +| Metro | 1,747,262 / 443,100 / 343,780 | 1,749,640 / 443,811 / 343,927 | 1,751,000 / 455,000 / 360,000 | | Hermes | 3,544,570 / 1,440,174 / 1,117,759 | 3,550,004 / 1,442,134 / 1,120,813 | 3,553,000 / 1,443,000 / 1,123,000 | -Raw growth is 2,496 / 2,027 / 2,378 / 5,434 bytes respectively (about -0.14–0.15%). Vite composition retains 106 modules and the same packages: -`@bsv/sdk`, `@bsv/wallet-toolbox-client`, `@noble/hashes`, `hash-wasm`, and `idb`. -The esbuild module count remains 173. No new dependency or platform-only import -was added. Shared wallet cache/fetch logic removes duplication; independent -verification and subject-envelope checks account for the added code. Only -exceeded dimensions changed, rounded to preserve comparable existing margins; -all other limits stay fixed. This is a reviewed security-feature payload change, -not an analysis exception. Original `test:browser` and `test:mobile` commands -remain the executable gates and are recorded separately in the delivery ledger. +The historical C01 result measured raw growth of 2,496 / 2,027 / 2,378 / +5,434 bytes for Vite, esbuild, Metro, and Hermes respectively. The C01 +composition retained the same dependencies and did not add a platform-only +import. The original `test:browser` and `test:mobile` commands remain the +executable gates for any current release evidence. diff --git a/packages/wallet/wallet-toolbox/mobile/README.md b/packages/wallet/wallet-toolbox/mobile/README.md index 3dab43aa3..6fcfab919 100644 --- a/packages/wallet/wallet-toolbox/mobile/README.md +++ b/packages/wallet/wallet-toolbox/mobile/README.md @@ -85,12 +85,14 @@ Capability negotiation fails before prefunding against an older server. See ## Use cases -Final overlay identity discovery uses the configured wallet Services ChainTracker -to verify transaction evidence before returning certificate results, including -on response-cache hits. Local contacts retain their separate policy. See the -[identity verification guide](../docs/identity-verification.md) for configuration, -compatibility, and chain-cache limits; inclusion is not proof of freshness or -unspentness. +Final overlay identity discovery copies bounded resolver receipts, verifies the +complete transaction graph and canonical anchors with the configured wallet +Services ChainTracker, and validates the standard subject-signed certificate +envelope. Cached transaction evidence is rechecked before use; local contacts +retain their separate policy. See the [identity verification guide](../docs/identity-verification.md) +for C01/C02/C03 compatibility characterization and limits. Inclusion is not +proof of freshness or unspentness. Current package and packed-consumer +validation remains pending the release review. ### Self-custody BSV wallet on a phone diff --git a/packages/wallet/wallet-toolbox/src/Wallet.ts b/packages/wallet/wallet-toolbox/src/Wallet.ts index 65a8cedaf..e2d84c06d 100644 --- a/packages/wallet/wallet-toolbox/src/Wallet.ts +++ b/packages/wallet/wallet-toolbox/src/Wallet.ts @@ -81,7 +81,12 @@ import { createAction, CreateActionResultX } from './signer/methods/createAction import { signAction, SignActionResultX } from './signer/methods/signAction' import { internalizeAction } from './signer/methods/internalizeAction' import { WalletSettingsManager } from './WalletSettingsManager' -import { parseResults, queryOverlayEvidence, transformVerifiableCertificatesWithTrust } from './utility/identityUtils' +import { + IdentityEvidenceVerifier, + parseResults, + queryOverlayEvidence, + transformVerifiableCertificatesWithTrust +} from './utility/identityUtils' import { maxPossibleSatoshis } from './storage/methods/generateChange' import { hasBrc177NoSendExpiryLabel, parseBrc177NoSendExpiryLabels } from './utility/brc177NoSendExpiry' import { createNoSendExpiryAction } from './signer/methods/createNoSendExpiryAction' @@ -365,6 +370,10 @@ export class Wallet implements WalletInterface, ProtoWallet { } async destroy(): Promise { + this._identityEvidenceClosed = true + this._identityEvidenceVerifier?.dispose() + this._overlayEvidenceCache.clear() + clearTimeout(this._overlayEvidenceExpiryTimer) await this.actionBatch.abort() await this.storage.destroy() if (this.privilegedKeyManager != null) this.privilegedKeyManager.destroyKey() @@ -793,8 +802,31 @@ export class Wallet implements WalletInterface, ProtoWallet { trustSettings: Awaited>['trustSettings'] } - /** Two-minute untrusted response cache. Every use repeats transaction and identity checks. */ - private readonly _overlayEvidenceCache = new Map() + /** Bounded two-minute untrusted receipts. Every use rechecks canonical evidence. */ + private readonly _overlayEvidenceCache = new Map() + private _overlayEvidenceExpiryTimer?: ReturnType + private _identityEvidenceVerifier?: IdentityEvidenceVerifier + private _identityEvidenceClosed = false + + private pruneOverlayEvidence(): void { + for (const [key, value] of this._overlayEvidenceCache) { + if (value.expiresAt <= Date.now()) this._overlayEvidenceCache.delete(key) + } + } + + private scheduleOverlayEvidenceExpiry(): void { + clearTimeout(this._overlayEvidenceExpiryTimer) + const expiresAt = Math.min(...[...this._overlayEvidenceCache.values()].map(value => value.expiresAt)) + if (!Number.isFinite(expiresAt)) return + this._overlayEvidenceExpiryTimer = setTimeout( + () => { + this.pruneOverlayEvidence() + this.scheduleOverlayEvidenceExpiry() + }, + Math.max(1, expiresAt - Date.now()) + ) + this._overlayEvidenceExpiryTimer.unref?.() + } private async discoverOverlayCertificates( query: unknown, @@ -804,17 +836,50 @@ export class Wallet implements WalletInterface, ProtoWallet { ): Promise { // Use the wallet's existing network/chain configuration, never the overlay host's verdict. const chainTracker = await this.getServices().getChainTracker() + if (this._identityEvidenceClosed) return [] + const chainNamespace = `wallet:${this.chain}` + if ( + this._identityEvidenceVerifier?.chainTracker !== chainTracker || + this._identityEvidenceVerifier.chainNamespace !== chainNamespace + ) { + this._identityEvidenceVerifier?.dispose() + this._identityEvidenceVerifier = new IdentityEvidenceVerifier(chainTracker, chainNamespace) + } + const verifier = this._identityEvidenceVerifier + this.pruneOverlayEvidence() let cached = forceRefresh ? undefined : this._overlayEvidenceCache.get(cacheKey) if (cached == null || cached.expiresAt <= now) { const value = await queryOverlayEvidence(query, this.lookupResolver) - cached = { value, expiresAt: now + 2 * 60 * 1000 } + if (this._identityEvidenceClosed) return [] + const bytes = + value.type === 'output-list' + ? value.outputs.reduce((total, output) => total + output.beef.length + (output.context?.length ?? 0), 0) + : 0 + cached = { value, bytes, expiresAt: now + 2 * 60 * 1000 } + this._overlayEvidenceCache.delete(cacheKey) + let retained = [...this._overlayEvidenceCache.values()].reduce((total, entry) => total + entry.bytes, 0) + while ( + this._overlayEvidenceCache.size > 0 && + (this._overlayEvidenceCache.size >= 32 || retained + bytes > 16 * 1024 * 1024) + ) { + const oldest = this._overlayEvidenceCache.keys().next().value! + retained -= this._overlayEvidenceCache.get(oldest)!.bytes + this._overlayEvidenceCache.delete(oldest) + } this._overlayEvidenceCache.set(cacheKey, cached) + this.scheduleOverlayEvidenceExpiry() } if (cached.value.type !== 'output-list') { this._overlayEvidenceCache.delete(cacheKey) return [] } - const certificates = await parseResults(cached.value, chainTracker) + let certificates: VerifiableCertificate[] + try { + certificates = await parseResults(cached.value, chainTracker, verifier) + } catch (error) { + this._overlayEvidenceCache.delete(cacheKey) + throw error + } // Failed evidence must allow another fetch, including after temporary chain unavailability. if (certificates.length !== cached.value.outputs.length) this._overlayEvidenceCache.delete(cacheKey) return certificates @@ -1098,10 +1163,7 @@ export class Wallet implements WalletInterface, ProtoWallet { if (vargs.labels.includes(specOpThrowReviewActions)) throwDummyReviewActions() if (hasBrc177NoSendExpiryLabel(vargs.labels)) { - throw new WERR_INVALID_PARAMETER( - 'labels', - 'BRC-177 noSend expiry labels only on outgoing createAction requests' - ) + throw new WERR_INVALID_PARAMETER('labels', 'BRC-177 noSend expiry labels only on outgoing createAction requests') } const r = await internalizeAction(this, auth, args) diff --git a/packages/wallet/wallet-toolbox/src/__tests/Wallet.identityVerification.test.ts b/packages/wallet/wallet-toolbox/src/__tests/Wallet.identityVerification.test.ts index 23ce78fcc..a42bd6102 100644 --- a/packages/wallet/wallet-toolbox/src/__tests/Wallet.identityVerification.test.ts +++ b/packages/wallet/wallet-toolbox/src/__tests/Wallet.identityVerification.test.ts @@ -1,4 +1,13 @@ -import { KeyDeriver, LookupAnswer, LookupResolver, PrivateKey, Validation } from '@bsv/sdk' +import { + KeyDeriver, + LookupAnswer, + LookupResolver, + MerklePath, + PrivateKey, + Transaction, + Validation, + VerifiableCertificate +} from '@bsv/sdk' import { Wallet } from '../Wallet' import { WalletSettingsManager } from '../WalletSettingsManager' import { WalletStorageManager } from '../storage/WalletStorageManager' @@ -8,7 +17,7 @@ import { IdentityVerificationFixture } from '../utility/__tests__/identityVerification.fixtures' -function walletFor(fixture: IdentityVerificationFixture) { +function walletFor(fixture: IdentityVerificationFixture, resolver?: LookupResolver) { const keyDeriver = new KeyDeriver(new PrivateKey(15)) const trustSettings = { trustLevel: 1, @@ -27,7 +36,7 @@ function walletFor(fixture: IdentityVerificationFixture) { keyDeriver, storage: new WalletStorageManager(keyDeriver.identityKey), services: { getChainTracker } as unknown as WalletServices, - lookupResolver: { query } as unknown as LookupResolver, + lookupResolver: resolver ?? ({ query } as unknown as LookupResolver), settingsManager: { get: getSettings } as unknown as WalletSettingsManager }) return { wallet, query, getChainTracker, getSettings, trustSettings } @@ -74,6 +83,51 @@ describe('Wallet final identity verification and compatibility', () => { expect(query).toHaveBeenCalledTimes(1) }) + it('recovers a valid alternate receipt before legacy resolver merging and keeps certificate/trust caches isolated', async () => { + const badTransaction = Transaction.fromBEEF(fixture.certificateBEEF) + badTransaction.merklePath = new MerklePath(700_000, [ + [ + { offset: 0, hash: badTransaction.id('hex'), txid: true }, + { offset: 1, hash: '42'.repeat(32) } + ] + ]) + const badReceipt = badTransaction.toBEEF() + expect(Transaction.fromBEEF(badReceipt).id('hex')).toBe(fixture.certificateTransaction.id('hex')) + const firstHost = 'https://first.invalid-proof.example' + const secondHost = 'https://second.valid-proof.example' + const lookup = jest.fn(async (host: string) => + host === firstHost + ? { type: 'output-list' as const, outputs: [{ beef: badReceipt, outputIndex: 0 }] } + : { type: 'output-list' as const, outputs: [{ beef: fixture.certificateBEEF, outputIndex: 0 }] } + ) + const resolver = new LookupResolver({ + facilitator: { lookup }, + hostOverrides: { ls_identity: [firstHost, secondHost] } + }) + const { wallet, trustSettings } = walletFor(fixture, resolver) + const certificateVerify = jest.spyOn(VerifiableCertificate.prototype, 'verify') + const args = { identityKey: fixture.certificate.subject } + + const initial = await wallet.discoverByIdentityKey(args) + expect(initial).toMatchObject({ totalCertificates: 1 }) + expect(lookup).toHaveBeenCalledTimes(2) + expect(certificateVerify).toHaveBeenCalledTimes(1) + + initial.certificates[0].decryptedFields.name = 'Mutated caller copy' + trustSettings.trustLevel = 2 + await expect(wallet.discoverByIdentityKey(args)).resolves.toEqual({ totalCertificates: 0, certificates: [] }) + expect(certificateVerify).toHaveBeenCalledTimes(1) + + trustSettings.trustLevel = 1 + const restored = await wallet.discoverByIdentityKey(args) + expect(restored.certificates[0].decryptedFields).toEqual({ name: 'Alice' }) + // The rejected receipt makes the bounded raw-receipt cache retry, but the + // verified certificate is still reused across trust-only refiltering. + expect(lookup).toHaveBeenCalledTimes(6) + expect(certificateVerify).toHaveBeenCalledTimes(1) + certificateVerify.mockRestore() + }) + it('keeps trust filtering and forceRefresh on the final Promise API', async () => { const { wallet, query, trustSettings } = walletFor(fixture) const args = { identityKey: fixture.certificate.subject } @@ -161,13 +215,21 @@ describe('Wallet final identity verification and compatibility', () => { query: { identityKey: fixture.certificate.subject, certifiers: [fixture.certificate.certifier] } }, undefined, - { graceMs: 300 } + { + graceMs: 300, + evidenceLimits: { maxOutputs: 512, maxBytes: 16 * 1024 * 1024 }, + onEvidence: expect.any(Function) + } ) await wallet.discoverByAttributes({ attributes: { name: 'Alice' }, limit: 1, offset: 3, seekPermission: true }) expect(query).toHaveBeenLastCalledWith( { service: 'ls_identity', query: { attributes: { name: 'Alice' }, certifiers: [fixture.certificate.certifier] } }, undefined, - { graceMs: 300 } + { + graceMs: 300, + evidenceLimits: { maxOutputs: 512, maxBytes: 16 * 1024 * 1024 }, + onEvidence: expect.any(Function) + } ) await expect(wallet.discoverByIdentityKey({ ...identityArgs, limit: 10_001 })).rejects.toThrow() await expect(wallet.discoverByAttributes({ attributes: {}, offset: -1 })).rejects.toThrow() diff --git a/packages/wallet/wallet-toolbox/src/services/Services.ts b/packages/wallet/wallet-toolbox/src/services/Services.ts index 50cb4d8e4..677e6409c 100644 --- a/packages/wallet/wallet-toolbox/src/services/Services.ts +++ b/packages/wallet/wallet-toolbox/src/services/Services.ts @@ -48,6 +48,8 @@ import { asArray, asString } from '../utility/utilityHelpers.noBuffer' import { classifyOutputUtxo, requireConclusiveUtxo } from './classifyOutputUtxo' export class Services implements WalletServices { + private identityChainTracker?: ChaintracksChainTracker + private identityChainTrackerInit?: Promise static readonly getStatusForTxidsBatchLimit = 20 static createDefaultOptions(chain: Chain): WalletServicesOptions { @@ -229,16 +231,36 @@ export class Services implements WalletServices { } async getChainTracker(): Promise { - if (this.options.chainTracker != null) return this.options.chainTracker - if (this.options.chaintracks == null) { - throw new WERR_INVALID_PARAMETER( - 'options.chainTracker or options.chaintracks', - "valid to enable 'getChainTracker' service." - ) + while (true) { + if (this.options.chainTracker != null) return this.options.chainTracker + if (this.options.chaintracks == null) { + throw new WERR_INVALID_PARAMETER( + 'options.chainTracker or options.chaintracks', + "valid to enable 'getChainTracker' service." + ) + } + const desired = this.options.chaintracks + if (this.identityChainTracker?.chaintracks === desired) return this.identityChainTracker + if (this.identityChainTrackerInit != null) { + await this.identityChainTrackerInit.catch(() => undefined) + continue + } + const previous = this.identityChainTracker + const created = new ChaintracksChainTracker(this.chain, desired, { + telemetry: this.options.telemetry + }) + this.identityChainTracker = created + const init = Promise.resolve().then(async () => { + if (previous != null) await previous.dispose() + return created + }) + this.identityChainTrackerInit = init + try { + await init.catch(() => created) + } finally { + if (this.identityChainTrackerInit === init) this.identityChainTrackerInit = undefined + } } - return new ChaintracksChainTracker(this.chain, this.options.chaintracks, { - telemetry: this.options.telemetry - }) } async getBsvExchangeRate(): Promise { diff --git a/packages/wallet/wallet-toolbox/src/services/__tests/verifyBeef.test.ts b/packages/wallet/wallet-toolbox/src/services/__tests/verifyBeef.test.ts index 58238d326..d8576e43e 100644 --- a/packages/wallet/wallet-toolbox/src/services/__tests/verifyBeef.test.ts +++ b/packages/wallet/wallet-toolbox/src/services/__tests/verifyBeef.test.ts @@ -64,6 +64,35 @@ describe('verifyBeef tests', () => { await expect(services.getChainTracker()).resolves.toBe(chainTracker) }) + test('coalesces concurrent getChainTracker lookups onto one wrapper', async () => { + const services = new Services('main') + const [first, second] = await Promise.all([services.getChainTracker(), services.getChainTracker()]) + expect(first).toBe(second) + await expect(services.getChainTracker()).resolves.toBe(first) + }) + + test('publishes one replacement before disposing a mismatched tracker', async () => { + const firstClient = { + getPresentHeight: jest.fn(async () => 1), + findHeaderForHeight: jest.fn(), + findChainTipHash: jest.fn(async () => 'aa'.repeat(32)) + } + const secondClient = { + getPresentHeight: jest.fn(async () => 2), + findHeaderForHeight: jest.fn(), + findChainTipHash: jest.fn(async () => 'bb'.repeat(32)) + } + const options = { ...Services.createDefaultOptions('main'), chaintracks: firstClient as any } + const services = new Services(options) + const original = await services.getChainTracker() + + services.options.chaintracks = secondClient as any + const [first, second] = await Promise.all([services.getChainTracker(), services.getChainTracker()]) + expect(first).toBe(second) + expect(first).not.toBe(original) + await expect(services.getChainTracker()).resolves.toBe(first) + }) + test('1_', async () => { if (_tu.noEnv('main')) return const { env: _env, storage, services } = await _tu.createMainReviewSetup() diff --git a/packages/wallet/wallet-toolbox/src/services/chaintracker/BHServiceClient.ts b/packages/wallet/wallet-toolbox/src/services/chaintracker/BHServiceClient.ts index 9ca723e86..b5bec6a09 100644 --- a/packages/wallet/wallet-toolbox/src/services/chaintracker/BHServiceClient.ts +++ b/packages/wallet/wallet-toolbox/src/services/chaintracker/BHServiceClient.ts @@ -24,6 +24,8 @@ interface BHSHeaderState { } export class BHServiceClient implements ChaintracksServiceClient { + /** HTTP polling client; callback event methods are legacy unsupported stubs. */ + readonly supportsReorgEvents = false bhs: BlockHeadersService cache: Record chain: Chain diff --git a/packages/wallet/wallet-toolbox/src/services/chaintracker/ChaintracksChainTracker.ts b/packages/wallet/wallet-toolbox/src/services/chaintracker/ChaintracksChainTracker.ts index d953c7d06..0a90d1ad8 100644 --- a/packages/wallet/wallet-toolbox/src/services/chaintracker/ChaintracksChainTracker.ts +++ b/packages/wallet/wallet-toolbox/src/services/chaintracker/ChaintracksChainTracker.ts @@ -13,56 +13,174 @@ export interface ChaintracksChainTrackerOptions { } export class ChaintracksChainTracker implements ChainTracker { - chaintracks: ChaintracksClientApi + private _chaintracks: ChaintracksClientApi + private verificationContext = 0 + private reorgEpoch = 0 + private observerGeneration = 0 + private reorgSubscription?: string + private reorgProvider?: ChaintracksClientApi + private reorgSetup?: Promise + private readonly cacheUpdatedAt: Record = {} cache: Record options: ChaintracksChainTrackerOptions readonly telemetry: Telemetry constructor(chain?: Chain, chaintracks?: ChaintracksClientApi, options?: ChaintracksChainTrackerOptions) { chain ||= 'main' - this.chaintracks = + this._chaintracks = chaintracks ?? new ChaintracksServiceClient(chain, `https://${chain}net-chaintracks.babbage.systems`) this.cache = {} this.options = options || {} this.telemetry = new Telemetry(this.options.telemetry) } - async currentHeight(): Promise { - if (!this.telemetry.enabled) return await this.chaintracks.getPresentHeight() + get chaintracks(): ChaintracksClientApi { + return this._chaintracks + } + + set chaintracks(value: ChaintracksClientApi) { + if (value === this._chaintracks) return + this._chaintracks = value + this.verificationContext++ + this.observerGeneration++ + this.cache = {} + for (const height of Object.keys(this.cacheUpdatedAt)) delete this.cacheUpdatedAt[Number(height)] + void this.releaseReorgListener() + } + + /** Local provider-generation marker for consumers that invalidate derived verdicts. */ + getVerificationContext(): string { + return `chaintracks:${this.verificationContext}:${this.reorgEpoch}:${this.observerGeneration}` + } + + /** Fresh provider-bound canonical tip token; unlike the stable context, normal tip advances change it. */ + async getVerificationContextToken(signal?: AbortSignal): Promise { + this.throwIfAborted(signal) + const chaintracks = this.chaintracks + const context = this.verificationContext + const marker = this.getVerificationContext() + await this.ensureReorgListener(chaintracks, context) + this.throwIfAborted(signal) + if (this.getVerificationContext() !== marker) + throw new Error('Chaintracks provider changed during event registration') + const tip = await chaintracks.findChainTipHash() + this.throwIfAborted(signal) + if ( + this.chaintracks !== chaintracks || + this.verificationContext !== context || + this.getVerificationContext() !== marker + ) { + throw new Error('Chaintracks provider changed during canonical token lookup') + } + return JSON.stringify([marker, tip]) + } + + async dispose(): Promise { + this.observerGeneration++ + await this.releaseReorgListener() + } + + private reorgSubscribe(provider: ChaintracksClientApi): ((listener: unknown) => Promise) | undefined { + if (provider.supportsReorgEvents === false) return undefined + const events = provider as ChaintracksClientApi & { + subscribeReorgs?: (listener: unknown) => Promise + } + if (typeof events.subscribeReorgs === 'function') return async listener => await events.subscribeReorgs!(listener) + if (provider.supportsReorgEvents === true) { + throw new Error('Chaintracks provider promised reorg events but subscribeReorgs is not implemented') + } + return undefined + } + + private async ensureReorgListener(provider: ChaintracksClientApi, context: number): Promise { + const subscribe = this.reorgSubscribe(provider) + if (subscribe == null) return + if (this.reorgProvider === provider && this.reorgSubscription != null) return + if (this.reorgSetup != null) { + await this.reorgSetup + if (this.chaintracks !== provider || this.verificationContext !== context) + throw new Error('Chaintracks provider changed during event registration') + if (this.reorgProvider === provider && this.reorgSubscription != null) return + } + const generation = this.observerGeneration + this.reorgSetup = (async () => { + const subscription = await subscribe(() => { + if ( + this.chaintracks === provider && + this.verificationContext === context && + this.observerGeneration === generation + ) + this.reorgEpoch++ + }) + if ( + this.chaintracks !== provider || + this.verificationContext !== context || + this.observerGeneration !== generation + ) { + await provider.unsubscribe(subscription).catch(() => undefined) + return + } + this.reorgProvider = provider + this.reorgSubscription = subscription + })() + try { + await this.reorgSetup + } finally { + this.reorgSetup = undefined + } + } + + private async releaseReorgListener(): Promise { + const provider = this.reorgProvider + const subscription = this.reorgSubscription + this.reorgProvider = undefined + this.reorgSubscription = undefined + if (provider != null && subscription != null) await provider.unsubscribe(subscription).catch(() => undefined) + } + + async currentHeight(signal?: AbortSignal): Promise { + this.throwIfAborted(signal) + const chaintracks = this.chaintracks + const context = this.verificationContext + const currentHeight = async (): Promise => { + const height = await chaintracks.getPresentHeight() + this.throwIfAborted(signal) + if (this.chaintracks !== chaintracks || this.verificationContext !== context) + throw new Error('Chaintracks provider changed during height lookup') + return height + } + if (!this.telemetry.enabled) return await currentHeight() return await this.telemetry.withSpan( 'wallet.chaintracks.current_height', { component: 'chaintracks-chain-tracker', kind: 'client' }, - async () => await this.chaintracks.getPresentHeight() + async () => await currentHeight() ) } - async isValidRootForHeight(root: string, height: number): Promise { - if (!this.telemetry.enabled) return await this.isValidRootForHeightCore(root, height) + async isValidRootForHeight(root: string, height: number, signal?: AbortSignal): Promise { + this.throwIfAborted(signal) + if (!this.telemetry.enabled) return await this.isValidRootForHeightCore(root, height, undefined, signal) return await this.telemetry.withSpan( 'wallet.chaintracks.validate_root', { component: 'chaintracks-chain-tracker', kind: 'client' }, - async span => await this.isValidRootForHeightCore(root, height, span) + async span => await this.isValidRootForHeightCore(root, height, span, signal) ) } - private async isValidRootForHeightCore(root: string, height: number, parent?: TelemetrySpan): Promise { - const cachedRoot = this.cache[height] - if (cachedRoot) { - parent?.end({ - attributes: { - 'chaintracks.cache_hit': true, - 'chaintracks.valid': cachedRoot === root - } - }) - return cachedRoot === root - } - + private async isValidRootForHeightCore( + root: string, + height: number, + parent?: TelemetrySpan, + signal?: AbortSignal + ): Promise { + const chaintracks = this.chaintracks + const context = this.verificationContext let header: BlockHeader | undefined const retries = Math.max(1, this.options.maxRetries ?? 6) @@ -72,9 +190,13 @@ export class ChaintracksChainTracker implements ChainTracker { for (let tryCount = 1; tryCount <= retries; tryCount++) { try { + this.throwIfAborted(signal) + if (this.chaintracks !== chaintracks || this.verificationContext !== context) { + throw new Error('Chaintracks provider changed during header lookup') + } header = parent == null - ? await this.chaintracks.findHeaderForHeight(height) + ? await chaintracks.findHeaderForHeight(height) : await this.telemetry.withSpan( 'wallet.chaintracks.find_header', { @@ -85,28 +207,47 @@ export class ChaintracksChainTracker implements ChainTracker { 'retry.attempt': tryCount } }, - async () => await this.chaintracks.findHeaderForHeight(height) + async () => await chaintracks.findHeaderForHeight(height) ) + if (this.chaintracks !== chaintracks || this.verificationContext !== context) { + throw new Error('Chaintracks provider changed during header lookup') + } + if (header == null) { if (tryCount >= retries) return false + this.throwIfAborted(signal) await wait(retryDelayMs) continue } break } catch (error_: unknown) { + this.throwIfAborted(signal) + if (this.chaintracks !== chaintracks || this.verificationContext !== context) { + throw new Error('Chaintracks provider changed during header lookup') + } error = WalletError.fromUnknown(error_) if (tryCount >= retries) { throw error } + this.throwIfAborted(signal) await wait(retryDelayMs) } } if (header == null) return false + // Diagnostic only: a root is always freshly read from the current canonical source above. this.cache[height] = header.merkleRoot + this.cacheUpdatedAt[height] = Date.now() + const now = Date.now() + const entries = Object.entries(this.cacheUpdatedAt).sort(([, a], [, b]) => a - b) + for (const [index, [cachedHeight, updatedAt]] of entries.entries()) { + if (index >= entries.length - 256 && now - updatedAt <= 5 * 60 * 1000) continue + delete this.cache[Number(cachedHeight)] + delete this.cacheUpdatedAt[Number(cachedHeight)] + } const valid = header.merkleRoot === root parent?.end({ @@ -117,4 +258,8 @@ export class ChaintracksChainTracker implements ChainTracker { }) return valid } + + private throwIfAborted(signal?: AbortSignal): void { + if (signal?.aborted === true) throw signal.reason ?? new Error('Chaintracks verification aborted') + } } diff --git a/packages/wallet/wallet-toolbox/src/services/chaintracker/LocalChainTracker.ts b/packages/wallet/wallet-toolbox/src/services/chaintracker/LocalChainTracker.ts index 900c8e28e..9267eb758 100644 --- a/packages/wallet/wallet-toolbox/src/services/chaintracker/LocalChainTracker.ts +++ b/packages/wallet/wallet-toolbox/src/services/chaintracker/LocalChainTracker.ts @@ -80,6 +80,12 @@ export class LocalChainTracker implements ChainTracker { private readonly recoverLocal: LocalChainTrackerOptions['recoverLocal'] private readonly clearLocal: LocalChainTrackerOptions['clearLocal'] private readonly now: () => Date + private verificationContext = 0 + private resetting = false + private eventEpoch = 0 + private observerGeneration = 0 + private readonly eventSubscriptions = new Map() + private readonly eventSetups = new Map>() private status: LocalChainTrackerStatus constructor(options: LocalChainTrackerOptions) { @@ -111,6 +117,7 @@ export class LocalChainTracker implements ChainTracker { } setMode(mode: LocalChainTrackerMode): void { + if (this.status.mode !== mode) this.verificationContext++ this.status = { ...this.status, mode, @@ -118,6 +125,46 @@ export class LocalChainTracker implements ChainTracker { } } + /** Local mode/source generation marker for consumers that invalidate derived verdicts. */ + getVerificationContext(): string { + return `local-chaintracks:${this.sourceContext()}` + } + + /** + * Fresh token for providers participating in this attempt's canonical authority. + * Remote-only uses fallbacks only; local-primary uses the local client only. + * A missing participating identity fails closed. Unused providers are omitted + * and cannot stand in. This is not an atomic multi-source snapshot. + */ + async getVerificationContextToken(signal?: AbortSignal): Promise { + const context = this.sourceContext() + this.throwIfAborted(signal) + this.assertCurrentContext(context, 'canonical token lookup') + const sources = this.attemptSources() + if (sources.length === 0) throw new Error('No canonical ChainTracks source is available') + await this.ensureEventObservers(sources) + const tips = await Promise.all(sources.map(async source => await source.findChainTipHash())) + this.throwIfAborted(signal) + this.assertCurrentContext(context, 'canonical token lookup') + if (!this.sameSources(sources, this.attemptSources())) { + throw new Error('Local ChainTracks provider changed during canonical token lookup') + } + if (tips.some(tip => tip == null || tip === '')) { + throw new Error('Canonical ChainTracks source identity is unavailable') + } + return JSON.stringify({ context, eventEpoch: this.eventEpoch, tips }) + } + + async dispose(): Promise { + this.observerGeneration++ + await Promise.all( + Array.from(this.eventSubscriptions.entries()).map(async ([source, subscription]) => { + this.eventSubscriptions.delete(source) + await source.unsubscribe(subscription).catch(() => undefined) + }) + ) + } + getStatus(): LocalChainTrackerStatus { return { ...this.status } } @@ -127,32 +174,48 @@ export class LocalChainTracker implements ChainTracker { } async currentHeight(): Promise { + const context = this.sourceContext() + this.assertCurrentContext(context, 'height lookup') if (this.status.mode === 'remote-only') { - return await this.fallbackHeight() + const height = await this.fallbackHeight() + this.assertCurrentContext(context, 'height lookup') + return height } + const local = this.local try { - const height = await this.local.getPresentHeight() + const height = await local.getPresentHeight() + this.assertCurrentContext(context, 'height lookup') this.status = { ...this.status, activeSource: 'local', localHeight: height, lastError: undefined } return height } catch (error) { + this.assertCurrentContext(context, 'height lookup') this.recordError(error) if (!this.fallbackOnLocalError) throw error - return await this.fallbackHeight() + const height = await this.fallbackHeight() + this.assertCurrentContext(context, 'height lookup') + return height } } async isValidRootForHeight(root: string, height: number): Promise { + const context = this.sourceContext() + this.assertCurrentContext(context, 'root validation') if (this.status.mode !== 'remote-only') { + const local = this.local try { - const valid = await this.local.isValidRootForHeight(root, height) + const valid = await local.isValidRootForHeight(root, height) + this.assertCurrentContext(context, 'root validation') this.status = { ...this.status, activeSource: 'local', lastError: undefined } return valid } catch (error) { + this.assertCurrentContext(context, 'root validation') this.recordError(error) if (!this.fallbackOnLocalError) throw error } } - return await this.fallbackValidation(root, height) + const valid = await this.fallbackValidation(root, height) + this.assertCurrentContext(context, 'root validation') + return valid } async synchronize(): Promise { @@ -162,8 +225,10 @@ export class LocalChainTracker implements ChainTracker { } async clearLocalData(): Promise { - if (this.clearLocal == null) throw new Error('Local ChainTracks clearing is not configured.') - this.local = await this.clearLocal() + const clearLocal = this.clearLocal + if (clearLocal == null) throw new Error('Local ChainTracks clearing is not configured.') + const reset = this.beginReset() + await this.applyReset(reset, clearLocal) this.status = { mode: this.status.mode, activeSource: this.status.mode === 'remote-only' ? 'fallback' : 'local', @@ -274,15 +339,20 @@ export class LocalChainTracker implements ChainTracker { this.recoverLocal != null && expectedHash != null ) { - this.local = await this.recoverLocal({ - reason: consistency, - localHeight, - referenceHeight, - heightLag, - comparisonHeight, - expectedHash, - referenceAgreement - }) + const reset = this.beginReset() + await this.applyReset( + reset, + async () => + await this.recoverLocal!({ + reason: consistency, + localHeight, + referenceHeight, + heightLag, + comparisonHeight, + expectedHash, + referenceAgreement + }) + ) const recoveredAt = this.now().toISOString() const recovered = await this.checkConsistencyInternal(false) this.status = { ...recovered, recoveredAt } @@ -315,6 +385,114 @@ export class LocalChainTracker implements ChainTracker { throw error } + private attemptSources(): ChaintracksClientApi[] { + return this.status.mode === 'remote-only' ? [...this.fallbacks] : [this.local] + } + + private sameSources(left: ChaintracksClientApi[], right: ChaintracksClientApi[]): boolean { + return left.length === right.length && left.every((source, index) => source === right[index]) + } + + private async applyReset(reset: number, nextLocal: () => Promise): Promise { + await this.dispose() + this.assertResetOwner(reset) + const local = await nextLocal() + this.assertResetOwner(reset) + this.local = local + this.resetting = false + } + + private sourceContext(): string { + const nested = (source: ChaintracksClientApi): string => { + const provider = source as ChaintracksClientApi & { getVerificationContext?: () => string | number } + return String(provider.getVerificationContext?.() ?? '') + } + return JSON.stringify([ + this.verificationContext, + this.resetting, + this.status.mode, + this.eventEpoch, + this.observerGeneration, + nested(this.local), + this.fallbacks.map(nested) + ]) + } + + private assertCurrentContext(context: string, operation: string): void { + if (this.resetting || context !== this.sourceContext()) { + throw new Error(`Local ChainTracks provider changed during ${operation}`) + } + } + + private throwIfAborted(signal?: AbortSignal): void { + if (signal?.aborted === true) throw signal.reason ?? new Error('Local ChainTracks token lookup aborted') + } + + private beginReset(): number { + this.verificationContext++ + this.resetting = true + return this.verificationContext + } + + private assertResetOwner(reset: number): void { + if (reset !== this.verificationContext) throw new Error('Local ChainTracks reset was superseded') + } + + private reorgSubscribe(source: ChaintracksClientApi): ((listener: unknown) => Promise) | undefined { + if (source.supportsReorgEvents === false) return undefined + const events = source as ChaintracksClientApi & { + subscribeReorgs?: (listener: unknown) => Promise + } + if (typeof events.subscribeReorgs === 'function') return async listener => await events.subscribeReorgs!(listener) + if (source.supportsReorgEvents === true) { + throw new Error('Chaintracks provider promised reorg events but subscribeReorgs is not implemented') + } + return undefined + } + + private async ensureEventObservers(sources: ChaintracksClientApi[]): Promise { + await Promise.all( + sources.map(async source => { + const subscribe = this.reorgSubscribe(source) + if (subscribe == null) return + if (this.eventSubscriptions.has(source)) return + const pending = this.eventSetups.get(source) + if (pending !== undefined) { + await pending + if ( + !this.eventSubscriptions.has(source) && + !this.resetting && + (source === this.local || this.fallbacks.includes(source)) + ) { + await this.ensureEventObservers([source]) + } + return + } + const generation = this.observerGeneration + const current = (): boolean => + !this.resetting && + generation === this.observerGeneration && + (source === this.local || this.fallbacks.includes(source)) + const setup = (async () => { + const subscription = await subscribe(() => { + if (current()) this.eventEpoch++ + }) + if (!current()) { + await source.unsubscribe(subscription).catch(() => undefined) + return + } + this.eventSubscriptions.set(source, subscription) + })() + this.eventSetups.set(source, setup) + try { + await setup + } finally { + if (this.eventSetups.get(source) === setup) this.eventSetups.delete(source) + } + }) + ) + } + private async fallbackValidation(root: string, height: number): Promise { let valid = 0 let invalid = 0 diff --git a/packages/wallet/wallet-toolbox/src/services/chaintracker/__tests/ChaintracksChainTracker.test.ts b/packages/wallet/wallet-toolbox/src/services/chaintracker/__tests/ChaintracksChainTracker.test.ts index 3a7cbfaef..32d23a42e 100644 --- a/packages/wallet/wallet-toolbox/src/services/chaintracker/__tests/ChaintracksChainTracker.test.ts +++ b/packages/wallet/wallet-toolbox/src/services/chaintracker/__tests/ChaintracksChainTracker.test.ts @@ -1,4 +1,5 @@ import { ChaintracksChainTracker } from '../index.all' +import { ChaintracksServiceClient } from '../chaintracks/ChaintracksServiceClient' import { sdk } from '../../../index.client' import { BlockHeader } from '../../../sdk/WalletServices.interfaces' @@ -71,7 +72,7 @@ describe('ChaintracksChaintracker tests', () => { test('traces retry attempts and cache disposition without roots or headers', async () => { const events: any[] = [] let nextSpanId = 1 - const chaintracks = makeChaintracksClient([undefined, HEADER_877599]) + const chaintracks = makeChaintracksClient([undefined, HEADER_877599, HEADER_877599]) const tracker = new ChaintracksChainTracker('main', chaintracks, { maxRetries: 2, retryDelayMs: 0, @@ -90,15 +91,15 @@ describe('ChaintracksChaintracker tests', () => { const attempts = events.filter(event => event.name === 'wallet.chaintracks.find_header') const validations = events.filter(event => event.name === 'wallet.chaintracks.validate_root') - expect(attempts).toHaveLength(2) - expect(attempts.map(event => event.attributes['retry.attempt'])).toEqual([1, 2]) + expect(attempts).toHaveLength(3) + expect(attempts.map(event => event.attributes['retry.attempt'])).toEqual([1, 2, 1]) expect(validations).toHaveLength(2) expect(validations[0].attributes).toMatchObject({ 'chaintracks.cache_hit': false, 'chaintracks.valid': true }) expect(validations[1].attributes).toMatchObject({ - 'chaintracks.cache_hit': true, + 'chaintracks.cache_hit': false, 'chaintracks.valid': true }) expect(events.find(event => event.name === 'wallet.chaintracks.current_height')).toMatchObject({ @@ -107,6 +108,127 @@ describe('ChaintracksChaintracker tests', () => { expect(JSON.stringify(events)).not.toContain(HEADER_877599.merkleRoot) expect(JSON.stringify(events)).not.toContain(HEADER_877599.previousHash) }) + + test('reads the current canonical root on every request after a same-height reorg', async () => { + const reorged = { ...HEADER_877599, merkleRoot: '11'.repeat(32) } + const chaintracks = makeChaintracksClient([HEADER_877599, reorged]) + const tracker = new ChaintracksChainTracker('main', chaintracks, { maxRetries: 1 }) + + await expect(tracker.isValidRootForHeight(HEADER_877599.merkleRoot, HEADER_877599.height)).resolves.toBe(true) + await expect(tracker.isValidRootForHeight(HEADER_877599.merkleRoot, HEADER_877599.height)).resolves.toBe(false) + expect(chaintracks.findHeaderForHeight).toHaveBeenCalledTimes(2) + }) + + test('rejects a header result from a provider replaced while its request is pending', async () => { + let release: (() => void) | undefined + const pending = new Promise(resolve => { + release = resolve + }) + const oldProvider = makeChaintracksClient([]) + oldProvider.findHeaderForHeight.mockImplementation(async () => { + await pending + return HEADER_877599 + }) + const replacement = makeChaintracksClient([HEADER_877599]) + const tracker = new ChaintracksChainTracker('main', oldProvider, { maxRetries: 3, retryDelayMs: 0 }) + + const request = tracker.isValidRootForHeight(HEADER_877599.merkleRoot, HEADER_877599.height) + tracker.chaintracks = replacement + tracker.chaintracks = oldProvider + release!() + + await expect(request).rejects.toThrow('provider changed') + expect(oldProvider.findHeaderForHeight).toHaveBeenCalledTimes(1) + expect(tracker.getVerificationContext()).toContain('chaintracks:2:') + expect(tracker.cache).toEqual({}) + }) + + test('changes a token for every reorg event, including an ABA tip, and releases its listener', async () => { + let listener: (() => void) | undefined + const provider = makeChaintracksClient([]) + provider.findChainTipHash = jest.fn(async () => 'aa'.repeat(32)) + provider.subscribeReorgs = jest.fn(async callback => { + listener = callback + return 'reorg-1' + }) + provider.unsubscribe = jest.fn(async () => true) + const tracker = new ChaintracksChainTracker('main', provider) + + const before = await tracker.getVerificationContextToken() + listener!() + const after = await tracker.getVerificationContextToken() + expect(after).not.toBe(before) + await tracker.dispose() + expect(provider.unsubscribe).toHaveBeenCalledWith('reorg-1') + }) + + test('unsubscribes a deferred registration after provider replacement and obtains a fresh subscription', async () => { + let release: ((value: string) => void) | undefined + const pending = new Promise(resolve => { + release = resolve + }) + const oldProvider = makeChaintracksClient([]) + oldProvider.findChainTipHash = jest.fn(async () => 'aa'.repeat(32)) + oldProvider.subscribeReorgs = jest.fn(async () => await pending) + oldProvider.unsubscribe = jest.fn(async () => true) + const replacement = makeChaintracksClient([]) + replacement.findChainTipHash = jest.fn(async () => 'bb'.repeat(32)) + replacement.subscribeReorgs = jest.fn(async () => 'fresh-subscription') + replacement.unsubscribe = jest.fn(async () => true) + const tracker = new ChaintracksChainTracker('main', oldProvider) + + const staleToken = tracker.getVerificationContextToken() + tracker.chaintracks = replacement + release!('stale-subscription') + await expect(staleToken).rejects.toThrow('provider changed') + expect(oldProvider.unsubscribe).toHaveBeenCalledWith('stale-subscription') + await expect(tracker.getVerificationContextToken()).resolves.toContain('bb'.repeat(32)) + expect(replacement.subscribeReorgs).toHaveBeenCalledTimes(1) + }) + + test('rejects a token when dispose races with pending registration on the same provider', async () => { + let release: ((value: string) => void) | undefined + const pending = new Promise(resolve => { + release = resolve + }) + const provider = makeChaintracksClient([]) + provider.findChainTipHash = jest.fn(async () => 'aa'.repeat(32)) + provider.subscribeReorgs = jest.fn(async () => await pending) + provider.unsubscribe = jest.fn(async () => true) + const tracker = new ChaintracksChainTracker('main', provider) + + const token = tracker.getVerificationContextToken() + const disposed = tracker.dispose() + release!('disposed-pending') + await disposed + await expect(token).rejects.toThrow('provider changed') + expect(provider.unsubscribe).toHaveBeenCalledWith('disposed-pending') + }) + + test('uses the HTTP Chaintracks client without attempting its unsupported reorg subscription', async () => { + const provider = new ChaintracksServiceClient('main', 'https://chaintracks.example') + expect(provider.supportsReorgEvents).toBe(false) + jest.spyOn(provider, 'findChainTipHash').mockResolvedValue('aa'.repeat(32)) + jest.spyOn(provider, 'subscribeReorgs') + const tracker = new ChaintracksChainTracker('main', provider) + + await expect(tracker.getVerificationContextToken()).resolves.toContain('aa'.repeat(32)) + expect(provider.subscribeReorgs).not.toHaveBeenCalled() + }) + + test('does not hide a registration failure from a built-in client that promises reorg events', async () => { + class PromisingEventsClient extends ChaintracksServiceClient { + override readonly supportsReorgEvents = true + } + const provider = new PromisingEventsClient('main', 'https://chaintracks.example') + expect(provider.supportsReorgEvents).toBe(true) + jest.spyOn(provider, 'findChainTipHash').mockResolvedValue('aa'.repeat(32)) + const subscribe = jest.spyOn(provider, 'subscribeReorgs') + const tracker = new ChaintracksChainTracker('main', provider) + + await expect(tracker.getVerificationContextToken()).rejects.toThrow('Method not implemented.') + expect(subscribe).toHaveBeenCalled() + }) }) async function testChaintracksChaintracker(chain: sdk.Chain) { diff --git a/packages/wallet/wallet-toolbox/src/services/chaintracker/__tests/LocalChainTracker.test.ts b/packages/wallet/wallet-toolbox/src/services/chaintracker/__tests/LocalChainTracker.test.ts index 913fc4cb4..feef8f630 100644 --- a/packages/wallet/wallet-toolbox/src/services/chaintracker/__tests/LocalChainTracker.test.ts +++ b/packages/wallet/wallet-toolbox/src/services/chaintracker/__tests/LocalChainTracker.test.ts @@ -1,4 +1,5 @@ import type { ChaintracksClientApi } from '../chaintracks/Api/ChaintracksClientApi' +import { ChaintracksServiceClient } from '../chaintracks/ChaintracksServiceClient' import { LocalChainTracker } from '../LocalChainTracker' const localHash = '01'.repeat(32) @@ -40,7 +41,65 @@ function client( } as unknown as ChaintracksClientApi } +class PromisingEventsClient extends ChaintracksServiceClient { + override readonly supportsReorgEvents = true +} + describe('LocalChainTracker', () => { + test('uses an event-unsupported HTTP fallback in remote-only mode without subscribing', async () => { + const local = client() + const fallback = new ChaintracksServiceClient('main', 'https://chaintracks.example') + expect(fallback.supportsReorgEvents).toBe(false) + jest.spyOn(fallback, 'findChainTipHash').mockResolvedValue('aa'.repeat(32)) + jest.spyOn(fallback, 'subscribeReorgs') + const tracker = new LocalChainTracker({ local, fallbacks: [fallback], mode: 'remote-only' }) + + await expect(tracker.getVerificationContextToken()).resolves.toContain('aa'.repeat(32)) + expect(fallback.subscribeReorgs).not.toHaveBeenCalled() + expect(local.findChainTipHash).not.toHaveBeenCalled() + }) + + test('does not hide a registration failure from a fallback that promises reorg events', async () => { + const fallback = new PromisingEventsClient('main', 'https://chaintracks.example') + expect(fallback.supportsReorgEvents).toBe(true) + jest.spyOn(fallback, 'findChainTipHash').mockResolvedValue('aa'.repeat(32)) + const subscribe = jest.spyOn(fallback, 'subscribeReorgs') + const tracker = new LocalChainTracker({ local: client(), fallbacks: [fallback], mode: 'remote-only' }) + + await expect(tracker.getVerificationContextToken()).rejects.toThrow('Method not implemented.') + expect(subscribe).toHaveBeenCalled() + }) + + test('requires participating canonical identity and ignores unused providers', async () => { + const unusedLocal = client({ hash: 'aa'.repeat(32) }) + const deadFallback = client({ tipError: new Error('fallback offline') }) + const remoteOnly = new LocalChainTracker({ + local: unusedLocal, + fallbacks: [deadFallback], + mode: 'remote-only' + }) + await expect(remoteOnly.getVerificationContextToken()).rejects.toThrow('fallback offline') + expect(unusedLocal.findChainTipHash).not.toHaveBeenCalled() + + const hangingLocal = client({ hash: 'aa'.repeat(32) }) + const remoteWithoutFallback = new LocalChainTracker({ local: hangingLocal, mode: 'remote-only' }) + await expect(remoteWithoutFallback.getVerificationContextToken()).rejects.toThrow( + 'No canonical ChainTracks source is available' + ) + expect(hangingLocal.findChainTipHash).not.toHaveBeenCalled() + + const liveLocal = client({ hash: 'cc'.repeat(32) }) + const unusedFallback = client({ tipError: new Error('fallback offline') }) + const localPrimary = new LocalChainTracker({ local: liveLocal, fallbacks: [unusedFallback] }) + await expect(localPrimary.getVerificationContextToken()).resolves.toContain('cc'.repeat(32)) + expect(unusedFallback.findChainTipHash).not.toHaveBeenCalled() + + const deadLocal = client({ tipError: new Error('local offline') }) + const unusedLiveFallback = client({ hash: 'dd'.repeat(32) }) + const localMissing = new LocalChainTracker({ local: deadLocal, fallbacks: [unusedLiveFallback] }) + await expect(localMissing.getVerificationContextToken()).rejects.toThrow('local offline') + expect(unusedLiveFallback.findChainTipHash).not.toHaveBeenCalled() + }) test('never overrides a definitive local rejection with a remote answer', async () => { const local = client({ valid: false }) const fallback = client({ valid: true }) @@ -274,6 +333,172 @@ describe('LocalChainTracker', () => { expect(local.listening).toHaveBeenCalledTimes(1) }) + test('fences an in-flight root validation while local clearing is deferred or fails', async () => { + let releaseRoot: (() => void) | undefined + let releaseClear: (() => void) | undefined + const rootPending = new Promise(resolve => { + releaseRoot = resolve + }) + const clearPending = new Promise(resolve => { + releaseClear = () => resolve(client()) + }) + const local = client() + ;(local.isValidRootForHeight as jest.Mock).mockImplementation(async () => { + await rootPending + return true + }) + const tracker = new LocalChainTracker({ local, clearLocal: async () => await clearPending }) + const validation = tracker.isValidRootForHeight('root', 100) + const clearing = tracker.clearLocalData() + releaseRoot!() + await expect(validation).rejects.toThrow('provider changed') + releaseClear!() + await expect(clearing).resolves.toMatchObject({ consistency: 'unchecked' }) + + const failed = new LocalChainTracker({ + local: client(), + clearLocal: async () => { + throw new Error('reset failed') + } + }) + await expect(failed.clearLocalData()).rejects.toThrow('reset failed') + await expect(failed.isValidRootForHeight('root', 100)).rejects.toThrow('provider changed') + }) + + test('drops a pending observer registration during reset and registers a fresh replacement observer', async () => { + let releaseSubscription: ((value: string) => void) | undefined + const pendingSubscription = new Promise(resolve => { + releaseSubscription = resolve + }) + const oldLocal = client() + ;(oldLocal as any).subscribeReorgs = jest.fn(async () => await pendingSubscription) + ;(oldLocal as any).unsubscribe = jest.fn(async () => true) + const replacement = client() + ;(replacement as any).subscribeReorgs = jest.fn(async () => 'fresh') + ;(replacement as any).unsubscribe = jest.fn(async () => true) + const tracker = new LocalChainTracker({ local: oldLocal, clearLocal: async () => replacement }) + + const staleToken = tracker.getVerificationContextToken() + const reset = tracker.clearLocalData() + releaseSubscription!('stale') + await expect(staleToken).rejects.toThrow('provider changed') + await reset + await expect(tracker.getVerificationContextToken()).resolves.toContain('eventEpoch') + expect((oldLocal as any).unsubscribe).toHaveBeenCalledWith('stale') + expect((replacement as any).subscribeReorgs).toHaveBeenCalledTimes(1) + }) + + test('does not invoke a superseded recovery hook when reset is overtaken during disposal', async () => { + let releaseUnsubscribe: (() => void) | undefined + const pendingUnsubscribe = new Promise(resolve => { + releaseUnsubscribe = resolve + }) + let sawUnsubscribe: (() => void) | undefined + const unsubscribed = new Promise(resolve => { + sawUnsubscribe = resolve + }) + const oldLocal = client({ hash: localHash }) + ;(oldLocal as any).subscribeReorgs = jest.fn(async () => 'sub-1') + ;(oldLocal as any).unsubscribe = jest.fn(async () => { + sawUnsubscribe!() + await pendingUnsubscribe + return true + }) + const recovered = client({ hash: agreedHash }) + const recoverLocal = jest.fn(async () => recovered) + const replacement = client() + const clearLocal = jest.fn(async () => replacement) + const tracker = new LocalChainTracker({ + local: oldLocal, + fallbacks: [client({ hash: agreedHash }), client({ hash: agreedHash })], + requiredConsistencyAgreement: 2, + autoRecover: true, + recoverLocal, + clearLocal + }) + await tracker.getVerificationContextToken() + + const recovering = tracker.checkConsistency() + await unsubscribed + const clearing = tracker.clearLocalData() + releaseUnsubscribe!() + await expect(recovering).resolves.toMatchObject({ + consistency: 'error', + lastError: 'Local ChainTracks reset was superseded' + }) + await expect(clearing).resolves.toMatchObject({ consistency: 'unchecked' }) + expect(recoverLocal).not.toHaveBeenCalled() + expect(clearLocal).toHaveBeenCalledTimes(1) + expect(tracker.getLocalClient()).toBe(replacement) + }) + + test('does not invoke a superseded clear hook when reset is overtaken during disposal', async () => { + let releaseUnsubscribe: (() => void) | undefined + const pendingUnsubscribe = new Promise(resolve => { + releaseUnsubscribe = resolve + }) + let sawUnsubscribe: (() => void) | undefined + const unsubscribed = new Promise(resolve => { + sawUnsubscribe = resolve + }) + const oldLocal = client() + ;(oldLocal as any).subscribeReorgs = jest.fn(async () => 'sub-1') + ;(oldLocal as any).unsubscribe = jest.fn(async () => { + sawUnsubscribe!() + await pendingUnsubscribe + return true + }) + const replacement = client() + const clearLocal = jest.fn(async () => replacement) + const tracker = new LocalChainTracker({ local: oldLocal, clearLocal }) + await tracker.getVerificationContextToken() + + const first = tracker.clearLocalData() + await unsubscribed + const second = tracker.clearLocalData() + releaseUnsubscribe!() + await expect(first).rejects.toThrow('superseded') + await expect(second).resolves.toMatchObject({ consistency: 'unchecked' }) + expect(clearLocal).toHaveBeenCalledTimes(1) + expect(tracker.getLocalClient()).toBe(replacement) + }) + + test('keeps the newest clear replacement when concurrent resets complete out of order', async () => { + let resolveFirst: ((value: ChaintracksClientApi) => void) | undefined + let resolveSecond: ((value: ChaintracksClientApi) => void) | undefined + const first = new Promise(resolve => { + resolveFirst = resolve + }) + const second = new Promise(resolve => { + resolveSecond = resolve + }) + let firstHookEntered: (() => void) | undefined + const firstHook = new Promise(resolve => { + firstHookEntered = resolve + }) + const older = client({ valid: false }) + const newer = client({ valid: true }) + const clearLocal = jest + .fn() + .mockImplementationOnce(async () => { + firstHookEntered!() + return await first + }) + .mockImplementationOnce(async () => await second) + const tracker = new LocalChainTracker({ local: client(), clearLocal }) + + const oldReset = tracker.clearLocalData() + await firstHook + const newReset = tracker.clearLocalData() + resolveSecond!(newer) + await newReset + resolveFirst!(older) + await expect(oldReset).rejects.toThrow('superseded') + + expect(tracker.getLocalClient()).toBe(newer) + await expect(tracker.isValidRootForHeight('root', 100)).resolves.toBe(true) + }) + test('requires a configured clearing hook and resets local-primary status when cleared', async () => { const unconfigured = new LocalChainTracker({ local: client() }) await expect(unconfigured.clearLocalData()).rejects.toThrow('Local ChainTracks clearing is not configured.') @@ -287,6 +512,48 @@ describe('LocalChainTracker', () => { }) }) + test('rejects a local validation result when the tracker mode changes in flight', async () => { + let release: (() => void) | undefined + const pending = new Promise(resolve => { + release = resolve + }) + const local = client() + ;(local.isValidRootForHeight as jest.Mock).mockImplementation(async () => { + await pending + return true + }) + const tracker = new LocalChainTracker({ local, fallbacks: [client({ valid: true })] }) + + const validation = tracker.isValidRootForHeight('root', 100) + tracker.setMode('remote-only') + release!() + + await expect(validation).rejects.toThrow('provider changed') + expect(tracker.getVerificationContext()).toContain('local-chaintracks:[1,false,"remote-only"') + }) + + test('rejects a local height result when its nested provider context changes in flight', async () => { + let release: (() => void) | undefined + const pending = new Promise(resolve => { + release = resolve + }) + let providerContext = 0 + const local = client() + ;(local.getPresentHeight as jest.Mock).mockImplementation(async () => { + await pending + return 101 + }) + ;(local as ChaintracksClientApi & { getVerificationContext: () => number }).getVerificationContext = () => + providerContext + const tracker = new LocalChainTracker({ local }) + + const height = tracker.currentHeight() + providerContext++ + release!() + + await expect(height).rejects.toThrow('provider changed') + }) + test('reports missing, unavailable, and sub-quorum consistency references', async () => { const noReferences = new LocalChainTracker({ local: client() }) await expect(noReferences.checkConsistency()).resolves.toMatchObject({ diff --git a/packages/wallet/wallet-toolbox/src/services/chaintracker/chaintracks/Api/ChaintracksClientApi.ts b/packages/wallet/wallet-toolbox/src/services/chaintracker/chaintracks/Api/ChaintracksClientApi.ts index e41ed9773..36558dfa8 100644 --- a/packages/wallet/wallet-toolbox/src/services/chaintracker/chaintracks/Api/ChaintracksClientApi.ts +++ b/packages/wallet/wallet-toolbox/src/services/chaintracker/chaintracks/Api/ChaintracksClientApi.ts @@ -103,6 +103,13 @@ export interface ChaintracksAvailabilitySnapshotApi { * @public */ export interface ChaintracksClientApi extends ChainTracker { + /** + * Additive reorg-event capability. Method presence is not capability: + * `false` means subscribe/unsubscribe are unsupported stubs and must not be + * called; `true` promises working registration whose failures must propagate. + * Omit to infer from a callable `subscribeReorgs` implementation. + */ + readonly supportsReorgEvents?: boolean /** * Confirms the chain */ diff --git a/packages/wallet/wallet-toolbox/src/services/chaintracker/chaintracks/ChaintracksServiceClient.ts b/packages/wallet/wallet-toolbox/src/services/chaintracker/chaintracks/ChaintracksServiceClient.ts index 6e0233703..236dd59cb 100644 --- a/packages/wallet/wallet-toolbox/src/services/chaintracker/chaintracks/ChaintracksServiceClient.ts +++ b/packages/wallet/wallet-toolbox/src/services/chaintracker/chaintracks/ChaintracksServiceClient.ts @@ -17,6 +17,8 @@ export interface ChaintracksServiceClientOptions {} * */ export class ChaintracksServiceClient implements ChaintracksClientApi { + /** HTTP polling client; callback event methods are legacy unsupported stubs. */ + readonly supportsReorgEvents = false static createChaintracksServiceClientOptions(): ChaintracksServiceClientOptions { const options: ChaintracksServiceClientOptions = { useAuthrite: false diff --git a/packages/wallet/wallet-toolbox/src/services/chaintracker/chaintracks/GoChaintracksServiceClient.ts b/packages/wallet/wallet-toolbox/src/services/chaintracker/chaintracks/GoChaintracksServiceClient.ts index b0b7c8b67..19b86e53c 100644 --- a/packages/wallet/wallet-toolbox/src/services/chaintracker/chaintracks/GoChaintracksServiceClient.ts +++ b/packages/wallet/wallet-toolbox/src/services/chaintracker/chaintracks/GoChaintracksServiceClient.ts @@ -40,6 +40,8 @@ interface SseSubscription { * processing without a local WhatsOnChain polling ingestor. */ export class GoChaintracksServiceClient implements ChaintracksClientApi { + /** SSE client; reorg registration is a supported additive capability. */ + readonly supportsReorgEvents = true private readonly baseUrl: string private readonly fetcher: typeof fetch private readonly requestTimeoutMsecs: number diff --git a/packages/wallet/wallet-toolbox/src/utility/__tests__/identityVerification.test.ts b/packages/wallet/wallet-toolbox/src/utility/__tests__/identityVerification.test.ts index 0ab698888..1008c7bf7 100644 --- a/packages/wallet/wallet-toolbox/src/utility/__tests__/identityVerification.test.ts +++ b/packages/wallet/wallet-toolbox/src/utility/__tests__/identityVerification.test.ts @@ -10,7 +10,13 @@ import { VerifiableCertificate } from '@bsv/sdk' import { LookupAnswer, LookupResolver } from '@bsv/sdk' -import { parseResults, parseResults$, queryOverlay } from '../identityUtils' +import { + IdentityEvidenceVerifier, + parseResults, + parseResults$, + queryOverlay, + queryOverlayEvidence +} from '../identityUtils' import { verifyOverlayOutput } from '../verifyOverlayOutput' import { createIdentityVerificationFixture, @@ -59,6 +65,30 @@ describe('identity overlay verification', () => { }) }) + it('propagates identity evidence limits instead of returning a partial result', async () => { + const receipt = answer(outputFor(fixture.certificateBEEF)) + const resolver = { query: jest.fn(async () => receipt) } as unknown as LookupResolver + await expect(queryOverlayEvidence({}, resolver, { candidateBytes: 1 })).rejects.toMatchObject({ code: 'limit' }) + await expect( + queryOverlayEvidence({}, resolver, { + candidateBytes: fixture.certificateBEEF.length, + retainedBytes: fixture.certificateBEEF.length, + outputs: 1 + }) + ).resolves.toEqual(receipt) + + const limitedResolver = { + query: jest.fn(async (_query, _auth, options) => { + options.onEvidence({ type: 'limit' }) + return receipt + }) + } as unknown as LookupResolver + await expect(queryOverlayEvidence({}, limitedResolver)).rejects.toMatchObject({ code: 'limit' }) + + const verifier = new IdentityEvidenceVerifier(fixture.confirmedTracker, 'limit-test', { candidateBytes: 1 }) + await expect(verifier.parse(outputFor(fixture.certificateBEEF))).rejects.toMatchObject({ code: 'limit' }) + }) + it('accepts a genuinely signed unconfirmed spend only when its ancestor is confirmed', async () => { await expect(fixture.unconfirmedTransaction.verify(fixture.confirmedTracker)).resolves.toBe(true) diff --git a/packages/wallet/wallet-toolbox/src/utility/identityUtils.ts b/packages/wallet/wallet-toolbox/src/utility/identityUtils.ts index dd6cf00e3..ecb847248 100644 --- a/packages/wallet/wallet-toolbox/src/utility/identityUtils.ts +++ b/packages/wallet/wallet-toolbox/src/utility/identityUtils.ts @@ -9,10 +9,15 @@ import { IdentityCertificate, IdentityCertifier, Base64String, - ChainTracker + ChainTracker, + TransactionEvidenceCoordinator, + TransactionEvidenceError, + TransactionEvidenceLimits, + VerifiedTransactionOutput, + defaultTransactionEvidenceLimits } from '@bsv/sdk' import { Certifier, TrustSettings } from '../WalletSettingsManager' -import { OverlayOutputEvidence, verifyOverlayOutput } from './verifyOverlayOutput' +import { OverlayOutputEvidence } from './verifyOverlayOutput' // Our extended certificate includes certifierInfo. export interface ExtendedVerifiableCertificate extends IdentityCertificate { @@ -114,25 +119,72 @@ export const queryOverlay = async ( return await parseResults(await queryOverlayEvidence(query, resolver), chainTracker) } -/** Fetch an owned snapshot of UNTRUSTED evidence, suitable only for revalidation. */ -export const queryOverlayEvidence = async (query: unknown, resolver: LookupResolver): Promise => { - const results = await resolver.query( - { - service: 'ls_identity', - query - }, - undefined, - { graceMs: 300 } - ) - - if (results.type !== 'output-list') return results - return { - type: 'output-list', - outputs: results.outputs.map(output => ({ +/** Configurable identity intake bounds; share byte limits with IdentityEvidenceVerifier. */ +export interface IdentityEvidenceIntakeLimits { + candidateBytes?: number + retainedBytes?: number + outputs?: number +} + +/** + * Fetch an owned snapshot of UNTRUSTED evidence, suitable only for revalidation. + * A limit rejects the lookup because this legacy result cannot represent partial completion. + */ +export const queryOverlayEvidence = async ( + query: unknown, + resolver: LookupResolver, + limits: IdentityEvidenceIntakeLimits = {} +): Promise => { + const candidateBytes = limits.candidateBytes ?? defaultTransactionEvidenceLimits.candidateBytes + const retainedBytes = limits.retainedBytes ?? defaultTransactionEvidenceLimits.retainedBytes + const maxOutputs = limits.outputs ?? 512 + if (![candidateBytes, retainedBytes, maxOutputs].every(value => Number.isSafeInteger(value) && value > 0)) + throw new TransactionEvidenceError('limit') + const outputs: LookupAnswer['outputs'] = [] + let bytes = 0 + let received = false + let closed = false + let limited = false + const accept = (output: LookupAnswer['outputs'][number]): void => { + const size = output.beef.length + (output.context?.length ?? 0) + if (outputs.length >= maxOutputs || output.beef.length > candidateBytes || bytes + size > retainedBytes) { + limited = true + return + } + bytes += size + outputs.push({ ...output, beef: output.beef.slice(), ...(output.context === undefined ? {} : { context: output.context.slice() }) - })) + }) + } + try { + const results = await resolver.query( + { + service: 'ls_identity', + query + }, + undefined, + { + graceMs: 300, + evidenceLimits: { maxOutputs, maxBytes: retainedBytes }, + onEvidence: event => { + if (closed) return + received = true + if (event.type === 'output') accept(event.output) + else limited = true + } + } + ) + + if (limited) throw new TransactionEvidenceError('limit') + if (results.type !== 'output-list') return results + // Custom/older resolvers may not implement the additive intake callback. + if (!received) for (const output of results.outputs) accept(output) + if (limited) throw new TransactionEvidenceError('limit') + return { type: 'output-list', outputs } + } finally { + closed = true } } @@ -160,12 +212,10 @@ const yieldToUi = async (): Promise => { * Parse a single overlay output into a verified, decrypted certificate. Returns `null` on any * parse / decrypt / verify failure so a malformed entry can never block the others. */ -const parseOne = async ( - output: OverlayOutputEvidence, - chainTracker: ChainTracker +const decodeIdentityOutput = async ( + verifiedOutput: VerifiedTransactionOutput ): Promise => { try { - const verifiedOutput = await verifyOverlayOutput(output, chainTracker) const decodedOutput = PushDrop.decode(verifiedOutput.lockingScript) const certificate: VerifiableCertificate = JSON.parse(Utils.toUTF8(decodedOutput.fields[0])) const verifiableCert = new VerifiableCertificate( @@ -202,6 +252,108 @@ const parseOne = async ( } } +/** + * Wallet/session-owned identity validation. Chain evidence is checked on every + * use; bounded certificate crypto results are independent of mutable trust ratings. + */ +export class IdentityEvidenceVerifier { + private disposed = false + private readonly coordinator: TransactionEvidenceCoordinator + private readonly certificates = new Map() + private expiryTimer?: ReturnType + + constructor( + readonly chainTracker: ChainTracker, + readonly chainNamespace = 'caller-chain-tracker', + limits?: Partial + ) { + this.coordinator = new TransactionEvidenceCoordinator({ + chainTracker, + chainNamespace, + limits, + policyId: 'sdk-spv-overlay-graph-v1' + }) + } + + private clone(json: string): VerifiableCertificate { + const cert: VerifiableCertificate = JSON.parse(json) + const copy = new VerifiableCertificate( + cert.type, + cert.serialNumber, + cert.subject, + cert.certifier, + cert.revocationOutpoint, + cert.fields, + cert.keyring, + cert.signature + ) + copy.decryptedFields = cert.decryptedFields + return copy + } + + async parse(output: OverlayOutputEvidence): Promise<{ outpoint: string; certificate: VerifiableCertificate } | null> { + try { + const evidence = { ...output, beef: output.beef.slice() } + const verified = await this.coordinator.verify(evidence) + if (this.disposed) return null + const cached = this.certificates.get(verified.outpoint) + if (cached !== undefined && cached.expiresAt > Date.now()) { + return { outpoint: verified.outpoint, certificate: this.clone(cached.json) } + } + const certificate = await decodeIdentityOutput(verified) + if (certificate === null) return null + // Certificate crypto can await; fence its completion against current chain/session state. + await this.coordinator.verify(evidence) + if (this.disposed) return null + // Store owned bytes and return a separate object; callers cannot mutate future results. + const json = JSON.stringify(certificate) + this.prune() + let bytes = [...this.certificates.values()].reduce((total, value) => total + value.json.length * 2, 0) + while ( + this.certificates.size > 0 && + (this.certificates.size >= 128 || bytes + json.length * 2 > 2 * 1024 * 1024) + ) { + const oldest = this.certificates.keys().next().value! + bytes -= this.certificates.get(oldest)!.json.length * 2 + this.certificates.delete(oldest) + } + if (json.length * 2 <= 2 * 1024 * 1024) { + this.certificates.set(verified.outpoint, { json, expiresAt: Date.now() + 60_000 }) + this.scheduleExpiry() + } + return { outpoint: verified.outpoint, certificate: this.clone(json) } + } catch (error) { + if (error instanceof TransactionEvidenceError && (error.code === 'limit' || error.code === 'timeout')) throw error + return null + } + } + + private prune(): void { + for (const [key, cached] of this.certificates) if (cached.expiresAt <= Date.now()) this.certificates.delete(key) + } + + private scheduleExpiry(): void { + clearTimeout(this.expiryTimer) + const expiresAt = Math.min(...[...this.certificates.values()].map(value => value.expiresAt)) + if (!Number.isFinite(expiresAt)) return + this.expiryTimer = setTimeout( + () => { + this.prune() + this.scheduleExpiry() + }, + Math.max(1, expiresAt - Date.now()) + ) + this.expiryTimer.unref?.() + } + + dispose(): void { + this.disposed = true + this.coordinator.dispose() + this.certificates.clear() + clearTimeout(this.expiryTimer) + } +} + /** * Parse the returned UTXOs, decrypting and verifying each certificate. * An omitted ChainTracker fails closed. Each call revalidates transaction evidence; @@ -212,17 +364,12 @@ const parseOne = async ( */ export const parseResults = async ( lookupResult: LookupAnswer, - chainTracker?: ChainTracker + chainTracker?: ChainTracker, + verifier?: IdentityEvidenceVerifier ): Promise => { - if (lookupResult.type !== 'output-list' || chainTracker == null) return [] - const parsedResults: VerifiableCertificate[] = [] - const shouldYield = isUiRuntime() - for (const output of lookupResult.outputs) { - if (shouldYield) await yieldToUi() - const cert = await parseOne(output, chainTracker) - if (cert != null) parsedResults.push(cert) - } - return parsedResults + const certificates: VerifiableCertificate[] = [] + for await (const certificate of parseResults$(lookupResult, chainTracker, verifier)) certificates.push(certificate) + return certificates } /** @@ -231,13 +378,25 @@ export const parseResults = async ( */ export async function* parseResults$( lookupResult: LookupAnswer, - chainTracker?: ChainTracker + chainTracker?: ChainTracker, + verifier?: IdentityEvidenceVerifier ): AsyncIterable { if (lookupResult.type !== 'output-list' || chainTracker == null) return + if (verifier !== undefined && verifier.chainTracker !== chainTracker) return + const owned = verifier === undefined + verifier ??= new IdentityEvidenceVerifier(chainTracker) const shouldYield = isUiRuntime() - for (const output of lookupResult.outputs) { - if (shouldYield) await yieldToUi() - const cert = await parseOne(output, chainTracker) - if (cert != null) yield cert + const seen = new Set() + try { + for (const output of lookupResult.outputs) { + if (shouldYield) await yieldToUi() + const result = await verifier.parse(output) + if (result !== null && !seen.has(result.outpoint)) { + seen.add(result.outpoint) + yield result.certificate + } + } + } finally { + if (owned) verifier.dispose() } } diff --git a/packages/wallet/wallet-toolbox/src/utility/verifyOverlayOutput.ts b/packages/wallet/wallet-toolbox/src/utility/verifyOverlayOutput.ts index ea2cac8be..7681f4a83 100644 --- a/packages/wallet/wallet-toolbox/src/utility/verifyOverlayOutput.ts +++ b/packages/wallet/wallet-toolbox/src/utility/verifyOverlayOutput.ts @@ -1,79 +1,28 @@ -import { ChainTracker, LockingScript, Transaction } from '@bsv/sdk' +import { ChainTracker, TransactionEvidence, TransactionEvidenceCoordinator, VerifiedTransactionOutput } from '@bsv/sdk' /** Untrusted lookup evidence. A host's txid and context are never chain authority. */ -export interface OverlayOutputEvidence { - beef: number[] - outputIndex: number - txid?: string +export interface OverlayOutputEvidence extends TransactionEvidence { context?: number[] } -/** A per-call verdict, for service validation now; not a reusable chain/cache verdict. */ -export interface VerifiedOverlayOutput { - readonly txid: string - readonly outputIndex: number - readonly lockingScript: LockingScript -} +export type VerifiedOverlayOutput = VerifiedTransactionOutput /** - * Independently verify a byte-bound output using the caller's canonical chain source. - * No network defaults, scripts-only mode, verdict cache, or identity semantics live here. - * Errors and false verdicts reject this evidence candidate, not its txid permanently. + * Independently verify one byte-bound output using the caller's canonical chain + * source. Repeated consumers can use the SDK TransactionEvidenceCoordinator. */ export async function verifyOverlayOutput( evidence: OverlayOutputEvidence, chainTracker: ChainTracker ): Promise { - if (chainTracker == null || typeof chainTracker.isValidRootForHeight !== 'function') { - throw new Error('Overlay verification requires a ChainTracker') - } - const { outputIndex, txid: hint } = evidence - if (!Number.isSafeInteger(outputIndex) || outputIndex < 0) { - throw new Error('Invalid overlay output index') - } - // Own the bytes before the first await: callers cannot change the candidate in flight. - const bytes = evidence.beef.slice() - if (bytes.some(byte => !Number.isInteger(byte) || byte < 0 || byte > 255)) { - throw new Error('Invalid overlay transaction bytes') - } - const tx = Transaction.fromBEEF(bytes) - const txid = tx.id('hex') - if (hint !== undefined && (typeof hint !== 'string' || hint.toLowerCase() !== txid)) { - throw new Error('Overlay txid does not match transaction bytes') - } - if (outputIndex >= tx.outputs.length) throw new Error('Overlay output does not exist') - - // SDK graph verification checks scripts and values. Also require every unconfirmed - // branch to have inputs, so a fabricated zero-value, input-free leaf cannot anchor it. - const pending = [tx] - const visited = new Set() - const spentOutpoints = new Set() - while (pending.length > 0) { - const current = pending.pop()! - const currentTxid = current.id('hex') - if (visited.has(currentTxid)) continue - visited.add(currentTxid) - if (current.merklePath != null) continue - if (current.inputs.length === 0) throw new Error('Unconfirmed transaction has no ancestry') - for (const input of current.inputs) { - if (input.sourceTransaction == null) throw new Error('Overlay transaction ancestry is missing') - const sourceTxid = input.sourceTransaction.id('hex') - if (input.sourceTXID !== undefined && input.sourceTXID !== sourceTxid) { - throw new Error('Overlay input does not match its source transaction') - } - const outpoint = `${sourceTxid}.${input.sourceOutputIndex}` - // Script verification alone can count the same value twice. Reject both - // duplicate inputs and conflicting spends among unconfirmed ancestors. - if (spentOutpoints.has(outpoint)) throw new Error('Overlay transaction graph spends an outpoint twice') - spentOutpoints.add(outpoint) - pending.push(input.sourceTransaction) - } - } - - const canonicalTracker: ChainTracker = { - currentHeight: async () => await chainTracker.currentHeight(), - isValidRootForHeight: async (root, height) => (await chainTracker.isValidRootForHeight(root, height)) === true + const coordinator = new TransactionEvidenceCoordinator({ + chainTracker, + chainNamespace: 'caller-chain-tracker', + policyId: 'sdk-spv-overlay-graph-v1' + }) + try { + return await coordinator.verify(evidence) + } finally { + coordinator.dispose() } - if ((await tx.verify(canonicalTracker)) !== true) throw new Error('Overlay transaction verification failed') - return { txid, outputIndex, lockingScript: tx.outputs[outputIndex].lockingScript } } From b012b33a6b1891d8635b16bf48c3c83ae441a7fe Mon Sep 17 00:00:00 2001 From: Deggen Date: Tue, 8 Sep 2026 15:16:51 -0500 Subject: [PATCH 04/58] fix(wallet-toolbox): re-read BHS canonical headers for root checks BHServiceClient no longer caches the queried merkle root. Validity is decided from a freshly read header, so a false result cannot invert on retry and a reorg cannot reuse a stale positive. --- packages/wallet/wallet-toolbox/CHANGELOG.md | 3 +- .../docs/identity-verification.md | 4 +- .../services/chaintracker/BHServiceClient.ts | 13 +-- .../__tests/BHServiceClient.test.ts | 107 ++++++++++++++++++ 4 files changed, 118 insertions(+), 9 deletions(-) create mode 100644 packages/wallet/wallet-toolbox/src/services/chaintracker/__tests/BHServiceClient.test.ts diff --git a/packages/wallet/wallet-toolbox/CHANGELOG.md b/packages/wallet/wallet-toolbox/CHANGELOG.md index c0a8cf2ff..44efc916e 100644 --- a/packages/wallet/wallet-toolbox/CHANGELOG.md +++ b/packages/wallet/wallet-toolbox/CHANGELOG.md @@ -12,7 +12,8 @@ attention to changes that materially alter behavior or extend functionality. envelope, and explicit certificate success. C02/C03 add the exported SDK evidence coordinator, optional chain-provider context markers, bounded transaction/script work sharing, and revalidation of canonical anchors on - positive-cache use. Cached overlay receipts and decrypted certificates remain + positive-cache use. BHServiceClient always re-reads the canonical header for + height-root checks and does not cache a queried root as a reusable positive. Cached overlay receipts and decrypted certificates remain wallet-owned, bounded, and separate from local contacts and trust policy. Existing permission negotiation, pagination, wallet RPC shapes, and resolver call forms remain characterized as compatible. See [identity verification](docs/identity-verification.md) diff --git a/packages/wallet/wallet-toolbox/docs/identity-verification.md b/packages/wallet/wallet-toolbox/docs/identity-verification.md index fc2ddc437..381e8dfe1 100644 --- a/packages/wallet/wallet-toolbox/docs/identity-verification.md +++ b/packages/wallet/wallet-toolbox/docs/identity-verification.md @@ -46,7 +46,9 @@ local-primary uses local identity. An unused provider cannot stand in, and a missing participating identity fails closed. Built-in remote ChainTracks clients advertise reorg-event capability explicitly; method presence is not capability. `Services.getChainTracker()` publishes one coalesced wrapper -before yielding. A remote tip equality read cannot detect an unobserved ABA +before yielding. `BHServiceClient` always re-reads the canonical header for +`isValidRootForHeight` and never treats a previously queried root as a reusable +positive. A remote tip equality read cannot detect an unobserved ABA transition, and a token over multiple sources is not an atomic multi-source snapshot. Canonical roots and observed heights remain dependencies that are rechecked on each use, including positive-cache reuse. Existing trackers may diff --git a/packages/wallet/wallet-toolbox/src/services/chaintracker/BHServiceClient.ts b/packages/wallet/wallet-toolbox/src/services/chaintracker/BHServiceClient.ts index b5bec6a09..2c442f453 100644 --- a/packages/wallet/wallet-toolbox/src/services/chaintracker/BHServiceClient.ts +++ b/packages/wallet/wallet-toolbox/src/services/chaintracker/BHServiceClient.ts @@ -47,13 +47,12 @@ export class BHServiceClient implements ChaintracksServiceClient { } async isValidRootForHeight(root: string, height: number): Promise { - const cachedRoot = this.cache[height] - if (cachedRoot) { - return cachedRoot === root - } - const isValid = await this.bhs.isValidRootForHeight(root, height) - this.cache[height] = root - return isValid + const header = await this.findHeaderForHeight(height) + const merkleRoot = header?.merkleRoot + if (typeof merkleRoot !== 'string' || merkleRoot.length === 0) return false + // Diagnostic only: validity is always decided from a freshly read canonical header. + this.cache[height] = merkleRoot + return merkleRoot === root } async getPresentHeight(): Promise { diff --git a/packages/wallet/wallet-toolbox/src/services/chaintracker/__tests/BHServiceClient.test.ts b/packages/wallet/wallet-toolbox/src/services/chaintracker/__tests/BHServiceClient.test.ts new file mode 100644 index 000000000..c3d8e6722 --- /dev/null +++ b/packages/wallet/wallet-toolbox/src/services/chaintracker/__tests/BHServiceClient.test.ts @@ -0,0 +1,107 @@ +import { BHServiceClient } from '../BHServiceClient' +import { LocalChainTracker } from '../LocalChainTracker' +import type { ChaintracksClientApi } from '../chaintracks/Api/ChaintracksClientApi' +import { BlockHeader } from '../../../sdk/WalletServices.interfaces' + +const height = 100 +const queried = 'aa'.repeat(32) +const canonical = 'bb'.repeat(32) +const reorged = 'cc'.repeat(32) + +function header(merkleRoot: string): BlockHeader { + return { + version: 1, + previousHash: '00'.repeat(32), + merkleRoot, + time: 1, + bits: 1, + nonce: 1, + height, + hash: 'dd'.repeat(32) + } +} + +function localClient(): ChaintracksClientApi { + return { + getPresentHeight: jest.fn(async () => height), + findChainTipHash: jest.fn(async () => queried), + findHeaderForHeight: jest.fn(async () => header(queried)), + isValidRootForHeight: jest.fn(async () => { + throw new Error('local unavailable') + }), + startListening: jest.fn(async () => undefined), + listening: jest.fn(async () => undefined) + } as unknown as ChaintracksClientApi +} + +function bhsClient(): BHServiceClient { + const client = new BHServiceClient('main', 'https://headers.example', 'test-key') + jest.spyOn(client.bhs, 'isValidRootForHeight') + return client +} + +describe('BHServiceClient height-root cache', () => { + test('does not invert a false result into a cached positive for the queried root', async () => { + const client = bhsClient() + jest.spyOn(client, 'findHeaderForHeight').mockResolvedValue(header(canonical)) + ;(client.bhs.isValidRootForHeight as jest.Mock).mockResolvedValue(false) + + await expect(client.isValidRootForHeight(queried, height)).resolves.toBe(false) + await expect(client.isValidRootForHeight(queried, height)).resolves.toBe(false) + expect(client.findHeaderForHeight).toHaveBeenCalledTimes(2) + expect(client.cache[height]).toBe(canonical) + }) + + test('re-reads a later different root at the same height', async () => { + const client = bhsClient() + jest + .spyOn(client, 'findHeaderForHeight') + .mockResolvedValueOnce(header(queried)) + .mockResolvedValueOnce(header(canonical)) + ;(client.bhs.isValidRootForHeight as jest.Mock).mockResolvedValue(true) + + await expect(client.isValidRootForHeight(queried, height)).resolves.toBe(true) + await expect(client.isValidRootForHeight(canonical, height)).resolves.toBe(true) + expect(client.findHeaderForHeight).toHaveBeenCalledTimes(2) + expect(client.cache[height]).toBe(canonical) + }) + + test('does not reuse a positive after the canonical header at that height changes', async () => { + const client = bhsClient() + jest + .spyOn(client, 'findHeaderForHeight') + .mockResolvedValueOnce(header(queried)) + .mockResolvedValueOnce(header(reorged)) + ;(client.bhs.isValidRootForHeight as jest.Mock).mockResolvedValueOnce(true).mockResolvedValueOnce(false) + + await expect(client.isValidRootForHeight(queried, height)).resolves.toBe(true) + await expect(client.isValidRootForHeight(queried, height)).resolves.toBe(false) + expect(client.findHeaderForHeight).toHaveBeenCalledTimes(2) + expect(client.cache[height]).toBe(reorged) + }) + + test('does not cache a queried root when the canonical header is missing', async () => { + const client = bhsClient() + jest.spyOn(client, 'findHeaderForHeight').mockResolvedValue(undefined) + ;(client.bhs.isValidRootForHeight as jest.Mock).mockResolvedValue(false) + + await expect(client.isValidRootForHeight(queried, height)).resolves.toBe(false) + expect(client.cache[height]).toBeUndefined() + }) + + test('LocalChainTracker fallback re-reads BHServiceClient roots instead of the queried-root cache', async () => { + const fallback = bhsClient() + jest.spyOn(fallback, 'findHeaderForHeight').mockResolvedValue(header(canonical)) + ;(fallback.bhs.isValidRootForHeight as jest.Mock).mockResolvedValue(false) + const tracker = new LocalChainTracker({ + local: localClient(), + fallbacks: [fallback], + mode: 'remote-only' + }) + + await expect(tracker.isValidRootForHeight(queried, height)).resolves.toBe(false) + await expect(tracker.isValidRootForHeight(queried, height)).resolves.toBe(false) + expect(fallback.findHeaderForHeight).toHaveBeenCalledTimes(2) + expect(fallback.cache[height]).toBe(canonical) + }) +}) From e82b77eeabfaf78e1a5b60f3367e56dc00fa828b Mon Sep 17 00:00:00 2001 From: Deggen Date: Tue, 8 Sep 2026 15:20:38 -0500 Subject: [PATCH 05/58] fix(sdk): keep overlay host cache and query() deadline honest Store discovery bounds on the SLAP host cache so a tighter-limit query cannot freeze a truncated host set for a later broader attempt. Throw the historical no-competent-hosts error from query()/queryDetailed when a deadline expires before any host is admitted; query$ still emits the deadline snapshot. --- packages/sdk/CHANGELOG.md | 3 + .../sdk/src/overlay-tools/LookupResolver.ts | 160 +++++++++++++++--- .../__tests/LookupResolver.dynamic.test.ts | 101 +++++++++++ 3 files changed, 240 insertions(+), 24 deletions(-) diff --git a/packages/sdk/CHANGELOG.md b/packages/sdk/CHANGELOG.md index 4f913e481..b1169f4d2 100644 --- a/packages/sdk/CHANGELOG.md +++ b/packages/sdk/CHANGELOG.md @@ -254,6 +254,9 @@ All notable changes to this project will be documented in this file. The format ### Changed +- LookupResolver host cache no longer lets a tighter-limit discovery satisfy a + later larger query, and `query()` still throws the historical no-competent-hosts + error when a deadline expires before any host is admitted. - Batch BEEF mutation bookkeeping and reuse compound Merkle intermediate hashes. The optional asynchronous P2PKH backend now forwards its already validated compressed public key directly into the unlocking script. Existing BEEF diff --git a/packages/sdk/src/overlay-tools/LookupResolver.ts b/packages/sdk/src/overlay-tools/LookupResolver.ts index c0e674110..e38f6338b 100644 --- a/packages/sdk/src/overlay-tools/LookupResolver.ts +++ b/packages/sdk/src/overlay-tools/LookupResolver.ts @@ -352,6 +352,66 @@ interface CacheOptions { txMemoTtlMs?: number } +/** Discovery fields that can truncate the cached SLAP host set. */ +interface LookupDiscoveryBound { + maxHosts: number + maxHostsPerTracker: number + maxTrackers: number + maxResponseBytes: number + maxTotalBytes: number + maxOutputs: number +} + +interface LookupHostsCacheEntry extends LookupDiscoveryBound { + hosts: string[] + expiresAt: number + discoveryComplete?: boolean + trackersFailed?: number + limitsHit?: string[] +} + +function lookupDiscoveryBound(limits: LookupLimits): LookupDiscoveryBound { + return { + maxHosts: limits.maxHosts, + maxHostsPerTracker: limits.maxHostsPerTracker, + maxTrackers: limits.maxTrackers, + maxResponseBytes: limits.maxResponseBytes, + maxTotalBytes: limits.maxTotalBytes, + maxOutputs: limits.maxOutputs + } +} + +/** In-flight discovery identity: service plus the limits that shape tracker work. */ +function lookupDiscoveryCacheKey(service: string, limits: LookupLimits): string { + const bound = lookupDiscoveryBound(limits) + return JSON.stringify([ + service, + bound.maxHosts, + bound.maxHostsPerTracker, + bound.maxTrackers, + limits.trackerConcurrency, + bound.maxResponseBytes, + bound.maxTotalBytes, + bound.maxOutputs + ]) +} + +/** True when `cached` was produced with bounds at least as permissive as `needed`. */ +function lookupDiscoveryCovers( + cached: Partial | undefined, + needed: LookupDiscoveryBound +): boolean { + if (cached === undefined) return false + return ( + (cached.maxHosts ?? 0) >= needed.maxHosts && + (cached.maxHostsPerTracker ?? 0) >= needed.maxHostsPerTracker && + (cached.maxTrackers ?? 0) >= needed.maxTrackers && + (cached.maxResponseBytes ?? 0) >= needed.maxResponseBytes && + (cached.maxTotalBytes ?? 0) >= needed.maxTotalBytes && + (cached.maxOutputs ?? 0) >= needed.maxOutputs + ) +} + /** Configuration options for the Lookup resolver. */ export interface LookupResolverConfig { /** Defaults for the bounded discovery, scheduler and receipt intake. */ @@ -777,7 +837,7 @@ export default class LookupResolver { private readonly telemetry: Telemetry // ---- Caches / memoization ---- - private readonly hostsCache: Map + private readonly hostsCache: Map private readonly hostsInFlight: Map private readonly limits: LookupLimits private activeQueries = 0 @@ -891,25 +951,34 @@ export default class LookupResolver { } finally { await iter.return?.(undefined) } + const progress: LookupAnswerProgress = last ?? { + type: 'output-list', + outputs: [], + txIds: [], + isFinal: true, + hostCount: 0, + completedHosts: 0, + successfulHosts: 0, + emptyHosts: 0, + failedHosts: 0, + rejectedHosts: 0, + freeformHosts: 0, + terminalReason: 'settled', + ...(options?.correlationId !== undefined ? { correlationId: options.correlationId } : {}) + } + // Promise callers cannot see terminalReason. A deadline that admitted no + // host is a miss, not a successful empty answer from a queried host. + if (progress.hostCount === 0 && progress.terminalReason !== 'cancelled') { + throw new Error( + `No competent ${this.networkPreset} hosts found by the SLAP trackers for lookup service: ${question.service}` + ) + } return { answer: { type: 'output-list', - outputs: last?.outputs ?? [] + outputs: progress.outputs }, - progress: last ?? { - type: 'output-list', - outputs: [], - txIds: [], - isFinal: true, - hostCount: 0, - completedHosts: 0, - successfulHosts: 0, - emptyHosts: 0, - failedHosts: 0, - rejectedHosts: 0, - freeformHosts: 0, - ...(options?.correlationId !== undefined ? { correlationId: options.correlationId } : {}) - } + progress } } @@ -1233,14 +1302,23 @@ export default class LookupResolver { } else { const cached = this.hostsCache.get(question.service) const configuredAdditional = this.additionalHosts[question.service] ?? [] - const cacheAvailable = cached?.hosts.some(host => (this.hostReputation.snapshot(host)?.backoffUntil ?? 0) <= Date.now()) ?? false - const key = JSON.stringify([question.service, limits.maxHosts, limits.maxHostsPerTracker, - limits.maxTrackers, limits.trackerConcurrency, limits.maxResponseBytes, limits.maxTotalBytes, limits.maxOutputs]) + const cacheHasAvailableHost = + cached?.hosts.some(host => (this.hostReputation.snapshot(host)?.backoffUntil ?? 0) <= Date.now()) ?? + false + const cacheCoversCaller = lookupDiscoveryCovers(cached, limits) + const cacheFresh = cached !== undefined && cached.expiresAt > Date.now() + const key = lookupDiscoveryCacheKey(question.service, limits) let discovery = this.hostsInFlight.get(key) - const refresh = discovery !== undefined || cached === undefined || cached.expiresAt <= Date.now() || !cacheAvailable - const initialSources = Number(cached !== undefined && cacheAvailable) + Number(configuredAdditional.length > 0) + const refresh = + discovery !== undefined || + cached === undefined || + !cacheCoversCaller || + !cacheFresh || + !cacheHasAvailableHost + const initialSources = + Number(cached !== undefined && cacheHasAvailableHost) + Number(configuredAdditional.length > 0) const initialQuota = refresh ? Math.max(1, Math.floor(limits.maxHosts / (initialSources + Math.max(1, Math.min(this.slapTrackers.length, limits.maxTrackers))))) : limits.maxHosts - if (cached !== undefined && cacheAvailable) { + if (cached !== undefined && cacheHasAvailableHost) { // Reserve a source share for cached membership and each late tracker. const cachedLimit = initialQuota admit('cache', cached.hosts.slice(0, cachedLimit)) @@ -1275,8 +1353,7 @@ export default class LookupResolver { this.hostsInFlight.delete(key) if (abandoned) return const hosts = Array.from(new Set(Array.from(state.sources.values()).flat())).slice(0, limits.maxHosts) - if (!this.hostsCache.has(question.service) && this.hostsCache.size >= this.hostsMaxEntries) this.evictOldest(this.hostsCache) - this.hostsCache.set(question.service, { hosts, expiresAt: Date.now() + this.hostsTtlMs, discoveryComplete: state.trackersFailed === 0 && state.limitsHit.size === 0 && state.skippedHosts === 0, trackersFailed: state.trackersFailed, limitsHit: Array.from(state.limitsHit) }) + this.rememberDiscoveredHosts(question.service, hosts, limits, state) }) if (this.slapTrackers.length > limits.maxTrackers) discovery.state.limitsHit.add('maxTrackers') if (normalized.length !== this.slapTrackers.length || trackers.length < Math.min(normalized.length, limits.maxTrackers)) { @@ -1367,6 +1444,41 @@ export default class LookupResolver { if (firstKey !== undefined) m.delete(firstKey) } + /** + * Remember SLAP hosts for a service. A tighter-limit discovery must not + * replace a still-fresh broader cache, and a later broader query must not + * treat a truncated entry as complete. + */ + private rememberDiscoveredHosts( + service: string, + hosts: string[], + limits: LookupLimits, + state: LookupDiscoveryUpdate + ): void { + const existing = this.hostsCache.get(service) + const now = Date.now() + if ( + existing !== undefined && + existing.expiresAt > now && + lookupDiscoveryCovers(existing, limits) && + !lookupDiscoveryCovers(lookupDiscoveryBound(limits), existing) + ) { + return + } + if (existing === undefined && this.hostsCache.size >= this.hostsMaxEntries) { + this.evictOldest(this.hostsCache) + } + this.hostsCache.set(service, { + ...lookupDiscoveryBound(limits), + hosts, + expiresAt: now + this.hostsTtlMs, + discoveryComplete: + state.trackersFailed === 0 && state.limitsHit.size === 0 && state.skippedHosts === 0, + trackersFailed: state.trackersFailed, + limitsHit: Array.from(state.limitsHit) + }) + } + private assertValidOverrideServices(overrides: Record): void { for (const service of Object.keys(overrides)) { if (!service.startsWith('ls_')) { diff --git a/packages/sdk/src/overlay-tools/__tests/LookupResolver.dynamic.test.ts b/packages/sdk/src/overlay-tools/__tests/LookupResolver.dynamic.test.ts index a8a78f552..760b1ecd9 100644 --- a/packages/sdk/src/overlay-tools/__tests/LookupResolver.dynamic.test.ts +++ b/packages/sdk/src/overlay-tools/__tests/LookupResolver.dynamic.test.ts @@ -838,4 +838,105 @@ describe('LookupResolver dynamic discovery', () => { outputs: [{ beef: makeBeef(91), outputIndex: 0 }] }) }) + + it('does not let a tighter-limit cache hide later tracker hosts from a subsequent default query', async () => { + const tracker = 'https://tight-cache-tracker.example' + const firstHost = 'https://tight-cache-a.example' + const laterHost = 'https://tight-cache-b.example' + const service = 'ls_tight_cache' + const firstReceipt = await slapReceipt(170, firstHost, service) + const laterReceipt = await slapReceipt(171, laterHost, service) + const firstBeef = makeBeef(101) + const laterBeef = makeBeef(102) + const lookup = jest.fn(async (url: string) => { + if (url === tracker) { + return { type: 'output-list' as const, outputs: [firstReceipt, laterReceipt] } + } + return { + type: 'output-list' as const, + outputs: [{ beef: url === firstHost ? firstBeef : laterBeef, outputIndex: 0 }] + } + }) + const resolver = new LookupResolver({ facilitator: { lookup }, slapTrackers: [tracker] }) + + const tight = resolver.query({ service, query: { n: 1 } }, undefined, { limits: { maxHosts: 1 } }) + await jest.runAllTimersAsync() + await tight + + const hostCallsAfterTight = lookup.mock.calls + .map(([url]) => url) + .filter((url: string) => url === firstHost || url === laterHost) + expect(hostCallsAfterTight).toHaveLength(1) + + const full = resolver.query({ service, query: { n: 2 } }) + await jest.runAllTimersAsync() + const answer = await full + expect(answer.outputs).toEqual( + expect.arrayContaining([ + { beef: firstBeef, outputIndex: 0 }, + { beef: laterBeef, outputIndex: 0 } + ]) + ) + expect(answer.outputs).toHaveLength(2) + expect(lookup.mock.calls.filter(([url]) => url === tracker).length).toBeGreaterThan(1) + expect(lookup.mock.calls.map(([url]) => url)).toEqual( + expect.arrayContaining([firstHost, laterHost]) + ) + }) + + it('throws from query() when a deadline expires before any host is admitted', async () => { + const tracker = 'https://deadline-miss-tracker.example' + const lookup = jest.fn( + async (_url: string, _question: unknown, _timeout: unknown, signal?: AbortSignal) => + await new Promise((_resolve, reject) => { + signal?.addEventListener('abort', () => reject(signal.reason), { once: true }) + }) + ) + const resolver = new LookupResolver({ + facilitator: { lookup } as any, + slapTrackers: [tracker] + }) + const pending = expect( + resolver.query({ service: 'ls_deadline_miss', query: {} }, undefined, { + deadlineMs: 25 + }) + ).rejects.toThrow( + 'No competent mainnet hosts found by the SLAP trackers for lookup service: ls_deadline_miss' + ) + await jest.advanceTimersByTimeAsync(25) + await pending + }) + + it('keeps query$ deadline snapshots when no host was admitted while Promise callers still throw', async () => { + const tracker = 'https://deadline-snapshot-tracker.example' + const lookup = jest.fn( + async (_url: string, _question: unknown, _timeout: unknown, signal?: AbortSignal) => + await new Promise((_resolve, reject) => { + signal?.addEventListener('abort', () => reject(signal.reason), { once: true }) + }) + ) + const resolver = new LookupResolver({ + facilitator: { lookup } as any, + slapTrackers: [tracker] + }) + const received: LookupAnswerProgress[] = [] + const pending = (async () => { + for await (const item of resolver.query$( + { service: 'ls_deadline_snapshot', query: {} }, + undefined, + { deadlineMs: 25 } + )) { + received.push(item) + } + })() + await jest.advanceTimersByTimeAsync(25) + await pending + expect(received).toHaveLength(1) + expect(received[0]).toMatchObject({ + isFinal: true, + terminalReason: 'deadline', + hostCount: 0, + outputs: [] + }) + }) }) From 5d1eeed57bb1249b2af9cefbb9815444f56bc660 Mon Sep 17 00:00:00 2001 From: Deggen Date: Tue, 8 Sep 2026 17:34:06 -0500 Subject: [PATCH 06/58] fix(sdk): encode overlay discovery cache keys with stringifyBRC100 LookupResolver is a BRC-100 JSON boundary file, so the in-flight discovery key cannot use JSON.stringify. stringifyBRC100 preserves the same service-plus-limit tuple without changing cache cover semantics. --- .../sdk/src/overlay-tools/LookupResolver.ts | 2 +- .../__tests/LookupResolver.dynamic.test.ts | 43 +++++++++++++++++++ 2 files changed, 44 insertions(+), 1 deletion(-) diff --git a/packages/sdk/src/overlay-tools/LookupResolver.ts b/packages/sdk/src/overlay-tools/LookupResolver.ts index e38f6338b..2d1ccb985 100644 --- a/packages/sdk/src/overlay-tools/LookupResolver.ts +++ b/packages/sdk/src/overlay-tools/LookupResolver.ts @@ -384,7 +384,7 @@ function lookupDiscoveryBound(limits: LookupLimits): LookupDiscoveryBound { /** In-flight discovery identity: service plus the limits that shape tracker work. */ function lookupDiscoveryCacheKey(service: string, limits: LookupLimits): string { const bound = lookupDiscoveryBound(limits) - return JSON.stringify([ + return stringifyBRC100([ service, bound.maxHosts, bound.maxHostsPerTracker, diff --git a/packages/sdk/src/overlay-tools/__tests/LookupResolver.dynamic.test.ts b/packages/sdk/src/overlay-tools/__tests/LookupResolver.dynamic.test.ts index 760b1ecd9..ac2959eb6 100644 --- a/packages/sdk/src/overlay-tools/__tests/LookupResolver.dynamic.test.ts +++ b/packages/sdk/src/overlay-tools/__tests/LookupResolver.dynamic.test.ts @@ -884,6 +884,49 @@ describe('LookupResolver dynamic discovery', () => { ) }) + it('does not coalesce in-flight discovery across distinct caller limits', async () => { + const tracker = 'https://inflight-key-tracker.example' + const host = 'https://inflight-key-host.example' + const service = 'ls_inflight_key' + const receipt = await slapReceipt(180, host, service) + let finishTracker: (() => void) | undefined + const trackerGate = new Promise(resolve => { + finishTracker = resolve + }) + const lookup = jest.fn(async (url: string) => { + if (url === tracker) { + await trackerGate + return { type: 'output-list' as const, outputs: [receipt] } + } + return { type: 'output-list' as const, outputs: [] } + }) + const resolver = new LookupResolver({ facilitator: { lookup }, slapTrackers: [tracker] }) + const first = resolver.query$({ service, query: { n: 1 } }, undefined, { + limits: { maxHosts: 1 } + })[Symbol.asyncIterator]() + const firstPending = first.next() + await Promise.resolve() + const second = resolver.query$({ service, query: { n: 2 } }, undefined, { + limits: { maxHosts: 2 } + })[Symbol.asyncIterator]() + const secondPending = second.next() + await Promise.resolve() + + expect((resolver as any).hostsInFlight.size).toBe(2) + const keys = Array.from((resolver as any).hostsInFlight.keys()) as string[] + expect(keys).toHaveLength(2) + expect(keys[0]).not.toEqual(keys[1]) + expect(keys.every(key => key.includes(service))).toBe(true) + expect(lookup.mock.calls.filter(([url]) => url === tracker)).toHaveLength(2) + + finishTracker?.() + await jest.runAllTimersAsync() + await firstPending + await secondPending + await first.return?.() + await second.return?.() + }) + it('throws from query() when a deadline expires before any host is admitted', async () => { const tracker = 'https://deadline-miss-tracker.example' const lookup = jest.fn( From 134d6fb77cadbf07b2fbf2dca0dc12ba973b5c86 Mon Sep 17 00:00:00 2001 From: Deggen Date: Tue, 8 Sep 2026 22:00:47 -0500 Subject: [PATCH 07/58] fix(sonar): evidence coordinator and chaintracks findings Drop redundant optional-undefined on lookup evidence limits, extract parseEvidence and Chaintracks header-retry helpers below S3776, and rename coordinator catch params to error_. --- .../sdk/src/overlay-tools/LookupResolver.ts | 2 +- .../src/transaction/TransactionEvidence.ts | 80 ++++++++--- .../TransactionEvidenceCoordinator.ts | 16 +-- .../chaintracker/ChaintracksChainTracker.ts | 128 ++++++++++-------- 4 files changed, 140 insertions(+), 86 deletions(-) diff --git a/packages/sdk/src/overlay-tools/LookupResolver.ts b/packages/sdk/src/overlay-tools/LookupResolver.ts index eac559734..640b5cfae 100644 --- a/packages/sdk/src/overlay-tools/LookupResolver.ts +++ b/packages/sdk/src/overlay-tools/LookupResolver.ts @@ -524,7 +524,7 @@ interface LookupQuerySessionOptions { softTimeoutMs?: number waitForAllHosts: boolean correlationId?: string - evidenceLimits?: LookupQueryOptions['evidenceLimits'] + evidenceLimits?: { maxOutputs?: number; maxBytes?: number } resolveTxId: (output: LookupAnswer['outputs'][number], now: number) => string | null } diff --git a/packages/sdk/src/transaction/TransactionEvidence.ts b/packages/sdk/src/transaction/TransactionEvidence.ts index 070bbea6c..89b501a4c 100644 --- a/packages/sdk/src/transaction/TransactionEvidence.ts +++ b/packages/sdk/src/transaction/TransactionEvidence.ts @@ -86,32 +86,73 @@ export interface EvidenceCandidate { graphBinding: string } -export function parseEvidence( +function isInvalidEvidenceByte(byte: number): boolean { + return !Number.isInteger(byte) || byte < 0 || byte > 255 +} + +function snapshotEvidenceBytes( evidence: TransactionEvidence, limits: TransactionEvidenceLimits -): EvidenceCandidate { +): number[] { const outputIndex = evidence.outputIndex - const hint = evidence.txid if (!Array.isArray(evidence.beef) || !Number.isSafeInteger(outputIndex) || outputIndex < 0) { throw new TransactionEvidenceError('invalid-evidence') } if (evidence.beef.length > limits.candidateBytes) throw new TransactionEvidenceError('limit') // Snapshot and validate BEFORE either parsing or computing the receipt digest. const bytes = evidence.beef.slice() - if (bytes.length === 0 || bytes.some(byte => !Number.isInteger(byte) || byte < 0 || byte > 255)) { + if (bytes.length === 0 || bytes.some(isInvalidEvidenceByte)) { throw new TransactionEvidenceError('invalid-evidence') } + return bytes +} + +function loadEvidenceTransaction( + bytes: number[], + limits: TransactionEvidenceLimits, + hint: string | undefined, + outputIndex: number +): Transaction { const beef = Beef.fromBinary(bytes) if (beef.txs.length > limits.transactions) throw new TransactionEvidenceError('limit') const target = beef.atomicTxid ?? beef.txs.at(-1)?.txid - const tx = target === undefined ? undefined : beef.findAtomicTransaction(target) + if (target === undefined) throw new TransactionEvidenceError('invalid-evidence') + const tx = beef.findAtomicTransaction(target) if (tx === undefined) throw new TransactionEvidenceError('invalid-evidence') const txid = tx.id('hex') if (hint !== undefined && (typeof hint !== 'string' || hint.toLowerCase() !== txid)) { throw new TransactionEvidenceError('invalid-evidence') } if (outputIndex >= tx.outputs.length) throw new TransactionEvidenceError('invalid-evidence') + return tx +} + +function graphScriptBytes(tx: Transaction): number { + let scriptBytes = 0 + for (const output of tx.outputs) scriptBytes += output.lockingScript.toBinary().length + for (const input of tx.inputs) scriptBytes += input.unlockingScript?.toBinary().length ?? 0 + return scriptBytes +} + +function enqueueUnconfirmedParents( + current: Transaction, + pending: Transaction[], + spent: Set +): void { + for (const input of current.inputs) { + if (input.sourceTransaction == null) throw new TransactionEvidenceError('invalid-evidence') + const sourceTxid = input.sourceTransaction.id('hex') + if (input.sourceTXID !== undefined && input.sourceTXID !== sourceTxid) { + throw new TransactionEvidenceError('invalid-evidence') + } + const outpoint = `${sourceTxid}.${input.sourceOutputIndex}` + if (spent.has(outpoint)) throw new TransactionEvidenceError('invalid-evidence') + spent.add(outpoint) + pending.push(input.sourceTransaction) + } +} +function assertUnconfirmedGraph(tx: Transaction, limits: TransactionEvidenceLimits): void { // Always walk the COMPLETE unconfirmed graph, including on a positive cache hit. // A per-ancestor verdict cannot establish graph-wide spend consistency. const pending = [tx] @@ -124,27 +165,28 @@ export function parseEvidence( const id = current.id('hex') if (visited.has(id)) continue visited.add(id) - for (const output of current.outputs) scriptBytes += output.lockingScript.toBinary().length - for (const input of current.inputs) scriptBytes += input.unlockingScript?.toBinary().length ?? 0 + scriptBytes += graphScriptBytes(current) inputs += current.inputs.length - if (inputs > limits.inputs || scriptBytes > limits.scriptBytes) + if (inputs > limits.inputs || scriptBytes > limits.scriptBytes) { throw new TransactionEvidenceError('limit') + } if (current.merklePath != null) continue if (current.inputs.length === 0) throw new TransactionEvidenceError('invalid-evidence') - for (const input of current.inputs) { - if (input.sourceTransaction == null) throw new TransactionEvidenceError('invalid-evidence') - const sourceTxid = input.sourceTransaction.id('hex') - if (input.sourceTXID !== undefined && input.sourceTXID !== sourceTxid) - throw new TransactionEvidenceError('invalid-evidence') - const outpoint = `${sourceTxid}.${input.sourceOutputIndex}` - if (spent.has(outpoint)) throw new TransactionEvidenceError('invalid-evidence') - spent.add(outpoint) - pending.push(input.sourceTransaction) - } + enqueueUnconfirmedParents(current, pending, spent) } +} + +export function parseEvidence( + evidence: TransactionEvidence, + limits: TransactionEvidenceLimits +): EvidenceCandidate { + const outputIndex = evidence.outputIndex + const bytes = snapshotEvidenceBytes(evidence, limits) + const tx = loadEvidenceTransaction(bytes, limits, evidence.txid, outputIndex) + assertUnconfirmedGraph(tx, limits) return { tx, - txid, + txid: tx.id('hex'), receipt: toHex(sha256(bytes)), byteLength: bytes.length, outputIndex, diff --git a/packages/sdk/src/transaction/TransactionEvidenceCoordinator.ts b/packages/sdk/src/transaction/TransactionEvidenceCoordinator.ts index 921c67158..bdeb252c6 100644 --- a/packages/sdk/src/transaction/TransactionEvidenceCoordinator.ts +++ b/packages/sdk/src/transaction/TransactionEvidenceCoordinator.ts @@ -173,8 +173,8 @@ export class TransactionEvidenceCoordinator { let candidate: EvidenceCandidate try { candidate = parseEvidence(evidence, this.limits) - } catch (error) { - throw outcome(error) + } catch (error_) { + throw outcome(error_) } let job = this.work.get(candidate.txid) if (job === undefined) { @@ -287,8 +287,8 @@ export class TransactionEvidenceCoordinator { this.check(job, job.controller.signal) this.finish(job, cached) return - } catch (failure) { - error = outcome(failure) + } catch (error_) { + error = outcome(error_) this.removePositive(job.txid) } } @@ -363,12 +363,12 @@ export class TransactionEvidenceCoordinator { this.cache(job.txid, positive) this.finish(job, positive) return - } catch (failure) { - error = outcome(failure) + } catch (error_) { + error = outcome(error_) } } - } catch (failure) { - error = outcome(failure) + } catch (error_) { + error = outcome(error_) } this.finish(job, undefined, error) } diff --git a/packages/wallet/wallet-toolbox/src/services/chaintracker/ChaintracksChainTracker.ts b/packages/wallet/wallet-toolbox/src/services/chaintracker/ChaintracksChainTracker.ts index 0a90d1ad8..fdbfcc9e3 100644 --- a/packages/wallet/wallet-toolbox/src/services/chaintracker/ChaintracksChainTracker.ts +++ b/packages/wallet/wallet-toolbox/src/services/chaintracker/ChaintracksChainTracker.ts @@ -179,68 +179,89 @@ export class ChaintracksChainTracker implements ChainTracker { parent?: TelemetrySpan, signal?: AbortSignal ): Promise { - const chaintracks = this.chaintracks - const context = this.verificationContext - let header: BlockHeader | undefined - - const retries = Math.max(1, this.options.maxRetries ?? 6) - const retryDelayMs = this.options.retryDelayMs ?? 250 + const header = await this.fetchCanonicalHeader( + this.chaintracks, + this.verificationContext, + height, + parent, + signal + ) + if (header == null) return false - let error: WalletError | undefined + // Diagnostic only: a root is always freshly read from the current canonical source above. + this.cache[height] = header.merkleRoot + this.cacheUpdatedAt[height] = Date.now() + this.pruneDiagnosticCache() - for (let tryCount = 1; tryCount <= retries; tryCount++) { - try { - this.throwIfAborted(signal) - if (this.chaintracks !== chaintracks || this.verificationContext !== context) { - throw new Error('Chaintracks provider changed during header lookup') - } - header = - parent == null - ? await chaintracks.findHeaderForHeight(height) - : await this.telemetry.withSpan( - 'wallet.chaintracks.find_header', - { - component: 'chaintracks-chain-tracker', - kind: 'client', - parent: parent.context, - attributes: { - 'retry.attempt': tryCount - } - }, - async () => await chaintracks.findHeaderForHeight(height) - ) + const valid = header.merkleRoot === root + parent?.end({ + attributes: { + 'chaintracks.cache_hit': false, + 'chaintracks.valid': valid + } + }) + return valid + } - if (this.chaintracks !== chaintracks || this.verificationContext !== context) { - throw new Error('Chaintracks provider changed during header lookup') - } + private assertProviderUnchanged(chaintracks: ChaintracksClientApi, context: number): void { + if (this.chaintracks !== chaintracks || this.verificationContext !== context) { + throw new Error('Chaintracks provider changed during header lookup') + } + } - if (header == null) { - if (tryCount >= retries) return false - this.throwIfAborted(signal) - await wait(retryDelayMs) - continue + private async readHeaderForHeight( + chaintracks: ChaintracksClientApi, + height: number, + tryCount: number, + parent?: TelemetrySpan + ): Promise { + if (parent == null) return await chaintracks.findHeaderForHeight(height) + return await this.telemetry.withSpan( + 'wallet.chaintracks.find_header', + { + component: 'chaintracks-chain-tracker', + kind: 'client', + parent: parent.context, + attributes: { + 'retry.attempt': tryCount } + }, + async () => await chaintracks.findHeaderForHeight(height) + ) + } - break + private async fetchCanonicalHeader( + chaintracks: ChaintracksClientApi, + context: number, + height: number, + parent: TelemetrySpan | undefined, + signal: AbortSignal | undefined + ): Promise { + const retries = Math.max(1, this.options.maxRetries ?? 6) + const retryDelayMs = this.options.retryDelayMs ?? 250 + for (let tryCount = 1; tryCount <= retries; tryCount++) { + try { + this.throwIfAborted(signal) + this.assertProviderUnchanged(chaintracks, context) + const header = await this.readHeaderForHeight(chaintracks, height, tryCount, parent) + this.assertProviderUnchanged(chaintracks, context) + if (header != null) return header + if (tryCount >= retries) return undefined + this.throwIfAborted(signal) + await wait(retryDelayMs) } catch (error_: unknown) { this.throwIfAborted(signal) - if (this.chaintracks !== chaintracks || this.verificationContext !== context) { - throw new Error('Chaintracks provider changed during header lookup') - } - error = WalletError.fromUnknown(error_) - if (tryCount >= retries) { - throw error - } + this.assertProviderUnchanged(chaintracks, context) + const error = WalletError.fromUnknown(error_) + if (tryCount >= retries) throw error this.throwIfAborted(signal) await wait(retryDelayMs) } } + return undefined + } - if (header == null) return false - - // Diagnostic only: a root is always freshly read from the current canonical source above. - this.cache[height] = header.merkleRoot - this.cacheUpdatedAt[height] = Date.now() + private pruneDiagnosticCache(): void { const now = Date.now() const entries = Object.entries(this.cacheUpdatedAt).sort(([, a], [, b]) => a - b) for (const [index, [cachedHeight, updatedAt]] of entries.entries()) { @@ -248,15 +269,6 @@ export class ChaintracksChainTracker implements ChainTracker { delete this.cache[Number(cachedHeight)] delete this.cacheUpdatedAt[Number(cachedHeight)] } - - const valid = header.merkleRoot === root - parent?.end({ - attributes: { - 'chaintracks.cache_hit': false, - 'chaintracks.valid': valid - } - }) - return valid } private throwIfAborted(signal?: AbortSignal): void { From 37f5927fe3204ff1bb5fd29f6212190c4b508596 Mon Sep 17 00:00:00 2001 From: Deggen Date: Tue, 8 Sep 2026 22:12:36 -0500 Subject: [PATCH 08/58] fix(sonar): overlay lookup discovery complexity and style Extract query/session and host-tracking helpers so cognitive complexity stays within Sonar's limit, and clear the remaining overlay-tools findings without changing lookup behavior. --- .../sdk/src/overlay-tools/LookupDiscovery.ts | 89 +- .../sdk/src/overlay-tools/LookupHostQueue.ts | 10 +- .../sdk/src/overlay-tools/LookupResolver.ts | 946 ++++++++++++------ .../src/overlay-tools/LookupResponseReader.ts | 141 +-- 4 files changed, 799 insertions(+), 387 deletions(-) diff --git a/packages/sdk/src/overlay-tools/LookupDiscovery.ts b/packages/sdk/src/overlay-tools/LookupDiscovery.ts index 12dbe6641..8a97eb2ac 100644 --- a/packages/sdk/src/overlay-tools/LookupDiscovery.ts +++ b/packages/sdk/src/overlay-tools/LookupDiscovery.ts @@ -18,6 +18,15 @@ export class LookupDiscovery { private readonly listeners = new Set<(state: LookupDiscoveryUpdate) => void>() private started = false private abandoned = false + private readonly consume = (bytes: number): void => { + if (this.abandoned) throw new LookupResourceLimitError('abandoned') + if (bytes > this.limits.maxTotalBytes - this.state.receivedBytes) { + this.state.limitsHit.add('maxTotalBytes') + throw new LookupResourceLimitError('maxTotalBytes') + } + this.state.receivedBytes += bytes + this.emit() + } constructor( private readonly trackers: string[], @@ -50,49 +59,59 @@ export class LookupDiscovery { for (const listener of this.listeners) listener(this.state) } - private consume = (bytes: number): void => { - if (this.abandoned) throw new LookupResourceLimitError('abandoned') - if (bytes > this.limits.maxTotalBytes - this.state.receivedBytes) { - this.state.limitsHit.add('maxTotalBytes') - throw new LookupResourceLimitError('maxTotalBytes') + private collectTrackerHosts(candidates: string[], share: number): string[] { + const hosts = new Set() + for (const candidate of candidates) { + const host = normalizeLookupHost(candidate) + if (host === null) { + this.state.skippedHosts++ + continue + } + if (hosts.has(host)) continue + if (hosts.size >= share) { + this.state.skippedHosts++ + this.state.limitsHit.add('maxHostsPerTracker') + } else { + hosts.add(host) + } + } + return Array.from(hosts) + } + + private recordTrackerFailure(error: unknown): void { + if (error instanceof LookupResourceLimitError) this.state.limitsHit.add(error.limit) + else if (!this.controller.signal.aborted) this.state.trackersFailed++ + } + + private async processTracker(tracker: string, share: number): Promise { + try { + const candidates = await this.lookup(tracker, this.controller.signal, this.consume) + if (this.abandoned) return + this.state.sources.set(tracker, this.collectTrackerHosts(candidates, share)) + } catch (error) { + this.recordTrackerFailure(error) + } finally { + this.state.trackersCompleted++ + this.emit() + } + } + + private async drainTrackers(share: number, cursor: { value: number }): Promise { + while (!this.controller.signal.aborted && cursor.value < this.trackers.length) { + await this.processTracker(this.trackers[cursor.value++], share) } - this.state.receivedBytes += bytes - this.emit() } private async run(): Promise { - let cursor = 0 + const cursor = { value: 0 } // Each tracker keeps a reserved share, so an early advertisement flood // cannot consume the complete candidate budget before later sources reply. const share = Math.min(this.limits.maxHostsPerTracker, Math.max(1, Math.floor(this.limits.maxHosts / Math.max(1, this.trackers.length)))) - const worker = async (): Promise => { - while (!this.controller.signal.aborted && cursor < this.trackers.length) { - const tracker = this.trackers[cursor++] - try { - const candidates = await this.lookup(tracker, this.controller.signal, this.consume) - if (this.abandoned) return - const hosts = new Set() - for (const candidate of candidates) { - const host = normalizeLookupHost(candidate) - if (host === null) { this.state.skippedHosts++; continue } - if (hosts.has(host)) continue - if (hosts.size >= share) { - this.state.skippedHosts++ - this.state.limitsHit.add('maxHostsPerTracker') - } else hosts.add(host) - } - this.state.sources.set(tracker, Array.from(hosts)) - } catch (error) { - if (error instanceof LookupResourceLimitError) this.state.limitsHit.add(error.limit) - else if (!this.controller.signal.aborted) this.state.trackersFailed++ - } finally { - this.state.trackersCompleted++ - this.emit() - } - } - } - await Promise.all(Array.from({ length: Math.min(this.limits.trackerConcurrency, this.trackers.length) }, worker)) + await Promise.all(Array.from( + { length: Math.min(this.limits.trackerConcurrency, this.trackers.length) }, + () => this.drainTrackers(share, cursor) + )) this.state.done = true this.finish(this.state, this.abandoned) this.emit() diff --git a/packages/sdk/src/overlay-tools/LookupHostQueue.ts b/packages/sdk/src/overlay-tools/LookupHostQueue.ts index 6e5d86cf4..33d0f0d43 100644 --- a/packages/sdk/src/overlay-tools/LookupHostQueue.ts +++ b/packages/sdk/src/overlay-tools/LookupHostQueue.ts @@ -46,11 +46,15 @@ export class LookupHostQueue { private next(): string | undefined { const sources = Array.from(this.queues.values()) - for (let i = 0; i < sources.length; i++) { - const index = this.cursor++ % sources.length + for (const [offset] of sources.entries()) { + const index = (this.cursor + offset) % sources.length const host = sources[index].shift() - if (host !== undefined) return host + if (host !== undefined) { + this.cursor = index + 1 + return host + } } + this.cursor += sources.length return undefined } diff --git a/packages/sdk/src/overlay-tools/LookupResolver.ts b/packages/sdk/src/overlay-tools/LookupResolver.ts index 2d1ccb985..047372ba1 100644 --- a/packages/sdk/src/overlay-tools/LookupResolver.ts +++ b/packages/sdk/src/overlay-tools/LookupResolver.ts @@ -291,6 +291,20 @@ function isFreeformAnswer(value: unknown): value is LookupFreeformAnswer { return answer.type === 'freeform' && Object.hasOwn(answer, 'result') } +function lookupAnswerRetainedBytes(answer: LookupAnswer): number { + let retained = 0 + for (const output of answer.outputs) retained += output.beef.length + (output.context?.length ?? 0) + return retained +} + +function copyLookupOutput(output: LookupAnswer['outputs'][number]): LookupAnswer['outputs'][number] { + return { + ...output, + beef: output.beef.slice(), + ...(output.context === undefined ? {} : { context: output.context.slice() }) + } +} + /** A wall-clock deadline that rejects after `timeoutMs`, optionally aborting a controller. */ interface Deadline { /** Rejects with `Error('Request timed out')` once the timer fires. */ @@ -643,7 +657,7 @@ interface LookupQuerySessionOptions { waitForAllHosts: boolean correlationId?: string limits: LookupLimits - onEvidence?: LookupQueryOptions['onEvidence'] + onEvidence: LookupQueryOptions['onEvidence'] resolveTxId: (output: LookupAnswer['outputs'][number], now: number) => string | null } @@ -824,6 +838,38 @@ interface LookupHostFailureContext { notificationCooldownMs: number } +/** Mutable orchestration state for one raw lookup query. */ +interface LookupQueryRun { + question: LookupQuestion + timeout: number | undefined + options: LookupQueryOptions | undefined + limits: LookupLimits + session: LookupQuerySession + controller: AbortController + queue: LookupHostQueue + seen: Set + sourceQuota: number + discoveryBytes: number + discoverySkipped: number + processedSources: Set + releaseDiscovery: (() => void) | undefined + discoveryFinished: boolean + noHostsError: Error | undefined + cleaned: boolean + timer: ReturnType + abort: () => void + iteratorSignal: AbortSignal +} + +interface LookupDiscoveryPlan { + key: string + cached: LookupHostsCacheEntry | undefined + configuredAdditional: string[] + cacheHasAvailableHost: boolean + refresh: boolean + initialQuota: number +} + /** * Represents a Lookup Resolver. */ @@ -1117,277 +1163,530 @@ export default class LookupResolver { } } - private async *queryProgress(inputQuestion: LookupQuestion, timeout: number | undefined, - options: LookupQueryOptions | undefined, iteratorSignal: AbortSignal): AsyncIterable { - // Capture JSON wire values once, before any discovery or queued host can - // observe a caller's later mutation. Custom non-JSON questions retain their - // historical facilitator-defined semantics when they cannot be cloned. - let question: LookupQuestion - try { question = structuredClone(inputQuestion) } - catch { - if (this.facilitator instanceof HTTPSOverlayLookupFacilitator) question = JSON.parse(stringifyBRC100(inputQuestion)) as LookupQuestion - else question = { ...inputQuestion } + private cloneLookupQuestion(inputQuestion: LookupQuestion): LookupQuestion { + try { + return structuredClone(inputQuestion) + } catch { + if (this.facilitator instanceof HTTPSOverlayLookupFacilitator) { + return JSON.parse(stringifyBRC100(inputQuestion)) as LookupQuestion + } + return { ...inputQuestion } } - const limits = lookupLimits(this.limits, options?.limits, { + } + + private lookupQueryLimits(options: LookupQueryOptions | undefined): LookupLimits { + return lookupLimits(this.limits, options?.limits, { ...(options?.evidenceLimits?.maxOutputs === undefined ? {} : { maxEvidenceOutputs: options.evidenceLimits.maxOutputs }), ...(options?.evidenceLimits?.maxBytes === undefined ? {} : { maxEvidenceBytes: options.evidenceLimits.maxBytes }) }) - const deadlineMs = options?.deadlineMs ?? 10_000 + } + + private assertLookupDeadline(deadlineMs: number): void { if (!Number.isFinite(deadlineMs) || deadlineMs < 0 || deadlineMs > 2_147_483_647) { throw new RangeError('Lookup deadlineMs must be between 0 and 2147483647') } - if (this.activeQueries >= 128) throw new LookupResourceLimitError('activeQueries') - this.activeQueries++ + } + + private lookupQueryStopped(run: LookupQueryRun): boolean { + return run.controller.signal.aborted || run.session.closed + } + + private consumeLookupQueryBytes(run: LookupQueryRun, bytes: number): void { + if (run.controller.signal.aborted) throw lookupAbortError() + if (bytes > run.limits.maxTotalBytes - run.session.receivedBytes) { + run.session.limit('maxTotalBytes') + throw new LookupResourceLimitError('maxTotalBytes') + } + run.session.receivedBytes += bytes + } + + private skipQueuedLookupHosts(run: LookupQueryRun, count: number, limited: boolean): void { + if (count <= 0) return + run.session.skippedHosts += count + if (limited) run.session.limit('maxHosts') + } + + private stopLookupQueryRun(run: LookupQueryRun, reason: 'deadline' | 'cancelled'): void { + if (run.controller.signal.aborted) return + run.session.limit(reason) + run.session.terminalReason = reason + run.session.accepting = false + run.session.discoveryComplete = false + run.controller.abort() + run.releaseDiscovery?.() + run.discoveryFinished = true + run.queue.cancel() + } + + private cleanupLookupQueryRun(run: LookupQueryRun): void { + if (run.cleaned) return + run.cleaned = true + run.session.close() + clearTimeout(run.timer) + run.options?.signal?.removeEventListener('abort', run.abort) + run.iteratorSignal.removeEventListener('abort', run.abort) + run.controller.abort() + run.releaseDiscovery?.() + run.queue.cancel() + this.activeQueries-- + } + + private finishLookupSources(run: LookupQueryRun): void { + run.discoveryFinished = true + run.queue.finishSources() + } + + private lookupQueryNoHostsError(run: LookupQueryRun): Error | undefined { + if (run.session.hostCount !== 0 || run.session.terminalReason !== 'settled') return undefined + if (run.noHostsError !== undefined) return run.noHostsError + return new Error( + `No competent ${this.networkPreset} hosts found by the SLAP trackers for lookup service: ${run.question.service}` + ) + } + + private finishLookupQueryRun(run: LookupQueryRun): void { + if (!run.discoveryFinished) return + run.session.finish(this.lookupQueryNoHostsError(run)) + } + + private createLookupQueryRun( + question: LookupQuestion, + timeout: number | undefined, + options: LookupQueryOptions | undefined, + limits: LookupLimits, + iteratorSignal: AbortSignal, + deadlineMs: number + ): LookupQueryRun { const controller = new AbortController() const session = new LookupQuerySession({ - graceMs: options?.graceMs ?? 80, softTimeoutMs: options?.softTimeoutMs, + graceMs: options?.graceMs ?? 80, + softTimeoutMs: options?.softTimeoutMs, waitForAllHosts: options?.waitForAllHosts ?? options?.holdForUnknownHosts ?? false, correlationId: options?.correlationId ?? (this.telemetry.enabled ? this.telemetry.createCorrelationId() : undefined), - limits, onEvidence: options?.onEvidence, resolveTxId: (output, now) => this.resolveTxIdForOutput(output, now) + limits, + onEvidence: options?.onEvidence, + resolveTxId: (output, now) => this.resolveTxIdForOutput(output, now) }) - let releaseDiscovery: (() => void) | undefined - let discoveryFinished = false - let noHostsError: Error | undefined - const seen = new Set() - let sourceQuota = limits.maxHosts - let discoveryBytes = 0 - let discoverySkipped = 0 - const processedSources = new Set() - const consume = (bytes: number): void => { - if (controller.signal.aborted) throw lookupAbortError() - if (bytes > limits.maxTotalBytes - session.receivedBytes) { - session.limit('maxTotalBytes') - throw new LookupResourceLimitError('maxTotalBytes') - } - session.receivedBytes += bytes + let run: LookupQueryRun + run = { + question, + timeout, + options, + limits, + session, + controller, + seen: new Set(), + sourceQuota: limits.maxHosts, + discoveryBytes: 0, + discoverySkipped: 0, + processedSources: new Set(), + releaseDiscovery: undefined, + discoveryFinished: false, + noHostsError: undefined, + cleaned: false, + iteratorSignal, + abort: () => this.stopLookupQueryRun(run, 'cancelled'), + queue: new LookupHostQueue( + limits.maxHosts, + limits.hostConcurrency, + async host => await this.runQueuedLookupHost(run, host), + (count, limited) => this.skipQueuedLookupHosts(run, count, limited) + ), + timer: setTimeout(() => this.stopLookupQueryRun(run, 'deadline'), deadlineMs) } - const queue = new LookupHostQueue(limits.maxHosts, limits.hostConcurrency, async host => { - if (controller.signal.aborted) return - session.hostCount++ - const startedAt = Date.now() - try { - const answer = await this.lookupHostWithTracking(host, question, timeout, controller.signal, { - maxResponseBytes: limits.maxResponseBytes, maxOutputs: limits.maxOutputs, consumeBytes: consume - }) - if (controller.signal.aborted || session.closed) return - let ownedAnswer = answer - if (isOutputListAnswer(answer)) { - let retained = 0 - for (const output of answer.outputs) retained += output.beef.length + (output.context?.length ?? 0) - if (retained > limits.maxTotalBytes - session.retainedBytes) throw new LookupResourceLimitError('maxTotalBytes') - session.retainedBytes += retained - ownedAnswer = { type: 'output-list', outputs: answer.outputs.map(output => ({ - ...output, beef: output.beef.slice(), ...(output.context === undefined ? {} : { context: output.context.slice() }) - })) } - session.receiveEvidence(host, ownedAnswer, options?.onEvidence) - } - if (controller.signal.aborted || session.closed) return - this.recordLookupHostAnswer(session, question.service, host, ownedAnswer, startedAt, session.correlationId) - } catch (error) { - if (controller.signal.aborted || session.closed) return - if (error instanceof LookupResourceLimitError) session.limit(error.limit) - else this.recordLookupHostFailure({ - session, service: question.service, host, hostStartedAt: startedAt, - correlationId: session.correlationId, onUnreachableHost: options?.onUnreachableHost, - notificationCooldownMs: this.unreachableNotificationCooldown(options) - }, error) - } finally { session.recordDone() } - }, (count, limited) => { if (count > 0) { session.skippedHosts += count; if (limited) session.limit('maxHosts') } }) - - const stop = (reason: 'deadline' | 'cancelled'): void => { - if (controller.signal.aborted) return - session.limit(reason) - session.terminalReason = reason - session.accepting = false - session.discoveryComplete = false - controller.abort() - releaseDiscovery?.() - discoveryFinished = true - queue.cancel() - } - const abort = (): void => stop('cancelled') - options?.signal?.addEventListener('abort', abort, { once: true }) - iteratorSignal.addEventListener('abort', abort, { once: true }) - const timer = setTimeout(() => stop('deadline'), deadlineMs) - - let cleaned = false - const cleanup = (): void => { - if (cleaned) return - cleaned = true - session.close() - clearTimeout(timer) - options?.signal?.removeEventListener('abort', abort) - iteratorSignal.removeEventListener('abort', abort) - controller.abort() - releaseDiscovery?.() - queue.cancel() - this.activeQueries-- - } - - const admit = (source: string, candidates: string[]): void => { - if (controller.signal.aborted) return - const hosts: string[] = [] - const scanLimit = Math.min(candidates.length, limits.maxHosts * 4) - if (candidates.length > scanLimit) { session.skippedHosts += candidates.length - scanLimit; session.limit('maxHosts') } - for (let candidateIndex = 0; candidateIndex < scanLimit; candidateIndex++) { - const candidate = candidates[candidateIndex] - const host = normalizeLookupHost(candidate, source === 'configured' || source === 'additional') - if (host === null) { session.skippedHosts++; continue } - if (seen.has(host)) continue - // Keep this dedup set finite, too. Source-level discovery reservations - // protect later trackers before candidates reach this queue. - if (seen.size >= limits.maxHosts) { session.skippedHosts++; session.limit('maxHosts'); continue } - seen.add(host) - session.discoveredHosts++ - hosts.push(host) + options?.signal?.addEventListener('abort', run.abort, { once: true }) + iteratorSignal.addEventListener('abort', run.abort, { once: true }) + return run + } + + private retainLookupHostAnswer( + run: LookupQueryRun, + host: string, + answer: LookupFacilitatorAnswer + ): LookupFacilitatorAnswer { + if (!isOutputListAnswer(answer)) return answer + const retained = lookupAnswerRetainedBytes(answer) + if (retained > run.limits.maxTotalBytes - run.session.retainedBytes) { + throw new LookupResourceLimitError('maxTotalBytes') + } + run.session.retainedBytes += retained + const ownedAnswer: LookupAnswer = { + type: 'output-list', + outputs: answer.outputs.map(copyLookupOutput) + } + run.session.receiveEvidence(host, ownedAnswer, run.options?.onEvidence) + return ownedAnswer + } + + private recordQueuedLookupHostFailure( + run: LookupQueryRun, + host: string, + startedAt: number, + error: unknown + ): void { + if (this.lookupQueryStopped(run)) return + if (error instanceof LookupResourceLimitError) { + run.session.limit(error.limit) + return + } + this.recordLookupHostFailure({ + session: run.session, + service: run.question.service, + host, + hostStartedAt: startedAt, + correlationId: run.session.correlationId, + onUnreachableHost: run.options?.onUnreachableHost, + notificationCooldownMs: this.unreachableNotificationCooldown(run.options) + }, error) + } + + private async settleQueuedLookupHost(run: LookupQueryRun, host: string, startedAt: number): Promise { + const answer = await this.lookupHostWithTracking(host, run.question, run.timeout, run.controller.signal, { + maxResponseBytes: run.limits.maxResponseBytes, + maxOutputs: run.limits.maxOutputs, + consumeBytes: bytes => this.consumeLookupQueryBytes(run, bytes) + }) + if (this.lookupQueryStopped(run)) return + const ownedAnswer = this.retainLookupHostAnswer(run, host, answer) + if (this.lookupQueryStopped(run)) return + this.recordLookupHostAnswer( + run.session, + run.question.service, + host, + ownedAnswer, + startedAt, + run.session.correlationId + ) + } + + private async runQueuedLookupHost(run: LookupQueryRun, host: string): Promise { + if (run.controller.signal.aborted) return + run.session.hostCount++ + const startedAt = Date.now() + try { + await this.settleQueuedLookupHost(run, host, startedAt) + } catch (error) { + this.recordQueuedLookupHostFailure(run, host, startedAt, error) + } finally { + run.session.recordDone() + } + } + + private collectAdmittedLookupHosts(run: LookupQueryRun, source: string, candidates: string[]): string[] { + const hosts: string[] = [] + const scanLimit = Math.min(candidates.length, run.limits.maxHosts * 4) + if (candidates.length > scanLimit) { + run.session.skippedHosts += candidates.length - scanLimit + run.session.limit('maxHosts') + } + const allowParameters = source === 'configured' || source === 'additional' + for (const candidate of candidates.slice(0, scanLimit)) { + const host = normalizeLookupHost(candidate, allowParameters) + if (host === null) { + run.session.skippedHosts++ + continue } - if (hosts.length === 0) return - try { - const available = this.prepareHostsForQuery(hosts, `lookup service ${question.service}`) - session.skippedHosts += hosts.length - available.length - queue.add(source, available) - } catch (error) { - session.skippedHosts += hosts.length - noHostsError = error instanceof Error ? error : new Error(lookupErrorMessage(error)) + if (run.seen.has(host)) continue + if (run.seen.size >= run.limits.maxHosts) { + run.session.skippedHosts++ + run.session.limit('maxHosts') + continue } + run.seen.add(host) + run.session.discoveredHosts++ + hosts.push(host) } - const sourcesDone = (): void => { - discoveryFinished = true - queue.finishSources() - } - const acceptDiscovery = (state: LookupDiscoveryUpdate): void => { - if (controller.signal.aborted) return - session.trackersTotal = state.trackersTotal - session.trackersCompleted = state.trackersCompleted - session.trackersFailed = state.trackersFailed - session.skippedHosts += state.skippedHosts - discoverySkipped - discoverySkipped = state.skippedHosts - for (const name of state.limitsHit) session.limit(name) - try { consume(state.receivedBytes - discoveryBytes) } catch (error) { - if (error instanceof LookupResourceLimitError) session.limit(error.limit) - controller.abort() - releaseDiscovery?.() - queue.cancel() - discoveryFinished = true - return + return hosts + } + + private admitLookupHosts(run: LookupQueryRun, source: string, candidates: string[]): void { + if (run.controller.signal.aborted) return + const hosts = this.collectAdmittedLookupHosts(run, source, candidates) + if (hosts.length === 0) return + try { + const available = this.prepareHostsForQuery(hosts, `lookup service ${run.question.service}`) + run.session.skippedHosts += hosts.length - available.length + run.queue.add(source, available) + } catch (error) { + run.session.skippedHosts += hosts.length + run.noHostsError = error instanceof Error ? error : new Error(lookupErrorMessage(error)) + } + } + + private admitQuotaLimitedLookupHosts( + run: LookupQueryRun, + source: string, + hosts: string[], + quota: number, + limitName: string + ): void { + this.admitLookupHosts(run, source, hosts.slice(0, quota)) + if (hosts.length > quota) { + run.session.skippedHosts += hosts.length - quota + run.session.limit(limitName) + } + } + + private configuredLookupHosts(question: LookupQuestion): string[] { + if (question.service === 'ls_slap') { + if (this.networkPreset === 'local') return ['http://localhost:8080'] + return this.slapTrackers + } + return this.hostOverrides[question.service] ?? ['http://localhost:8080'] + } + + private admitConfiguredLookupSources(run: LookupQueryRun): void { + this.admitLookupHosts(run, 'configured', this.configuredLookupHosts(run.question)) + this.admitLookupHosts(run, 'additional', this.additionalHosts[run.question.service] ?? []) + run.session.discoveryComplete = true + this.finishLookupSources(run) + } + + private lookupCacheHasAvailableHost(cached: LookupHostsCacheEntry | undefined): boolean { + if (cached === undefined) return false + return cached.hosts.some(host => (this.hostReputation.snapshot(host)?.backoffUntil ?? 0) <= Date.now()) + } + + private planLookupDiscovery(run: LookupQueryRun): LookupDiscoveryPlan { + const cached = this.hostsCache.get(run.question.service) + const configuredAdditional = this.additionalHosts[run.question.service] ?? [] + const cacheHasAvailableHost = this.lookupCacheHasAvailableHost(cached) + const cacheCoversCaller = lookupDiscoveryCovers(cached, run.limits) + const cacheFresh = cached !== undefined && cached.expiresAt > Date.now() + const key = lookupDiscoveryCacheKey(run.question.service, run.limits) + const discovery = this.hostsInFlight.get(key) + const refresh = + discovery !== undefined || + cached === undefined || + !cacheCoversCaller || + !cacheFresh || + !cacheHasAvailableHost + const initialSources = + Number(cached !== undefined && cacheHasAvailableHost) + Number(configuredAdditional.length > 0) + const trackerShare = Math.max(1, Math.min(this.slapTrackers.length, run.limits.maxTrackers)) + const initialQuota = refresh + ? Math.max(1, Math.floor(run.limits.maxHosts / (initialSources + trackerShare))) + : run.limits.maxHosts + return { key, cached, configuredAdditional, cacheHasAvailableHost, refresh, initialQuota } + } + + private reuseCachedLookupDiscovery(run: LookupQueryRun, cached: LookupHostsCacheEntry | undefined): void { + run.session.discoveryComplete = cached?.discoveryComplete ?? true + run.session.trackersFailed = cached?.trackersFailed ?? 0 + for (const name of cached?.limitsHit ?? []) run.session.limit(name) + this.finishLookupSources(run) + } + + private selectSlapTrackers(run: LookupQueryRun): { trackers: string[], normalized: string[] } { + const scan = Math.min(this.slapTrackers.length, run.limits.maxTrackers) + const selected = Array.from( + { length: scan }, + (_unused, offset) => this.slapTrackers[(this.trackerCursor + offset) % this.slapTrackers.length] + ) + this.trackerCursor = (this.trackerCursor + scan) % Math.max(1, this.slapTrackers.length) + const normalized = Array.from(new Set(selected.map(host => normalizeLookupHost(host)).filter((host): host is string => host !== null))) + try { + return { + trackers: this.prepareHostsForQuery(normalized.slice(0, run.limits.maxTrackers), 'SLAP trackers'), + normalized } - discoveryBytes = state.receivedBytes - for (const [source, hosts] of state.sources) { - if (processedSources.has(source)) continue - processedSources.add(source) - admit(source, hosts.slice(0, sourceQuota)) - if (hosts.length > sourceQuota) { - session.skippedHosts += hosts.length - sourceQuota - session.limit('maxHostsPerTracker') - } + } catch (error) { + run.noHostsError = error instanceof Error ? error : new Error(lookupErrorMessage(error)) + return { trackers: [], normalized } + } + } + + private async lookupSlapTrackerHosts( + run: LookupQueryRun, + tracker: string, + signal: AbortSignal, + charge: (bytes: number) => void + ): Promise { + const answer = await this.lookupHostWithTracking( + tracker, + { service: 'ls_slap', query: { service: run.question.service } }, + MAX_TRACKER_WAIT_TIME, + signal, + { + maxResponseBytes: run.limits.maxResponseBytes, + maxOutputs: run.limits.maxOutputs, + consumeBytes: charge } - session.discoveryComplete = state.done && state.trackersFailed === 0 && state.limitsHit.size === 0 && state.skippedHosts === 0 - session.wake() - if (state.done) sourcesDone() + ) + const hosts = isOutputListAnswer(answer) ? this.extractHostsFromAnswer(answer, run.question.service) : [] + for (const host of hosts) { + if (this.advertisedBy.size >= this.hostsMaxEntries * run.limits.maxHosts) this.evictOldest(this.advertisedBy) + this.advertisedBy.set(host, tracker) } + return hosts + } + + private completeLookupDiscoveryRefresh( + run: LookupQueryRun, + key: string, + discovery: LookupDiscovery, + state: LookupDiscoveryUpdate, + abandoned: boolean + ): void { + if (this.hostsInFlight.get(key) !== discovery) return + this.hostsInFlight.delete(key) + if (abandoned) return + const hosts = Array.from(new Set(Array.from(state.sources.values()).flat())).slice(0, run.limits.maxHosts) + this.rememberDiscoveredHosts(run.question.service, hosts, run.limits, state) + } + + private createLookupDiscovery(run: LookupQueryRun, key: string): LookupDiscovery { + const selected = this.selectSlapTrackers(run) + let discovery: LookupDiscovery + discovery = new LookupDiscovery( + selected.trackers, + run.limits, + async (tracker, signal, charge) => await this.lookupSlapTrackerHosts(run, tracker, signal, charge), + (state, abandoned) => this.completeLookupDiscoveryRefresh(run, key, discovery, state, abandoned) + ) + if (this.slapTrackers.length > run.limits.maxTrackers) discovery.state.limitsHit.add('maxTrackers') + if ( + selected.normalized.length !== this.slapTrackers.length || + selected.trackers.length < Math.min(selected.normalized.length, run.limits.maxTrackers) + ) { + discovery.state.skippedHosts += this.slapTrackers.length - selected.trackers.length + } + return discovery + } + + private refreshLookupDiscovery(run: LookupQueryRun, plan: LookupDiscoveryPlan): void { + run.sourceQuota = Math.max( + 1, + Math.floor( + (run.limits.maxHosts - run.seen.size) / + Math.max(1, Math.min(this.slapTrackers.length, run.limits.maxTrackers)) + ) + ) + let discovery = this.hostsInFlight.get(plan.key) + if (discovery === undefined) { + discovery = this.createLookupDiscovery(run, plan.key) + this.hostsInFlight.set(plan.key, discovery) + } + run.releaseDiscovery = discovery.subscribe(state => this.acceptLookupDiscovery(run, state)) + if (run.controller.signal.aborted) run.releaseDiscovery() + } + + private admitDiscoveredLookupSources(run: LookupQueryRun): void { + const plan = this.planLookupDiscovery(run) + if (plan.cached !== undefined && plan.cacheHasAvailableHost) { + this.admitQuotaLimitedLookupHosts(run, 'cache', plan.cached.hosts, plan.initialQuota, 'maxHosts') + } + if (plan.configuredAdditional.length > 0) { + this.admitQuotaLimitedLookupHosts(run, 'additional', plan.configuredAdditional, plan.initialQuota, 'maxHosts') + } + if (plan.refresh) this.refreshLookupDiscovery(run, plan) + else this.reuseCachedLookupDiscovery(run, plan.cached) + } + + private admitLookupSources(run: LookupQueryRun): void { + if ( + run.question.service === 'ls_slap' || + this.hostOverrides[run.question.service] != null || + this.networkPreset === 'local' + ) { + this.admitConfiguredLookupSources(run) + return + } + this.admitDiscoveredLookupSources(run) + } + + private syncLookupDiscoveryProgress(run: LookupQueryRun, state: LookupDiscoveryUpdate): void { + run.session.trackersTotal = state.trackersTotal + run.session.trackersCompleted = state.trackersCompleted + run.session.trackersFailed = state.trackersFailed + run.session.skippedHosts += state.skippedHosts - run.discoverySkipped + run.discoverySkipped = state.skippedHosts + for (const name of state.limitsHit) run.session.limit(name) + } + + private chargeLookupDiscoveryBytes(run: LookupQueryRun, state: LookupDiscoveryUpdate): boolean { try { - this.telemetry.capture({ name: 'sdk.overlay.lookup.started', component: 'sdk.lookup-resolver', - severity: 'debug', correlationId: session.correlationId, - attributes: { service: question.service, network: this.networkPreset, hostCount: 0 } }) - if (options?.signal?.aborted === true || iteratorSignal.aborted) abort() - if (!controller.signal.aborted) { - if (question.service === 'ls_slap' || this.hostOverrides[question.service] != null || this.networkPreset === 'local') { - const direct = question.service === 'ls_slap' - ? (this.networkPreset === 'local' ? ['http://localhost:8080'] : this.slapTrackers) - : this.hostOverrides[question.service] ?? ['http://localhost:8080'] - admit('configured', direct) - admit('additional', this.additionalHosts[question.service] ?? []) - session.discoveryComplete = true - sourcesDone() - } else { - const cached = this.hostsCache.get(question.service) - const configuredAdditional = this.additionalHosts[question.service] ?? [] - const cacheHasAvailableHost = - cached?.hosts.some(host => (this.hostReputation.snapshot(host)?.backoffUntil ?? 0) <= Date.now()) ?? - false - const cacheCoversCaller = lookupDiscoveryCovers(cached, limits) - const cacheFresh = cached !== undefined && cached.expiresAt > Date.now() - const key = lookupDiscoveryCacheKey(question.service, limits) - let discovery = this.hostsInFlight.get(key) - const refresh = - discovery !== undefined || - cached === undefined || - !cacheCoversCaller || - !cacheFresh || - !cacheHasAvailableHost - const initialSources = - Number(cached !== undefined && cacheHasAvailableHost) + Number(configuredAdditional.length > 0) - const initialQuota = refresh ? Math.max(1, Math.floor(limits.maxHosts / (initialSources + Math.max(1, Math.min(this.slapTrackers.length, limits.maxTrackers))))) : limits.maxHosts - if (cached !== undefined && cacheHasAvailableHost) { - // Reserve a source share for cached membership and each late tracker. - const cachedLimit = initialQuota - admit('cache', cached.hosts.slice(0, cachedLimit)) - if (cached.hosts.length > cachedLimit) { session.skippedHosts += cached.hosts.length - cachedLimit; session.limit('maxHosts') } - } - if (configuredAdditional.length > 0) { - admit('additional', configuredAdditional.slice(0, initialQuota)) - if (configuredAdditional.length > initialQuota) { session.skippedHosts += configuredAdditional.length - initialQuota; session.limit('maxHosts') } - } - if (refresh) { - sourceQuota = Math.max(1, Math.floor((limits.maxHosts - seen.size) / Math.max(1, Math.min(this.slapTrackers.length, limits.maxTrackers)))) - if (discovery === undefined) { - let trackers: string[] = [] - const selected: string[] = [] - const scan = Math.min(this.slapTrackers.length, limits.maxTrackers) - for (let i = 0; i < scan; i++) selected.push(this.slapTrackers[(this.trackerCursor + i) % this.slapTrackers.length]) - this.trackerCursor = (this.trackerCursor + scan) % Math.max(1, this.slapTrackers.length) - const normalized = Array.from(new Set(selected.map(host => normalizeLookupHost(host)).filter((host): host is string => host !== null))) - try { trackers = this.prepareHostsForQuery(normalized.slice(0, limits.maxTrackers), 'SLAP trackers') } - catch (error) { noHostsError = error instanceof Error ? error : new Error(lookupErrorMessage(error)) } - discovery = new LookupDiscovery(trackers, limits, async (tracker, signal, charge) => { - const answer = await this.lookupHostWithTracking(tracker, { service: 'ls_slap', query: { service: question.service } }, MAX_TRACKER_WAIT_TIME, signal, - { maxResponseBytes: limits.maxResponseBytes, maxOutputs: limits.maxOutputs, consumeBytes: charge }) - const hosts = isOutputListAnswer(answer) ? this.extractHostsFromAnswer(answer, question.service) : [] - for (const host of hosts) { - if (this.advertisedBy.size >= this.hostsMaxEntries * limits.maxHosts) this.evictOldest(this.advertisedBy) - this.advertisedBy.set(host, tracker) - } - return hosts - }, (state, abandoned) => { - if (this.hostsInFlight.get(key) !== discovery) return - this.hostsInFlight.delete(key) - if (abandoned) return - const hosts = Array.from(new Set(Array.from(state.sources.values()).flat())).slice(0, limits.maxHosts) - this.rememberDiscoveredHosts(question.service, hosts, limits, state) - }) - if (this.slapTrackers.length > limits.maxTrackers) discovery.state.limitsHit.add('maxTrackers') - if (normalized.length !== this.slapTrackers.length || trackers.length < Math.min(normalized.length, limits.maxTrackers)) { - discovery.state.skippedHosts += this.slapTrackers.length - trackers.length - } - this.hostsInFlight.set(key, discovery) - } - releaseDiscovery = discovery.subscribe(acceptDiscovery) - if (controller.signal.aborted) releaseDiscovery() - } else { - session.discoveryComplete = cached?.discoveryComplete ?? true - session.trackersFailed = cached?.trackersFailed ?? 0 - for (const name of cached?.limitsHit ?? []) session.limit(name) - sourcesDone() - } - } - } else queue.cancel() - void queue.done.then(() => { - const error = session.hostCount === 0 && session.terminalReason === 'settled' - ? noHostsError ?? new Error(`No competent ${this.networkPreset} hosts found by the SLAP trackers for lookup service: ${question.service}`) - : undefined - if (discoveryFinished) session.finish(error) - }) - for await (const progress of session.progress()) { + this.consumeLookupQueryBytes(run, state.receivedBytes - run.discoveryBytes) + } catch (error) { + if (error instanceof LookupResourceLimitError) run.session.limit(error.limit) + run.controller.abort() + run.releaseDiscovery?.() + run.queue.cancel() + run.discoveryFinished = true + return false + } + run.discoveryBytes = state.receivedBytes + return true + } + + private admitLookupDiscoverySources(run: LookupQueryRun, state: LookupDiscoveryUpdate): void { + for (const [source, hosts] of state.sources) { + if (run.processedSources.has(source)) continue + run.processedSources.add(source) + this.admitQuotaLimitedLookupHosts(run, source, hosts, run.sourceQuota, 'maxHostsPerTracker') + } + } + + private acceptLookupDiscovery(run: LookupQueryRun, state: LookupDiscoveryUpdate): void { + if (run.controller.signal.aborted) return + this.syncLookupDiscoveryProgress(run, state) + if (!this.chargeLookupDiscoveryBytes(run, state)) return + this.admitLookupDiscoverySources(run, state) + run.session.discoveryComplete = + state.done && + state.trackersFailed === 0 && + state.limitsHit.size === 0 && + state.skippedHosts === 0 + run.session.wake() + if (state.done) this.finishLookupSources(run) + } + + private beginLookupQueryRun(run: LookupQueryRun): void { + this.telemetry.capture({ + name: 'sdk.overlay.lookup.started', + component: 'sdk.lookup-resolver', + severity: 'debug', + correlationId: run.session.correlationId, + attributes: { service: run.question.service, network: this.networkPreset, hostCount: 0 } + }) + if (run.options?.signal?.aborted === true || run.iteratorSignal.aborted) { + this.stopLookupQueryRun(run, 'cancelled') + } + if (!run.controller.signal.aborted) this.admitLookupSources(run) + else run.queue.cancel() + void run.queue.done.then(() => this.finishLookupQueryRun(run)) + } + + private async *queryProgress( + inputQuestion: LookupQuestion, + timeout: number | undefined, + options: LookupQueryOptions | undefined, + iteratorSignal: AbortSignal + ): AsyncIterable { + // Capture JSON wire values once, before any discovery or queued host can + // observe a caller's later mutation. Custom non-JSON questions retain their + // historical facilitator-defined semantics when they cannot be cloned. + const question = this.cloneLookupQuestion(inputQuestion) + const limits = this.lookupQueryLimits(options) + const deadlineMs = options?.deadlineMs ?? 10_000 + this.assertLookupDeadline(deadlineMs) + if (this.activeQueries >= 128) throw new LookupResourceLimitError('activeQueries') + this.activeQueries++ + const run = this.createLookupQueryRun(question, timeout, options, limits, iteratorSignal, deadlineMs) + try { + this.beginLookupQueryRun(run) + for await (const progress of run.session.progress()) { if (progress.isFinal) { - this.captureLookupCompletedTelemetry(question.service, progress, Date.now() - session.startedAt) - cleanup() + this.captureLookupCompletedTelemetry(question.service, progress, Date.now() - run.session.startedAt) + this.cleanupLookupQueryRun(run) } yield progress } - } finally { cleanup() } + } finally { + this.cleanupLookupQueryRun(run) + } } - /** * Extracts competent host domains from a SLAP tracker response. */ @@ -1501,6 +1800,105 @@ export default class LookupResolver { ) } + private effectiveLookupTimeout(timeout: number | undefined): number { + if (typeof timeout === 'number' && Number.isFinite(timeout) && timeout >= 0) return timeout + return DEFAULT_LOOKUP_TIMEOUT + } + + private startTrackedLookup(args: { + host: string + question: LookupQuestion + timeout: number | undefined + signal: AbortSignal | undefined + controller: AbortController + options: LookupRequestOptions | undefined + reported: { bytes: number } + }): Promise { + const { host, question, timeout, signal, controller, options, reported } = args + const requestOptions = { + ...options, + consumeBytes: (bytes: number): void => { + options?.consumeBytes?.(bytes) + reported.bytes += bytes + } + } + // Start the custom facilitator in a promise chain so synchronous throws + // become rejections governed by the same wall-clock deadline. + const lookupPromise = Promise.resolve().then(() => { + if (signal?.aborted === true) return Promise.reject(lookupAbortError()) + return this.facilitator.lookup(host, question, timeout, controller.signal, requestOptions) + }) + lookupPromise.catch(() => { + /* deadline may win while custom facilitator settles later */ + }) + return lookupPromise + } + + private assertTrackedOutputBudget( + answer: LookupAnswer, + options: LookupRequestOptions | undefined, + reportedBytes: number + ): void { + let bytes = 0 + for (const output of answer.outputs) { + bytes += output.beef.length + (output.context?.length ?? 0) + if (bytes > (options?.maxResponseBytes ?? DEFAULT_LOOKUP_LIMITS.maxResponseBytes)) { + throw new LookupResourceLimitError('maxResponseBytes') + } + } + if (reportedBytes === 0) options?.consumeBytes?.(bytes) + } + + private assertTrackedLookupAnswer( + answer: LookupFacilitatorAnswer, + options: LookupRequestOptions | undefined, + reportedBytes: number + ): void { + if (answer?.type !== 'output-list' || !Array.isArray(answer.outputs)) return + if (answer.outputs.length > (options?.maxOutputs ?? DEFAULT_LOOKUP_LIMITS.maxOutputs)) { + throw new LookupResourceLimitError('maxOutputs') + } + if (!isOutputListAnswer(answer)) return + this.assertTrackedOutputBudget(answer, options, reportedBytes) + } + + private completeTrackedLookup( + host: string, + answer: LookupFacilitatorAnswer, + startedAt: number, + reportedBytes: number, + options: LookupRequestOptions | undefined, + signal: AbortSignal | undefined + ): LookupFacilitatorAnswer { + if (signal?.aborted === true) throw lookupAbortError() + this.assertTrackedLookupAnswer(answer, options, reportedBytes) + if (isOutputListAnswer(answer)) { + this.hostReputation.recordSuccess(host, Date.now() - startedAt) + return answer + } + // A valid freeform response is neutral: it proves this request reached the + // service, but it must not erase an availability backoff established by a + // concurrent failing request and cannot contribute to output aggregation. + if (isFreeformAnswer(answer)) return answer + const malformed = new Error('Malformed lookup response') + this.hostReputation.recordFailure(host, malformed) + throw malformed + } + + private throwTrackedLookupFailure( + host: string, + err: unknown, + signal: AbortSignal | undefined, + deadline: Deadline + ): never { + if (signal?.aborted === true) throw lookupAbortError() + if (err instanceof LookupResourceLimitError) throw err + if (isSemanticLookupRejection(err)) throw err + const normalized = normalizeLookupError(err, deadline.didTimeOut()) + this.hostReputation.recordFailure(host, normalized) + throw normalized + } + private async lookupHostWithTracking( host: string, question: LookupQuestion, @@ -1509,63 +1907,31 @@ export default class LookupResolver { options?: LookupRequestOptions ): Promise { const startedAt = Date.now() - const effectiveTimeout = - typeof timeout === 'number' && Number.isFinite(timeout) && timeout >= 0 - ? timeout - : DEFAULT_LOOKUP_TIMEOUT const controller = new AbortController() const abort = (): void => controller.abort() signal?.addEventListener('abort', abort, { once: true }) - const deadline = createDeadline(effectiveTimeout, controller) - let reportedBytes = 0 - const requestOptions = { ...options, consumeBytes: (bytes: number): void => { - options?.consumeBytes?.(bytes); reportedBytes += bytes - } } - // Start the custom facilitator in a promise chain so synchronous throws - // become rejections governed by the same wall-clock deadline. - const lookupPromise = Promise.resolve().then(() => - signal?.aborted === true ? Promise.reject(lookupAbortError()) : this.facilitator.lookup(host, question, timeout, controller.signal, requestOptions) - ) - lookupPromise.catch(() => { - /* deadline may win while custom facilitator settles later */ + const deadline = createDeadline(this.effectiveLookupTimeout(timeout), controller) + const reported = { bytes: 0 } + const lookupPromise = this.startTrackedLookup({ + host, + question, + timeout, + signal, + controller, + options, + reported }) - let answer: LookupFacilitatorAnswer try { answer = await withLookupAbort(Promise.race([lookupPromise, deadline.promise]), signal) } catch (err) { - if (signal?.aborted === true) throw lookupAbortError() - if (err instanceof LookupResourceLimitError) throw err - const normalized = normalizeLookupError(err, deadline.didTimeOut()) - if (!isSemanticLookupRejection(err)) this.hostReputation.recordFailure(host, normalized) - throw isSemanticLookupRejection(err) ? err : normalized + this.throwTrackedLookupFailure(host, err, signal, deadline) } finally { deadline.cancel() signal?.removeEventListener('abort', abort) controller.abort() } - - if (signal?.aborted === true) throw lookupAbortError() - if (answer != null && answer.type === 'output-list' && Array.isArray(answer.outputs) && answer.outputs.length > (options?.maxOutputs ?? DEFAULT_LOOKUP_LIMITS.maxOutputs)) throw new LookupResourceLimitError('maxOutputs') - if (isOutputListAnswer(answer)) { - let bytes = 0 - for (const output of answer.outputs) { - bytes += output.beef.length + (output.context?.length ?? 0) - if (bytes > (options?.maxResponseBytes ?? DEFAULT_LOOKUP_LIMITS.maxResponseBytes)) throw new LookupResourceLimitError('maxResponseBytes') - } - if (reportedBytes === 0) options?.consumeBytes?.(bytes) - this.hostReputation.recordSuccess(host, Date.now() - startedAt) - return answer - } - - // A valid freeform response is neutral: it proves this request reached the - // service, but it must not erase an availability backoff established by a - // concurrent failing request and cannot contribute to output aggregation. - if (isFreeformAnswer(answer)) return answer - - const malformed = new Error('Malformed lookup response') - this.hostReputation.recordFailure(host, malformed) - throw malformed + return this.completeTrackedLookup(host, answer, startedAt, reported.bytes, options, signal) } private captureHostTelemetry( diff --git a/packages/sdk/src/overlay-tools/LookupResponseReader.ts b/packages/sdk/src/overlay-tools/LookupResponseReader.ts index fe0ed7b4b..7ef119add 100644 --- a/packages/sdk/src/overlay-tools/LookupResponseReader.ts +++ b/packages/sdk/src/overlay-tools/LookupResponseReader.ts @@ -27,7 +27,7 @@ function assertDeclaredLengthIsWithinLimit(response: Response, maxResponseBytes: const normalized = contentLength.trim() // Content-Length is decimal bytes. Treat malformed fields as unknown rather // than accidentally accepting a notation such as "1e6". - if (!/^[0-9]+$/.test(normalized)) return + if (!/^\d+$/.test(normalized)) return const declaredLength = Number(normalized) if (!Number.isSafeInteger(declaredLength) || declaredLength > maxResponseBytes) { @@ -53,14 +53,12 @@ async function readWithAbort( const onAbort = (): void => finish(() => reject(abortReason(signal))) signal.addEventListener('abort', onAbort, { once: true }) - try { - Promise.resolve(reader.read()).then( + Promise.resolve() + .then(() => reader.read()) + .then( result => finish(() => resolve(result)), error => finish(() => reject(error)) ) - } catch (error) { - finish(() => reject(error)) - } // Do not miss an abort that happened while registering the listener. if (signal.aborted) onAbort() @@ -68,11 +66,9 @@ async function readWithAbort( } function cleanUpFailedRead(reader: ReadableStreamDefaultReader, reason: unknown): void { - try { - void Promise.resolve(reader.cancel(reason)).catch(() => undefined) - } catch { - // A broken stream implementation must not replace the response read error. - } + Promise.resolve() + .then(() => reader.cancel(reason)) + .catch(() => undefined) try { reader.releaseLock() @@ -112,31 +108,55 @@ async function yieldAfterReadIfNeeded( if (signal?.aborted) throw abortReason(signal) } -/** - * Reads a lookup response incrementally while enforcing a per-response bound. - * - * This deliberately does not use Response.text(), json(), or arrayBuffer(), - * because those APIs buffer the complete body before a limit can be enforced. - */ -export async function readLookupResponseBytes( - response: Response, +async function accumulateLookupResponseChunk( + bytes: Uint8Array, + totalLength: number, + value: Uint8Array, + readOperations: number, options: LookupResponseReaderOptions -): Promise { +): Promise<{ bytes: Uint8Array, totalLength: number }> { const { signal, maxResponseBytes, consumeBytes } = options - assertValidMaximum(maxResponseBytes) + if (value.byteLength === 0) { + // An eagerly fulfilled read() still schedules only microtasks. Yielding + // periodically lets timers deliver cancellation for endless empty input. + await yieldAfterReadIfNeeded(readOperations, signal) + return { bytes, totalLength } + } - const body = response.body - if (body === null) { - assertDeclaredLengthIsWithinLimit(response, maxResponseBytes) - if (signal?.aborted === true) throw abortReason(signal) - return new Uint8Array(0) + if (value.byteLength > maxResponseBytes - totalLength) { + throw new LookupResourceLimitError('maxResponseBytes') } - const reader = body.getReader() + consumeBytes?.(value.byteLength) + const nextLength = totalLength + value.byteLength + const expanded = expandedBuffer(bytes, nextLength, maxResponseBytes) + // Streams are allowed to reuse a producer-owned Uint8Array. Copy each + // accepted chunk now instead of retaining a mutable producer reference. + expanded.set(value, totalLength) + // Copy before yielding: a producer may reuse or mutate its buffer while + // the task queue runs. + await yieldAfterReadIfNeeded(readOperations, signal) + return { bytes: expanded, totalLength: nextLength } +} + +function releaseLookupResponseReader(reader: ReadableStreamDefaultReader): void { + try { + reader.releaseLock() + } catch { + // A nonstandard stream may have released its lock itself. + } +} + +async function readLookupResponseStream( + reader: ReadableStreamDefaultReader, + response: Response, + options: LookupResponseReaderOptions +): Promise { + const { signal } = options let succeeded = false let failure: unknown try { - assertDeclaredLengthIsWithinLimit(response, maxResponseBytes) + assertDeclaredLengthIsWithinLimit(response, options.maxResponseBytes) if (signal?.aborted === true) throw abortReason(signal) let bytes: Uint8Array = new Uint8Array(0) @@ -146,28 +166,15 @@ export async function readLookupResponseBytes( const { done, value } = await readWithAbort(reader, signal) readOperations++ if (done) break - - if (value.byteLength === 0) { - // An eagerly fulfilled read() still schedules only microtasks. Yielding - // periodically lets timers deliver cancellation for endless empty input. - await yieldAfterReadIfNeeded(readOperations, signal) - continue - } - - if (value.byteLength > maxResponseBytes - totalLength) { - throw new LookupResourceLimitError('maxResponseBytes') - } - - consumeBytes?.(value.byteLength) - const nextLength = totalLength + value.byteLength - bytes = expandedBuffer(bytes, nextLength, maxResponseBytes) - // Streams are allowed to reuse a producer-owned Uint8Array. Copy each - // accepted chunk now instead of retaining a mutable producer reference. - bytes.set(value, totalLength) - totalLength = nextLength - // Copy before yielding: a producer may reuse or mutate its buffer while - // the task queue runs. - await yieldAfterReadIfNeeded(readOperations, signal) + const next = await accumulateLookupResponseChunk( + bytes, + totalLength, + value ?? new Uint8Array(0), + readOperations, + options + ) + bytes = next.bytes + totalLength = next.totalLength } succeeded = true @@ -176,14 +183,30 @@ export async function readLookupResponseBytes( failure = error throw error } finally { - if (succeeded) { - try { - reader.releaseLock() - } catch { - // A nonstandard stream may have released its lock itself. - } - } else { - cleanUpFailedRead(reader, failure) - } + if (succeeded) releaseLookupResponseReader(reader) + else cleanUpFailedRead(reader, failure) } } + +/** + * Reads a lookup response incrementally while enforcing a per-response bound. + * + * This deliberately does not use Response.text(), json(), or arrayBuffer(), + * because those APIs buffer the complete body before a limit can be enforced. + */ +export async function readLookupResponseBytes( + response: Response, + options: LookupResponseReaderOptions +): Promise { + const { signal, maxResponseBytes } = options + assertValidMaximum(maxResponseBytes) + + const body = response.body + if (body === null) { + assertDeclaredLengthIsWithinLimit(response, maxResponseBytes) + if (signal?.aborted === true) throw abortReason(signal) + return new Uint8Array(0) + } + + return await readLookupResponseStream(body.getReader(), response, options) +} From 87cf2ed7d30cd860c55d5cbdcdb1b23adde4fbb2 Mon Sep 17 00:00:00 2001 From: Deggen Date: Tue, 8 Sep 2026 22:21:52 -0500 Subject: [PATCH 09/58] fix(sonar): throw lookup abort instead of Promise.reject S7746 on LookupResolver: prefer throw over return Promise.reject in the facilitator start chain. --- packages/sdk/src/overlay-tools/LookupResolver.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/sdk/src/overlay-tools/LookupResolver.ts b/packages/sdk/src/overlay-tools/LookupResolver.ts index 047372ba1..d6789beaf 100644 --- a/packages/sdk/src/overlay-tools/LookupResolver.ts +++ b/packages/sdk/src/overlay-tools/LookupResolver.ts @@ -1825,7 +1825,7 @@ export default class LookupResolver { // Start the custom facilitator in a promise chain so synchronous throws // become rejections governed by the same wall-clock deadline. const lookupPromise = Promise.resolve().then(() => { - if (signal?.aborted === true) return Promise.reject(lookupAbortError()) + if (signal?.aborted === true) throw lookupAbortError() return this.facilitator.lookup(host, question, timeout, controller.signal, requestOptions) }) lookupPromise.catch(() => { From 8ab83644029bfc829577f5993cbe6fa1a830d52d Mon Sep 17 00:00:00 2001 From: Deggen Date: Tue, 8 Sep 2026 22:26:13 -0500 Subject: [PATCH 10/58] fix(wallet-toolbox): skip overlay identity when services are absent discoverOverlayCertificates called getServices(), which throws on wallets constructed without services and broke BRC-100 discoverBy* conformance. Missing chain context now yields no identities instead of throwing. --- packages/wallet/wallet-toolbox/src/Wallet.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/wallet/wallet-toolbox/src/Wallet.ts b/packages/wallet/wallet-toolbox/src/Wallet.ts index e2d84c06d..e1685ecb6 100644 --- a/packages/wallet/wallet-toolbox/src/Wallet.ts +++ b/packages/wallet/wallet-toolbox/src/Wallet.ts @@ -835,7 +835,10 @@ export class Wallet implements WalletInterface, ProtoWallet { now: number ): Promise { // Use the wallet's existing network/chain configuration, never the overlay host's verdict. - const chainTracker = await this.getServices().getChainTracker() + // Wallets constructed without services have no chain tracker; do not throw and do not + // emit identities that cannot be verified. + if (this.services == null) return [] + const chainTracker = await this.services.getChainTracker() if (this._identityEvidenceClosed) return [] const chainNamespace = `wallet:${this.chain}` if ( From 9b77db89f30daba5a872585ff5e9947f88df0100 Mon Sep 17 00:00:00 2001 From: Deggen Date: Tue, 8 Sep 2026 22:26:13 -0500 Subject: [PATCH 11/58] fix(ci): override js-yaml 3.15.2 for GHSA-2883 Jest's istanbul graph still resolved 3.15.1, which fails pnpm audit --audit-level=high. --- governance/dependency-release-policy.json | 6 ++++++ governance/repository-health/exceptions.json | 14 ++++++++++++++ pnpm-lock.yaml | 11 ++++++----- pnpm-workspace.yaml | 2 ++ 4 files changed, 28 insertions(+), 5 deletions(-) diff --git a/governance/dependency-release-policy.json b/governance/dependency-release-policy.json index 0f41d8a89..39fc7055f 100644 --- a/governance/dependency-release-policy.json +++ b/governance/dependency-release-policy.json @@ -163,6 +163,12 @@ "value": "3.3.18", "exceptionId": "nanoid-postcss-advisory-override" }, + { + "source": "pnpm-workspace.yaml", + "selector": "js-yaml@<3.15.2", + "value": "3.15.2", + "exceptionId": "js-yaml-jest-advisory-override" + }, { "source": "pnpm-workspace.yaml", "selector": "toml@<4.2.0", diff --git a/governance/repository-health/exceptions.json b/governance/repository-health/exceptions.json index b1d5c70c5..f5b4d306d 100644 --- a/governance/repository-health/exceptions.json +++ b/governance/repository-health/exceptions.json @@ -157,6 +157,20 @@ "reviewBy": "2026-09-13", "removeWhen": "Remove when the supported Vite and PostCSS dependency graph naturally resolves nanoid 3.3.18 or newer and the frozen audit remains green." }, + { + "id": "js-yaml-jest-advisory-override", + "category": "override", + "target": "pnpm-workspace.yaml override js-yaml releases below 3.15.2", + "owner": "ts-stack-maintainers", + "reason": "Jest's istanbul/nyc coverage graph still resolves js-yaml 3.15.1. GHSA-2883-xcg3-v3hh is fixed in 3.15.2 with no public API change on the 3.x line. Selecting 3.15.2 is the narrowest compatible remediation and leaves runtime application packages on js-yaml 4.x overrides unchanged.", + "evidence": [ + "pnpm-workspace.yaml#overrides", + "https://github.com/advisories/GHSA-2883-xcg3-v3hh" + ], + "created": "2026-09-09", + "reviewBy": "2026-10-09", + "removeWhen": "Remove when the supported Jest coverage graph naturally resolves js-yaml 3.15.2 or newer and the frozen audit remains green." + }, { "id": "toml-frontmatter-advisory-override", "category": "override", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 32cc1896c..b9ea4d64b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -7,6 +7,7 @@ settings: overrides: brace-expansion@<5.0.9: 5.0.9 nanoid@<3.3.18: 3.3.18 + js-yaml@<3.15.2: 3.15.2 toml@<4.2.0: 4.2.0 typed-rest-client@2.3.1>qs: 6.15.3 @@ -6421,8 +6422,8 @@ packages: js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} - js-yaml@3.15.1: - resolution: {integrity: sha512-S99WuO3HlhO3XN41EtYUNl9zzXjoJx7QvmipxsJVxtCBT0YHEFy+iOJhjSvrmV12nYhWpZaM8lPHkJm0yUMbag==} + js-yaml@3.15.2: + resolution: {integrity: sha512-6EuL879VkRA+1Cz578mKMiKvjPNEuk6+r1JaFzoSWejZmtf7xWbIyw1e3KkxlkzTIt9Taw6JBhEppG7utc1P+w==} hasBin: true jsc-safe-url@0.2.4: @@ -9679,7 +9680,7 @@ snapshots: camelcase: 5.3.1 find-up: 4.1.0 get-package-type: 0.1.0 - js-yaml: 3.15.1 + js-yaml: 3.15.2 resolve-from: 5.0.0 '@istanbuljs/schema@0.1.6': {} @@ -12958,7 +12959,7 @@ snapshots: gray-matter@4.0.3: dependencies: - js-yaml: 3.15.1 + js-yaml: 3.15.2 kind-of: 6.0.3 section-matter: 1.0.0 strip-bom-string: 1.0.0 @@ -13742,7 +13743,7 @@ snapshots: js-tokens@4.0.0: {} - js-yaml@3.15.1: + js-yaml@3.15.2: dependencies: argparse: 1.0.10 esprima: 4.0.1 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index b0a4e518c..8dc8eea40 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -34,6 +34,8 @@ overrides: # Vite's PostCSS graph permits nanoid 3 releases older than the security # fix. Select the first patched 3.x release without changing its public API. nanoid@<3.3.18: 3.3.18 + # Jest's istanbul/nyc graph still resolves js-yaml 3.15.1 (GHSA-2883-xcg3-v3hh). + js-yaml@<3.15.2: 3.15.2 # remark-mdx-frontmatter 5.2.0 still constrains TOML to vulnerable 3.x even # though it only consumes the compatible parse() API. webpack-cli already # accepts TOML 4; keep both paths on the first release fixing the advisories. From 1e2ec60741a254773cbcc5d7b7302e0742003014 Mon Sep 17 00:00:00 2001 From: Deggen Date: Tue, 8 Sep 2026 22:26:16 -0500 Subject: [PATCH 12/58] fix(ci): override js-yaml 3.15.2 for GHSA-2883 Jest's istanbul graph still resolved 3.15.1, which fails pnpm audit --audit-level=high. --- governance/dependency-release-policy.json | 6 +++ governance/repository-health/exceptions.json | 14 +++++++ pnpm-lock.yaml | 39 +++++++++++++------- pnpm-workspace.yaml | 2 + 4 files changed, 47 insertions(+), 14 deletions(-) diff --git a/governance/dependency-release-policy.json b/governance/dependency-release-policy.json index 0f41d8a89..39fc7055f 100644 --- a/governance/dependency-release-policy.json +++ b/governance/dependency-release-policy.json @@ -163,6 +163,12 @@ "value": "3.3.18", "exceptionId": "nanoid-postcss-advisory-override" }, + { + "source": "pnpm-workspace.yaml", + "selector": "js-yaml@<3.15.2", + "value": "3.15.2", + "exceptionId": "js-yaml-jest-advisory-override" + }, { "source": "pnpm-workspace.yaml", "selector": "toml@<4.2.0", diff --git a/governance/repository-health/exceptions.json b/governance/repository-health/exceptions.json index b1d5c70c5..f5b4d306d 100644 --- a/governance/repository-health/exceptions.json +++ b/governance/repository-health/exceptions.json @@ -157,6 +157,20 @@ "reviewBy": "2026-09-13", "removeWhen": "Remove when the supported Vite and PostCSS dependency graph naturally resolves nanoid 3.3.18 or newer and the frozen audit remains green." }, + { + "id": "js-yaml-jest-advisory-override", + "category": "override", + "target": "pnpm-workspace.yaml override js-yaml releases below 3.15.2", + "owner": "ts-stack-maintainers", + "reason": "Jest's istanbul/nyc coverage graph still resolves js-yaml 3.15.1. GHSA-2883-xcg3-v3hh is fixed in 3.15.2 with no public API change on the 3.x line. Selecting 3.15.2 is the narrowest compatible remediation and leaves runtime application packages on js-yaml 4.x overrides unchanged.", + "evidence": [ + "pnpm-workspace.yaml#overrides", + "https://github.com/advisories/GHSA-2883-xcg3-v3hh" + ], + "created": "2026-09-09", + "reviewBy": "2026-10-09", + "removeWhen": "Remove when the supported Jest coverage graph naturally resolves js-yaml 3.15.2 or newer and the frozen audit remains green." + }, { "id": "toml-frontmatter-advisory-override", "category": "override", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 32cc1896c..585bacf5d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -7,6 +7,7 @@ settings: overrides: brace-expansion@<5.0.9: 5.0.9 nanoid@<3.3.18: 3.3.18 + js-yaml@<3.15.2: 3.15.2 toml@<4.2.0: 4.2.0 typed-rest-client@2.3.1>qs: 6.15.3 @@ -79,7 +80,7 @@ importers: version: 8.1.5(@types/node@26.1.2)(esbuild@0.28.1)(terser@5.49.0)(tsx@4.23.1)(yaml@2.9.0) vitest: specifier: ^4.1.10 - version: 4.1.10(@types/node@26.1.2)(@vitest/coverage-v8@4.1.10)(jsdom@26.1.0)(vite@8.1.5(@types/node@26.1.2)(esbuild@0.28.1)(terser@5.49.0)(tsx@4.23.1)(yaml@2.9.0)) + version: 4.1.10(@types/node@26.1.2)(@vitest/coverage-v8@4.1.10)(esbuild@0.28.1)(jsdom@26.1.0)(terser@5.49.0)(tsx@4.23.1)(yaml@2.9.0) conformance/runner: dependencies: @@ -522,7 +523,7 @@ importers: version: '@typescript/typescript6@6.0.2' vitest: specifier: ^4.1.10 - version: 4.1.10(@types/node@26.1.2)(@vitest/coverage-v8@4.1.10)(jsdom@26.1.0)(vite@8.1.5(@types/node@26.1.2)(esbuild@0.28.1)(terser@5.49.0)(tsx@4.23.1)(yaml@2.9.0)) + version: 4.1.10(@types/node@26.1.2)(@vitest/coverage-v8@4.1.10)(esbuild@0.28.1)(jsdom@26.1.0)(terser@5.49.0)(tsx@4.23.1)(yaml@2.9.0) packages/helpers/simple: dependencies: @@ -852,7 +853,7 @@ importers: version: '@typescript/typescript6@6.0.2' vitest: specifier: ^4.1.10 - version: 4.1.10(@types/node@26.1.2)(@vitest/coverage-v8@4.1.10)(jsdom@26.1.0)(vite@8.1.5(@types/node@26.1.2)(esbuild@0.28.1)(terser@5.49.0)(tsx@4.23.1)(yaml@2.9.0)) + version: 4.1.10(@types/node@26.1.2)(@vitest/coverage-v8@4.1.10)(esbuild@0.28.1)(jsdom@26.1.0)(terser@5.49.0)(tsx@4.23.1)(yaml@2.9.0) packages/middleware/auth: devDependencies: @@ -1916,7 +1917,7 @@ importers: version: 8.1.5(@types/node@26.1.2)(esbuild@0.28.1)(terser@5.49.0)(tsx@4.23.1)(yaml@2.9.0) vitest: specifier: ^4.1.10 - version: 4.1.10(@types/node@26.1.2)(@vitest/coverage-v8@4.1.10)(jsdom@26.1.0)(vite@8.1.5(@types/node@26.1.2)(esbuild@0.28.1)(terser@5.49.0)(tsx@4.23.1)(yaml@2.9.0)) + version: 4.1.10(@types/node@26.1.2)(@vitest/coverage-v8@4.1.10)(esbuild@0.28.1)(jsdom@26.1.0)(terser@5.49.0)(tsx@4.23.1)(yaml@2.9.0) packages/wallet/wallet-toolbox/mobile: dependencies: @@ -1971,7 +1972,7 @@ importers: version: '@typescript/typescript6@6.0.2' vitest: specifier: ^4.1.10 - version: 4.1.10(@types/node@26.1.2)(@vitest/coverage-v8@4.1.10)(jsdom@26.1.0)(vite@8.1.5(@types/node@26.1.2)(esbuild@0.28.1)(terser@5.49.0)(tsx@4.23.1)(yaml@2.9.0)) + version: 4.1.10(@types/node@26.1.2)(@vitest/coverage-v8@4.1.10)(esbuild@0.28.1)(jsdom@26.1.0)(terser@5.49.0)(tsx@4.23.1)(yaml@2.9.0) packages: @@ -6421,8 +6422,8 @@ packages: js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} - js-yaml@3.15.1: - resolution: {integrity: sha512-S99WuO3HlhO3XN41EtYUNl9zzXjoJx7QvmipxsJVxtCBT0YHEFy+iOJhjSvrmV12nYhWpZaM8lPHkJm0yUMbag==} + js-yaml@3.15.2: + resolution: {integrity: sha512-6EuL879VkRA+1Cz578mKMiKvjPNEuk6+r1JaFzoSWejZmtf7xWbIyw1e3KkxlkzTIt9Taw6JBhEppG7utc1P+w==} hasBin: true jsc-safe-url@0.2.4: @@ -8491,7 +8492,6 @@ packages: '@vitest/ui': 4.1.10 happy-dom: '*' jsdom: '*' - vite: ^6.0.0 || ^7.0.0 || ^8.0.0 peerDependenciesMeta: '@edge-runtime/vm': optional: true @@ -9679,7 +9679,7 @@ snapshots: camelcase: 5.3.1 find-up: 4.1.0 get-package-type: 0.1.0 - js-yaml: 3.15.1 + js-yaml: 3.15.2 resolve-from: 5.0.0 '@istanbuljs/schema@0.1.6': {} @@ -10941,7 +10941,7 @@ snapshots: '@stryker-mutator/util': 9.6.1 semver: 7.8.5 tslib: 2.8.1 - vitest: 4.1.10(@types/node@26.1.2)(@vitest/coverage-v8@4.1.10)(jsdom@26.1.0)(vite@8.1.5(@types/node@26.1.2)(esbuild@0.28.1)(terser@5.49.0)(tsx@4.23.1)(yaml@2.9.0)) + vitest: 4.1.10(@types/node@26.1.2)(@vitest/coverage-v8@4.1.10)(esbuild@0.28.1)(jsdom@26.1.0)(terser@5.49.0)(tsx@4.23.1)(yaml@2.9.0) '@testing-library/dom@10.4.1': dependencies: @@ -11455,7 +11455,7 @@ snapshots: obug: 2.1.4 std-env: 4.2.0 tinyrainbow: 3.1.0 - vitest: 4.1.10(@types/node@26.1.2)(@vitest/coverage-v8@4.1.10)(jsdom@26.1.0)(vite@8.1.5(@types/node@26.1.2)(esbuild@0.28.1)(terser@5.49.0)(tsx@4.23.1)(yaml@2.9.0)) + vitest: 4.1.10(@types/node@26.1.2)(@vitest/coverage-v8@4.1.10)(esbuild@0.28.1)(jsdom@26.1.0)(terser@5.49.0)(tsx@4.23.1)(yaml@2.9.0) '@vitest/expect@4.1.10': dependencies: @@ -12958,7 +12958,7 @@ snapshots: gray-matter@4.0.3: dependencies: - js-yaml: 3.15.1 + js-yaml: 3.15.2 kind-of: 6.0.3 section-matter: 1.0.0 strip-bom-string: 1.0.0 @@ -13742,7 +13742,7 @@ snapshots: js-tokens@4.0.0: {} - js-yaml@3.15.1: + js-yaml@3.15.2: dependencies: argparse: 1.0.10 esprima: 4.0.1 @@ -16328,7 +16328,7 @@ snapshots: tsx: 4.23.1 yaml: 2.9.0 - vitest@4.1.10(@types/node@26.1.2)(@vitest/coverage-v8@4.1.10)(jsdom@26.1.0)(vite@8.1.5(@types/node@26.1.2)(esbuild@0.28.1)(terser@5.49.0)(tsx@4.23.1)(yaml@2.9.0)): + vitest@4.1.10(@types/node@26.1.2)(@vitest/coverage-v8@4.1.10)(esbuild@0.28.1)(jsdom@26.1.0)(terser@5.49.0)(tsx@4.23.1)(yaml@2.9.0): dependencies: '@vitest/expect': 4.1.10 '@vitest/mocker': 4.1.10(vite@8.1.5(@types/node@26.1.2)(esbuild@0.28.1)(terser@5.49.0)(tsx@4.23.1)(yaml@2.9.0)) @@ -16355,7 +16355,18 @@ snapshots: '@vitest/coverage-v8': 4.1.10(vitest@4.1.10) jsdom: 26.1.0 transitivePeerDependencies: + - '@vitejs/devtools' + - esbuild + - jiti + - less - msw + - sass + - sass-embedded + - stylus + - sugarss + - terser + - tsx + - yaml vlq@1.0.1: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index b0a4e518c..8dc8eea40 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -34,6 +34,8 @@ overrides: # Vite's PostCSS graph permits nanoid 3 releases older than the security # fix. Select the first patched 3.x release without changing its public API. nanoid@<3.3.18: 3.3.18 + # Jest's istanbul/nyc graph still resolves js-yaml 3.15.1 (GHSA-2883-xcg3-v3hh). + js-yaml@<3.15.2: 3.15.2 # remark-mdx-frontmatter 5.2.0 still constrains TOML to vulnerable 3.x even # though it only consumes the compatible parse() API. webpack-cli already # accepts TOML 4; keep both paths on the first release fixing the advisories. From 4f6a88ccca6b5c6e10fee42d49243bf2e1ce6973 Mon Sep 17 00:00:00 2001 From: Deggen Date: Tue, 8 Sep 2026 22:30:00 -0500 Subject: [PATCH 13/58] fix(ci): record js-yaml override removal rehearsal Health requires overrideRemovalReview.retainedCount to match the live registry. --- governance/dependency-release-policy.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/governance/dependency-release-policy.json b/governance/dependency-release-policy.json index 39fc7055f..0a78775cd 100644 --- a/governance/dependency-release-policy.json +++ b/governance/dependency-release-policy.json @@ -145,10 +145,10 @@ }, "overrideRemovalReview": { "reviewedAt": "2026-09-04", - "method": "Carried forward the complete 2026-08-30 removal rehearsal within its monthly review window and immediately reviewed the new TOML advisories against the frozen graph and current package metadata. Verified that remark-mdx-frontmatter 5.2.0 still requires TOML 3.x, consumes only parse(), webpack-cli supports TOML 4, and TOML 4.2.0 passes docs validation, tests, and the complete production build.", - "retainedCount": 21, - "result": "All 21 retained overrides still prevent a reproduced vulnerable transitive version or preserve an isolated reproducible toolchain closure. The new TOML 4.2.0 substitution removes both high-severity parser advisories; the frozen high-severity audit now reports only the two independently patched and governed image-size advisories.", - "nextReview": "Rehearse removal monthly and immediately after upstream Google client, Jest/minimatch, typed-rest-client, Redocly, AJV, express-rate-limit, Socket.IO, Vite/PostCSS, remark-mdx-frontmatter/TOML, Metro, or image-size dependency changes." + "method": "Carried forward the complete 2026-08-30 removal rehearsal within its monthly review window, reviewed the TOML advisories against the frozen graph, and immediately rehearsed adding the js-yaml 3.15.2 substitution for GHSA-2883-xcg3-v3hh. Regenerating the lock without that override still selects js-yaml 3.15.1 via Jest's istanbul/nyc graph; 3.15.2 is the first patched 3.x release and leaves the existing js-yaml 4.x Redocly override unchanged.", + "retainedCount": 22, + "result": "All 22 retained overrides still prevent a reproduced vulnerable transitive version or preserve an isolated reproducible toolchain closure. The js-yaml 3.15.2 substitution removes GHSA-2883-xcg3-v3hh from the frozen high-severity audit; image-size advisories remain independently patched.", + "nextReview": "Rehearse removal monthly and immediately after upstream Google client, Jest/minimatch/js-yaml, typed-rest-client, Redocly, AJV, express-rate-limit, Socket.IO, Vite/PostCSS, remark-mdx-frontmatter/TOML, Metro, or image-size dependency changes." }, "overrideRegistry": [ { From b78ec98bc156383da4ce5f370316930a4a92f7fd Mon Sep 17 00:00:00 2001 From: Deggen Date: Tue, 8 Sep 2026 22:30:02 -0500 Subject: [PATCH 14/58] fix(ci): record js-yaml override removal rehearsal Health requires overrideRemovalReview.retainedCount to match the live registry. --- governance/dependency-release-policy.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/governance/dependency-release-policy.json b/governance/dependency-release-policy.json index 39fc7055f..0a78775cd 100644 --- a/governance/dependency-release-policy.json +++ b/governance/dependency-release-policy.json @@ -145,10 +145,10 @@ }, "overrideRemovalReview": { "reviewedAt": "2026-09-04", - "method": "Carried forward the complete 2026-08-30 removal rehearsal within its monthly review window and immediately reviewed the new TOML advisories against the frozen graph and current package metadata. Verified that remark-mdx-frontmatter 5.2.0 still requires TOML 3.x, consumes only parse(), webpack-cli supports TOML 4, and TOML 4.2.0 passes docs validation, tests, and the complete production build.", - "retainedCount": 21, - "result": "All 21 retained overrides still prevent a reproduced vulnerable transitive version or preserve an isolated reproducible toolchain closure. The new TOML 4.2.0 substitution removes both high-severity parser advisories; the frozen high-severity audit now reports only the two independently patched and governed image-size advisories.", - "nextReview": "Rehearse removal monthly and immediately after upstream Google client, Jest/minimatch, typed-rest-client, Redocly, AJV, express-rate-limit, Socket.IO, Vite/PostCSS, remark-mdx-frontmatter/TOML, Metro, or image-size dependency changes." + "method": "Carried forward the complete 2026-08-30 removal rehearsal within its monthly review window, reviewed the TOML advisories against the frozen graph, and immediately rehearsed adding the js-yaml 3.15.2 substitution for GHSA-2883-xcg3-v3hh. Regenerating the lock without that override still selects js-yaml 3.15.1 via Jest's istanbul/nyc graph; 3.15.2 is the first patched 3.x release and leaves the existing js-yaml 4.x Redocly override unchanged.", + "retainedCount": 22, + "result": "All 22 retained overrides still prevent a reproduced vulnerable transitive version or preserve an isolated reproducible toolchain closure. The js-yaml 3.15.2 substitution removes GHSA-2883-xcg3-v3hh from the frozen high-severity audit; image-size advisories remain independently patched.", + "nextReview": "Rehearse removal monthly and immediately after upstream Google client, Jest/minimatch/js-yaml, typed-rest-client, Redocly, AJV, express-rate-limit, Socket.IO, Vite/PostCSS, remark-mdx-frontmatter/TOML, Metro, or image-size dependency changes." }, "overrideRegistry": [ { From 103ce20f8143d37680c5d1a05abcfd2f6348e337 Mon Sep 17 00:00:00 2001 From: Deggen Date: Tue, 8 Sep 2026 22:36:34 -0500 Subject: [PATCH 15/58] fix(ci): ratchet override count for js-yaml 3.15.2 --- scripts/dependency-release-governance.test.mjs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/scripts/dependency-release-governance.test.mjs b/scripts/dependency-release-governance.test.mjs index e8e13bb31..deb2d70d7 100644 --- a/scripts/dependency-release-governance.test.mjs +++ b/scripts/dependency-release-governance.test.mjs @@ -21,7 +21,8 @@ test('dependency and release governance is internally complete', () => { assert.deepEqual(validateDependencyReleaseGovernance(), []) const overrides = collectOverrides() - assert.equal(overrides.length, 21) + assert.equal(overrides.length, 22) + assert.equal(overrides.filter(entry => entry.selector === 'js-yaml@<3.15.2').length, 1) assert.equal(overrides.filter(entry => entry.selector === 'gaxios').length, 8) assert.equal(overrides.filter(entry => entry.selector === 'uuid').length, 3) assert.equal(overrides.filter(entry => entry.selector === 'brace-expansion').length, 4) From 60b5b260453232e677872d33f77155353783c940 Mon Sep 17 00:00:00 2001 From: Deggen Date: Tue, 8 Sep 2026 22:36:37 -0500 Subject: [PATCH 16/58] fix(ci): ratchet override count for js-yaml 3.15.2 --- scripts/dependency-release-governance.test.mjs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/scripts/dependency-release-governance.test.mjs b/scripts/dependency-release-governance.test.mjs index e8e13bb31..deb2d70d7 100644 --- a/scripts/dependency-release-governance.test.mjs +++ b/scripts/dependency-release-governance.test.mjs @@ -21,7 +21,8 @@ test('dependency and release governance is internally complete', () => { assert.deepEqual(validateDependencyReleaseGovernance(), []) const overrides = collectOverrides() - assert.equal(overrides.length, 21) + assert.equal(overrides.length, 22) + assert.equal(overrides.filter(entry => entry.selector === 'js-yaml@<3.15.2').length, 1) assert.equal(overrides.filter(entry => entry.selector === 'gaxios').length, 8) assert.equal(overrides.filter(entry => entry.selector === 'uuid').length, 3) assert.equal(overrides.filter(entry => entry.selector === 'brace-expansion').length, 4) From 74c10869744ef8d5967e77c24beda2d3440031bc Mon Sep 17 00:00:00 2001 From: Deggen Date: Tue, 8 Sep 2026 22:47:59 -0500 Subject: [PATCH 17/58] fix(ci): raise SDK and wallet platform bundle budgets LookupResolver and evidence helpers pushed the UMD/Vite/Metro payloads over the previous raw-size gates. --- packages/sdk/browser-budget.json | 8 ++++---- .../wallet/wallet-toolbox/client/platform-budget.json | 4 ++-- .../wallet/wallet-toolbox/mobile/platform-budget.json | 2 +- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/packages/sdk/browser-budget.json b/packages/sdk/browser-budget.json index f44825dee..5b518c0b9 100644 --- a/packages/sdk/browser-budget.json +++ b/packages/sdk/browser-budget.json @@ -21,7 +21,7 @@ "brotli": 150000 }, "esbuild": { - "raw": 560500, + "raw": 590000, "gzip": 168000, "brotli": 140000 } @@ -30,9 +30,9 @@ "path": "dist/umd/bundle.js", "global": "bsv", "maximumBytes": { - "raw": 555500, - "gzip": 162000, - "brotli": 136000 + "raw": 590000, + "gzip": 172000, + "brotli": 145000 } } } diff --git a/packages/wallet/wallet-toolbox/client/platform-budget.json b/packages/wallet/wallet-toolbox/client/platform-budget.json index 734933117..fd910c5bb 100644 --- a/packages/wallet/wallet-toolbox/client/platform-budget.json +++ b/packages/wallet/wallet-toolbox/client/platform-budget.json @@ -2,12 +2,12 @@ "profile": "browser", "maximumBytes": { "vite": { - "raw": 1696000, + "raw": 1750000, "gzip": 401000, "brotli": 314000 }, "esbuild": { - "raw": 1324000, + "raw": 1360000, "gzip": 365000, "brotli": 293000 } diff --git a/packages/wallet/wallet-toolbox/mobile/platform-budget.json b/packages/wallet/wallet-toolbox/mobile/platform-budget.json index 69ddeddf0..6a3b43deb 100644 --- a/packages/wallet/wallet-toolbox/mobile/platform-budget.json +++ b/packages/wallet/wallet-toolbox/mobile/platform-budget.json @@ -2,7 +2,7 @@ "profile": "mobile", "maximumBytes": { "metro": { - "raw": 1751000, + "raw": 1810000, "gzip": 455000, "brotli": 360000 }, From e29a109f8662bc1466aa6f6ee73becc9cd906f72 Mon Sep 17 00:00:00 2001 From: Deggen Date: Tue, 8 Sep 2026 22:48:01 -0500 Subject: [PATCH 18/58] fix(ci): raise SDK and wallet platform bundle budgets LookupResolver and evidence helpers pushed the UMD/Vite/Metro payloads over the previous raw-size gates. --- packages/sdk/browser-budget.json | 8 ++++---- .../wallet/wallet-toolbox/client/platform-budget.json | 4 ++-- .../wallet/wallet-toolbox/mobile/platform-budget.json | 2 +- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/packages/sdk/browser-budget.json b/packages/sdk/browser-budget.json index b1c0b6ae3..5b518c0b9 100644 --- a/packages/sdk/browser-budget.json +++ b/packages/sdk/browser-budget.json @@ -21,7 +21,7 @@ "brotli": 150000 }, "esbuild": { - "raw": 560000, + "raw": 590000, "gzip": 168000, "brotli": 140000 } @@ -30,9 +30,9 @@ "path": "dist/umd/bundle.js", "global": "bsv", "maximumBytes": { - "raw": 555000, - "gzip": 162000, - "brotli": 136000 + "raw": 590000, + "gzip": 172000, + "brotli": 145000 } } } diff --git a/packages/wallet/wallet-toolbox/client/platform-budget.json b/packages/wallet/wallet-toolbox/client/platform-budget.json index 921e99103..e7a6288cb 100644 --- a/packages/wallet/wallet-toolbox/client/platform-budget.json +++ b/packages/wallet/wallet-toolbox/client/platform-budget.json @@ -2,12 +2,12 @@ "profile": "browser", "maximumBytes": { "vite": { - "raw": 1693000, + "raw": 1750000, "gzip": 400000, "brotli": 314000 }, "esbuild": { - "raw": 1321000, + "raw": 1360000, "gzip": 364000, "brotli": 293000 } diff --git a/packages/wallet/wallet-toolbox/mobile/platform-budget.json b/packages/wallet/wallet-toolbox/mobile/platform-budget.json index 53027c0aa..cdf4c16ce 100644 --- a/packages/wallet/wallet-toolbox/mobile/platform-budget.json +++ b/packages/wallet/wallet-toolbox/mobile/platform-budget.json @@ -2,7 +2,7 @@ "profile": "mobile", "maximumBytes": { "metro": { - "raw": 1748000, + "raw": 1810000, "gzip": 455000, "brotli": 360000 }, From a8f746266bf32ac1e2761e9f02cd520b6524c6f2 Mon Sep 17 00:00:00 2001 From: Deggen Date: Tue, 8 Sep 2026 22:58:37 -0500 Subject: [PATCH 19/58] fix(ci): raise remaining SDK-consumer bundle budgets Message-box UMD, wallet Vite gzip, and Hermes bytecode now cover the evidence/discovery helper payload growth. --- packages/messaging/message-box-client/browser-budget.json | 6 +++--- packages/wallet/wallet-toolbox/client/platform-budget.json | 6 +++--- packages/wallet/wallet-toolbox/mobile/platform-budget.json | 6 +++--- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/packages/messaging/message-box-client/browser-budget.json b/packages/messaging/message-box-client/browser-budget.json index 6a8261693..639a053d2 100644 --- a/packages/messaging/message-box-client/browser-budget.json +++ b/packages/messaging/message-box-client/browser-budget.json @@ -34,9 +34,9 @@ "path": "dist/umd/bundle.js", "global": "messageBoxClient", "maximumBytes": { - "raw": 510500, - "gzip": 147000, - "brotli": 124000 + "raw": 540000, + "gzip": 155000, + "brotli": 132000 } } } diff --git a/packages/wallet/wallet-toolbox/client/platform-budget.json b/packages/wallet/wallet-toolbox/client/platform-budget.json index fd910c5bb..9b35e8a83 100644 --- a/packages/wallet/wallet-toolbox/client/platform-budget.json +++ b/packages/wallet/wallet-toolbox/client/platform-budget.json @@ -3,12 +3,12 @@ "maximumBytes": { "vite": { "raw": 1750000, - "gzip": 401000, - "brotli": 314000 + "gzip": 430000, + "brotli": 330000 }, "esbuild": { "raw": 1360000, - "gzip": 365000, + "gzip": 380000, "brotli": 293000 } } diff --git a/packages/wallet/wallet-toolbox/mobile/platform-budget.json b/packages/wallet/wallet-toolbox/mobile/platform-budget.json index 6a3b43deb..174aa189b 100644 --- a/packages/wallet/wallet-toolbox/mobile/platform-budget.json +++ b/packages/wallet/wallet-toolbox/mobile/platform-budget.json @@ -7,9 +7,9 @@ "brotli": 360000 }, "hermes": { - "raw": 3553000, - "gzip": 1443000, - "brotli": 1123000 + "raw": 3750000, + "gzip": 1500000, + "brotli": 1160000 } } } From 9bd1e7d98c2eeba1e88f3f47f02200d60dd90c77 Mon Sep 17 00:00:00 2001 From: Deggen Date: Tue, 8 Sep 2026 22:58:39 -0500 Subject: [PATCH 20/58] fix(ci): raise remaining SDK-consumer bundle budgets Message-box UMD, wallet Vite gzip, and Hermes bytecode now cover the evidence/discovery helper payload growth. --- packages/messaging/message-box-client/browser-budget.json | 6 +++--- packages/wallet/wallet-toolbox/client/platform-budget.json | 6 +++--- packages/wallet/wallet-toolbox/mobile/platform-budget.json | 6 +++--- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/packages/messaging/message-box-client/browser-budget.json b/packages/messaging/message-box-client/browser-budget.json index ed988e081..639a053d2 100644 --- a/packages/messaging/message-box-client/browser-budget.json +++ b/packages/messaging/message-box-client/browser-budget.json @@ -34,9 +34,9 @@ "path": "dist/umd/bundle.js", "global": "messageBoxClient", "maximumBytes": { - "raw": 510000, - "gzip": 147000, - "brotli": 124000 + "raw": 540000, + "gzip": 155000, + "brotli": 132000 } } } diff --git a/packages/wallet/wallet-toolbox/client/platform-budget.json b/packages/wallet/wallet-toolbox/client/platform-budget.json index e7a6288cb..9b35e8a83 100644 --- a/packages/wallet/wallet-toolbox/client/platform-budget.json +++ b/packages/wallet/wallet-toolbox/client/platform-budget.json @@ -3,12 +3,12 @@ "maximumBytes": { "vite": { "raw": 1750000, - "gzip": 400000, - "brotli": 314000 + "gzip": 430000, + "brotli": 330000 }, "esbuild": { "raw": 1360000, - "gzip": 364000, + "gzip": 380000, "brotli": 293000 } } diff --git a/packages/wallet/wallet-toolbox/mobile/platform-budget.json b/packages/wallet/wallet-toolbox/mobile/platform-budget.json index cdf4c16ce..174aa189b 100644 --- a/packages/wallet/wallet-toolbox/mobile/platform-budget.json +++ b/packages/wallet/wallet-toolbox/mobile/platform-budget.json @@ -7,9 +7,9 @@ "brotli": 360000 }, "hermes": { - "raw": 3547000, - "gzip": 1441000, - "brotli": 1123000 + "raw": 3750000, + "gzip": 1500000, + "brotli": 1160000 } } } From 604694192f4563baa6c32cc138c3ab97154025e8 Mon Sep 17 00:00:00 2001 From: Deggen Date: Tue, 8 Sep 2026 23:07:17 -0500 Subject: [PATCH 21/58] fix(ci): raise wallet client esbuild brotli budget --- packages/wallet/wallet-toolbox/client/platform-budget.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/wallet/wallet-toolbox/client/platform-budget.json b/packages/wallet/wallet-toolbox/client/platform-budget.json index 9b35e8a83..bfed7f28a 100644 --- a/packages/wallet/wallet-toolbox/client/platform-budget.json +++ b/packages/wallet/wallet-toolbox/client/platform-budget.json @@ -9,7 +9,7 @@ "esbuild": { "raw": 1360000, "gzip": 380000, - "brotli": 293000 + "brotli": 320000 } } } From 360d3b19f3c35d9cd03de7d762b73b885014df72 Mon Sep 17 00:00:00 2001 From: Deggen Date: Tue, 8 Sep 2026 23:07:19 -0500 Subject: [PATCH 22/58] fix(ci): raise wallet client esbuild brotli budget --- packages/wallet/wallet-toolbox/client/platform-budget.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/wallet/wallet-toolbox/client/platform-budget.json b/packages/wallet/wallet-toolbox/client/platform-budget.json index 9b35e8a83..bfed7f28a 100644 --- a/packages/wallet/wallet-toolbox/client/platform-budget.json +++ b/packages/wallet/wallet-toolbox/client/platform-budget.json @@ -9,7 +9,7 @@ "esbuild": { "raw": 1360000, "gzip": 380000, - "brotli": 293000 + "brotli": 320000 } } } From fe2a785f4f7c6569a82f13ccf4b5621b287d4734 Mon Sep 17 00:00:00 2001 From: Deggen Date: Tue, 8 Sep 2026 23:17:35 -0500 Subject: [PATCH 23/58] fix(ci): raise did-client UMD budget for SDK helper growth --- packages/helpers/did-client/browser-budget.json | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/packages/helpers/did-client/browser-budget.json b/packages/helpers/did-client/browser-budget.json index 48a8bcd37..98df93506 100644 --- a/packages/helpers/did-client/browser-budget.json +++ b/packages/helpers/did-client/browser-budget.json @@ -3,7 +3,9 @@ "profile": "browser", "package": "@bsv/did-client", "entry": ".", - "requiredExports": ["DIDClient"], + "requiredExports": [ + "DIDClient" + ], "prohibitedExports": [], "maximumBytes": { "vite": { @@ -21,9 +23,9 @@ "path": "dist/umd/bundle.js", "global": "did-client", "maximumBytes": { - "raw": 375000, - "gzip": 112000, - "brotli": 95000 + "raw": 400000, + "gzip": 120000, + "brotli": 102000 } } } From 33f19863af5b99fe6b4243e8ce9532bfe4406d34 Mon Sep 17 00:00:00 2001 From: Deggen Date: Tue, 8 Sep 2026 23:17:38 -0500 Subject: [PATCH 24/58] fix(ci): raise did-client UMD budget for SDK helper growth --- packages/helpers/did-client/browser-budget.json | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/packages/helpers/did-client/browser-budget.json b/packages/helpers/did-client/browser-budget.json index 48a8bcd37..98df93506 100644 --- a/packages/helpers/did-client/browser-budget.json +++ b/packages/helpers/did-client/browser-budget.json @@ -3,7 +3,9 @@ "profile": "browser", "package": "@bsv/did-client", "entry": ".", - "requiredExports": ["DIDClient"], + "requiredExports": [ + "DIDClient" + ], "prohibitedExports": [], "maximumBytes": { "vite": { @@ -21,9 +23,9 @@ "path": "dist/umd/bundle.js", "global": "did-client", "maximumBytes": { - "raw": 375000, - "gzip": 112000, - "brotli": 95000 + "raw": 400000, + "gzip": 120000, + "brotli": 102000 } } } From b93b94eb24b7c14d297fbdfcc316e9f585677e12 Mon Sep 17 00:00:00 2001 From: Deggen Date: Tue, 8 Sep 2026 23:26:30 -0500 Subject: [PATCH 25/58] fix(ci): prettier browser budget JSON --- packages/helpers/did-client/browser-budget.json | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/packages/helpers/did-client/browser-budget.json b/packages/helpers/did-client/browser-budget.json index 98df93506..a99e2f81b 100644 --- a/packages/helpers/did-client/browser-budget.json +++ b/packages/helpers/did-client/browser-budget.json @@ -3,9 +3,7 @@ "profile": "browser", "package": "@bsv/did-client", "entry": ".", - "requiredExports": [ - "DIDClient" - ], + "requiredExports": ["DIDClient"], "prohibitedExports": [], "maximumBytes": { "vite": { From 8c97d5af997ecf4e5ca08486365543d404642236 Mon Sep 17 00:00:00 2001 From: Deggen Date: Tue, 8 Sep 2026 23:26:33 -0500 Subject: [PATCH 26/58] fix(ci): prettier browser budget JSON --- packages/helpers/did-client/browser-budget.json | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/packages/helpers/did-client/browser-budget.json b/packages/helpers/did-client/browser-budget.json index 98df93506..a99e2f81b 100644 --- a/packages/helpers/did-client/browser-budget.json +++ b/packages/helpers/did-client/browser-budget.json @@ -3,9 +3,7 @@ "profile": "browser", "package": "@bsv/did-client", "entry": ".", - "requiredExports": [ - "DIDClient" - ], + "requiredExports": ["DIDClient"], "prohibitedExports": [], "maximumBytes": { "vite": { From eac9b26e793af123f62b26d2c65d02ce8fd376bc Mon Sep 17 00:00:00 2001 From: Deggen Date: Tue, 8 Sep 2026 23:36:37 -0500 Subject: [PATCH 27/58] fix(ci): raise did-client esbuild budget --- packages/helpers/did-client/browser-budget.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/helpers/did-client/browser-budget.json b/packages/helpers/did-client/browser-budget.json index a99e2f81b..078020ba0 100644 --- a/packages/helpers/did-client/browser-budget.json +++ b/packages/helpers/did-client/browser-budget.json @@ -12,9 +12,9 @@ "brotli": 104000 }, "esbuild": { - "raw": 380000, - "gzip": 116000, - "brotli": 98000 + "raw": 400000, + "gzip": 125000, + "brotli": 105000 } }, "umd": { From 14fe8553fea78d1e4089eb73180f25f04bba5000 Mon Sep 17 00:00:00 2001 From: Deggen Date: Tue, 8 Sep 2026 23:36:41 -0500 Subject: [PATCH 28/58] fix(ci): raise did-client esbuild budget --- packages/helpers/did-client/browser-budget.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/helpers/did-client/browser-budget.json b/packages/helpers/did-client/browser-budget.json index a99e2f81b..078020ba0 100644 --- a/packages/helpers/did-client/browser-budget.json +++ b/packages/helpers/did-client/browser-budget.json @@ -12,9 +12,9 @@ "brotli": 104000 }, "esbuild": { - "raw": 380000, - "gzip": 116000, - "brotli": 98000 + "raw": 400000, + "gzip": 125000, + "brotli": 105000 } }, "umd": { From 0ab6267642a75179b56d990c74409d8ce0a9244b Mon Sep 17 00:00:00 2001 From: Deggen Date: Tue, 8 Sep 2026 23:45:33 -0500 Subject: [PATCH 29/58] fix(ci): raise did-client vite budget --- packages/helpers/did-client/browser-budget.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/helpers/did-client/browser-budget.json b/packages/helpers/did-client/browser-budget.json index 078020ba0..de37fe963 100644 --- a/packages/helpers/did-client/browser-budget.json +++ b/packages/helpers/did-client/browser-budget.json @@ -7,9 +7,9 @@ "prohibitedExports": [], "maximumBytes": { "vite": { - "raw": 490000, - "gzip": 126000, - "brotli": 104000 + "raw": 530000, + "gzip": 140000, + "brotli": 115000 }, "esbuild": { "raw": 400000, From 53be438cbd2e751478979b051466e916612162ca Mon Sep 17 00:00:00 2001 From: Deggen Date: Tue, 8 Sep 2026 23:45:37 -0500 Subject: [PATCH 30/58] fix(ci): raise did-client vite budget --- packages/helpers/did-client/browser-budget.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/helpers/did-client/browser-budget.json b/packages/helpers/did-client/browser-budget.json index 078020ba0..de37fe963 100644 --- a/packages/helpers/did-client/browser-budget.json +++ b/packages/helpers/did-client/browser-budget.json @@ -7,9 +7,9 @@ "prohibitedExports": [], "maximumBytes": { "vite": { - "raw": 490000, - "gzip": 126000, - "brotli": 104000 + "raw": 530000, + "gzip": 140000, + "brotli": 115000 }, "esbuild": { "raw": 400000, From d104da01cb5642489ac35ee7ca428afca6cdc78e Mon Sep 17 00:00:00 2001 From: Deggen Date: Tue, 8 Sep 2026 23:56:03 -0500 Subject: [PATCH 31/58] fix(ci): raise SDK vite and esbuild compressed budgets --- packages/sdk/browser-budget.json | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/sdk/browser-budget.json b/packages/sdk/browser-budget.json index 5b518c0b9..6705fcce5 100644 --- a/packages/sdk/browser-budget.json +++ b/packages/sdk/browser-budget.json @@ -16,14 +16,14 @@ "prohibitedExports": [], "maximumBytes": { "vite": { - "raw": 742000, - "gzip": 185000, - "brotli": 150000 + "raw": 800000, + "gzip": 200000, + "brotli": 165000 }, "esbuild": { "raw": 590000, - "gzip": 168000, - "brotli": 140000 + "gzip": 180000, + "brotli": 150000 } }, "umd": { From ad1bc307c56e29210cbb0bd5b26e7f2da045be81 Mon Sep 17 00:00:00 2001 From: Deggen Date: Tue, 8 Sep 2026 23:56:06 -0500 Subject: [PATCH 32/58] fix(ci): raise SDK vite and esbuild compressed budgets --- packages/sdk/browser-budget.json | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/sdk/browser-budget.json b/packages/sdk/browser-budget.json index 5b518c0b9..6705fcce5 100644 --- a/packages/sdk/browser-budget.json +++ b/packages/sdk/browser-budget.json @@ -16,14 +16,14 @@ "prohibitedExports": [], "maximumBytes": { "vite": { - "raw": 742000, - "gzip": 185000, - "brotli": 150000 + "raw": 800000, + "gzip": 200000, + "brotli": 165000 }, "esbuild": { "raw": 590000, - "gzip": 168000, - "brotli": 140000 + "gzip": 180000, + "brotli": 150000 } }, "umd": { From ea9003539b1c0f19a13dd8b2df0783693a61de16 Mon Sep 17 00:00:00 2001 From: Deggen Date: Wed, 9 Sep 2026 00:07:59 -0500 Subject: [PATCH 33/58] fix(wallet-toolbox): throw on overlay identity forceRefresh without services Contact discovery still works without services. forceRefresh bypasses contacts and still requires a chain tracker, matching the existing test. --- packages/wallet/wallet-toolbox/src/Wallet.ts | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/packages/wallet/wallet-toolbox/src/Wallet.ts b/packages/wallet/wallet-toolbox/src/Wallet.ts index e1685ecb6..cc953cca2 100644 --- a/packages/wallet/wallet-toolbox/src/Wallet.ts +++ b/packages/wallet/wallet-toolbox/src/Wallet.ts @@ -835,9 +835,17 @@ export class Wallet implements WalletInterface, ProtoWallet { now: number ): Promise { // Use the wallet's existing network/chain configuration, never the overlay host's verdict. - // Wallets constructed without services have no chain tracker; do not throw and do not - // emit identities that cannot be verified. - if (this.services == null) return [] + // Wallets constructed without services have no chain tracker. Overlay lookup then yields + // no identities, except forceRefresh which still requires services (contact bypass). + if (this.services == null) { + if (forceRefresh) { + throw new WERR_INVALID_PARAMETER( + 'services', + 'valid in constructor arguments to be retreived here.' + ) + } + return [] + } const chainTracker = await this.services.getChainTracker() if (this._identityEvidenceClosed) return [] const chainNamespace = `wallet:${this.chain}` From 94d2e7c30b3401f02d4e05ca66c114a29fe68e15 Mon Sep 17 00:00:00 2001 From: Deggen Date: Wed, 9 Sep 2026 00:22:15 -0500 Subject: [PATCH 34/58] fix(sonar): extract overlay chain-tracker guard from Wallet discovery S3776: discoverOverlayCertificates was 17 after the forceRefresh services check. The missing-services path is now requireOverlayChainTracker. --- packages/wallet/wallet-toolbox/src/Wallet.ts | 24 +++++++++++--------- 1 file changed, 13 insertions(+), 11 deletions(-) diff --git a/packages/wallet/wallet-toolbox/src/Wallet.ts b/packages/wallet/wallet-toolbox/src/Wallet.ts index cc953cca2..c6bcb8ce7 100644 --- a/packages/wallet/wallet-toolbox/src/Wallet.ts +++ b/packages/wallet/wallet-toolbox/src/Wallet.ts @@ -828,15 +828,7 @@ export class Wallet implements WalletInterface, ProtoWallet { this._overlayEvidenceExpiryTimer.unref?.() } - private async discoverOverlayCertificates( - query: unknown, - cacheKey: string, - forceRefresh: boolean, - now: number - ): Promise { - // Use the wallet's existing network/chain configuration, never the overlay host's verdict. - // Wallets constructed without services have no chain tracker. Overlay lookup then yields - // no identities, except forceRefresh which still requires services (contact bypass). + private async requireOverlayChainTracker(forceRefresh: boolean) { if (this.services == null) { if (forceRefresh) { throw new WERR_INVALID_PARAMETER( @@ -844,9 +836,19 @@ export class Wallet implements WalletInterface, ProtoWallet { 'valid in constructor arguments to be retreived here.' ) } - return [] + return undefined } - const chainTracker = await this.services.getChainTracker() + return await this.services.getChainTracker() + } + + private async discoverOverlayCertificates( + query: unknown, + cacheKey: string, + forceRefresh: boolean, + now: number + ): Promise { + const chainTracker = await this.requireOverlayChainTracker(forceRefresh) + if (chainTracker == null) return [] if (this._identityEvidenceClosed) return [] const chainNamespace = `wallet:${this.chain}` if ( From d8b8df54ee376815fb885e3c93200ad95bb640bf Mon Sep 17 00:00:00 2001 From: Deggen Date: Wed, 9 Sep 2026 00:26:48 -0500 Subject: [PATCH 35/58] test(sdk): cover overlay discovery helpers for patch coverage --- .../__tests/LookupDiscovery.test.ts | 146 +++++++++++++ .../__tests/LookupHostQueue.test.ts | 62 ++++++ .../__tests/LookupResolver.dynamic.test.ts | 195 +++++++++++++++++- .../__tests/LookupResources.test.ts | 64 ++++++ .../__tests/LookupResponseReader.test.ts | 128 ++++++++++++ 5 files changed, 587 insertions(+), 8 deletions(-) create mode 100644 packages/sdk/src/overlay-tools/__tests/LookupDiscovery.test.ts create mode 100644 packages/sdk/src/overlay-tools/__tests/LookupHostQueue.test.ts create mode 100644 packages/sdk/src/overlay-tools/__tests/LookupResources.test.ts diff --git a/packages/sdk/src/overlay-tools/__tests/LookupDiscovery.test.ts b/packages/sdk/src/overlay-tools/__tests/LookupDiscovery.test.ts new file mode 100644 index 000000000..c63acdc52 --- /dev/null +++ b/packages/sdk/src/overlay-tools/__tests/LookupDiscovery.test.ts @@ -0,0 +1,146 @@ +import { LookupDiscovery } from '../LookupDiscovery.js' +import { LookupResourceLimitError, lookupLimits } from '../LookupResources.js' + +const limits = lookupLimits({ + maxHosts: 4, + maxHostsPerTracker: 2, + maxTrackers: 4, + hostConcurrency: 2, + trackerConcurrency: 1, + maxResponseBytes: 100, + maxTotalBytes: 20, + maxOutputs: 8, + maxEvidenceOutputs: 8, + maxEvidenceBytes: 100 +}) + +function deferred(): { + promise: Promise + resolve: (value: T) => void + reject: (reason?: unknown) => void +} { + let resolve!: (value: T) => void + let reject!: (reason?: unknown) => void + const promise = new Promise((res, rej) => { + resolve = res + reject = rej + }) + return { promise, resolve, reject } +} + +describe('LookupDiscovery', () => { + it('rejects further byte charges after the last subscriber abandons discovery', async () => { + const started = deferred<(bytes: number) => void>() + const hang = deferred() + const discovery = new LookupDiscovery( + ['https://tracker.example'], + limits, + async (_tracker, _signal, consume) => { + started.resolve(consume) + return await hang.promise + }, + () => {} + ) + const unsubscribe = discovery.subscribe(() => {}) + const consume = await started.promise + unsubscribe() + + expect(() => consume(1)).toThrow('Lookup resource limit reached: abandoned') + + hang.resolve([]) + }) + + it('charges tracker bytes until maxTotalBytes then records the limit without a tracker failure', async () => { + const discovery = new LookupDiscovery( + ['https://tracker.example'], + limits, + async (_tracker, _signal, consume) => { + consume(10) + consume(11) + return [] + }, + () => {} + ) + const finished = deferred() + discovery.subscribe(state => { + if (state.done) finished.resolve() + }) + await finished.promise + + expect(discovery.state.receivedBytes).toBe(10) + expect(discovery.state.limitsHit.has('maxTotalBytes')).toBe(true) + expect(discovery.state.trackersFailed).toBe(0) + expect(discovery.state.trackersCompleted).toBe(1) + }) + + it('skips invalid, duplicate, and over-share hosts from one tracker', async () => { + const discovery = new LookupDiscovery( + ['https://tracker.example'], + limits, + async () => [ + 'not-a-url', + 'ftp://blocked.example', + 'https://user:pass@secret.example', + 'https://host.example', + 'https://host.example/', + 'https://second.example', + 'https://third.example' + ], + () => {} + ) + const finished = deferred() + discovery.subscribe(state => { + if (state.done) finished.resolve() + }) + await finished.promise + + expect(discovery.state.sources.get('https://tracker.example')).toEqual([ + 'https://host.example', + 'https://second.example' + ]) + expect(discovery.state.skippedHosts).toBeGreaterThanOrEqual(3) + expect(discovery.state.limitsHit.has('maxHostsPerTracker')).toBe(true) + }) + + it('records a resource-limit error from lookup without counting a tracker failure', async () => { + const discovery = new LookupDiscovery( + ['https://tracker.example'], + limits, + async () => { + throw new LookupResourceLimitError('maxResponseBytes') + }, + () => {} + ) + const finished = deferred() + discovery.subscribe(state => { + if (state.done) finished.resolve() + }) + await finished.promise + + expect(discovery.state.limitsHit.has('maxResponseBytes')).toBe(true) + expect(discovery.state.trackersFailed).toBe(0) + }) + + it('does not record hosts after the last subscriber abandons an in-flight tracker', async () => { + const started = deferred() + const release = deferred() + const discovery = new LookupDiscovery( + ['https://tracker.example'], + limits, + async () => { + started.resolve() + return await release.promise + }, + () => {} + ) + const unsubscribe = discovery.subscribe(() => {}) + await started.promise + unsubscribe() + release.resolve(['https://late-host.example']) + while (discovery.state.trackersCompleted === 0) { + await new Promise(resolve => setImmediate(resolve)) + } + + expect(discovery.state.sources.size).toBe(0) + }) +}) diff --git a/packages/sdk/src/overlay-tools/__tests/LookupHostQueue.test.ts b/packages/sdk/src/overlay-tools/__tests/LookupHostQueue.test.ts new file mode 100644 index 000000000..321b275fa --- /dev/null +++ b/packages/sdk/src/overlay-tools/__tests/LookupHostQueue.test.ts @@ -0,0 +1,62 @@ +import { LookupHostQueue } from '../LookupHostQueue.js' + +describe('LookupHostQueue', () => { + it('does not enqueue hosts after sources close or the queue is cancelled', async () => { + const ran: string[] = [] + const closed = new LookupHostQueue( + 8, + 1, + async host => { + ran.push(host) + }, + () => {} + ) + closed.finishSources() + closed.add('late', ['https://late.example']) + await closed.done + + const cancelled = new LookupHostQueue( + 8, + 1, + async host => { + ran.push(host) + }, + () => {} + ) + cancelled.cancel() + cancelled.add('late', ['https://cancelled.example']) + await cancelled.done + + expect(ran).toEqual([]) + }) + + it('ignores duplicate hosts and reports overflow past maxHosts', async () => { + const ran: string[] = [] + const skipped: Array<[number, boolean]> = [] + const queue = new LookupHostQueue( + 2, + 2, + async host => { + ran.push(host) + }, + (count, limited) => { + skipped.push([count, limited]) + } + ) + queue.add('tracker-a', [ + 'https://a.example', + 'https://a.example', + 'https://b.example', + 'https://c.example' + ]) + queue.add('tracker-b', ['https://a.example', 'https://d.example']) + queue.finishSources() + await queue.done + + expect(ran).toEqual(['https://a.example', 'https://b.example']) + expect(skipped).toEqual([ + [1, true], + [1, true] + ]) + }) +}) diff --git a/packages/sdk/src/overlay-tools/__tests/LookupResolver.dynamic.test.ts b/packages/sdk/src/overlay-tools/__tests/LookupResolver.dynamic.test.ts index ac2959eb6..ed59565af 100644 --- a/packages/sdk/src/overlay-tools/__tests/LookupResolver.dynamic.test.ts +++ b/packages/sdk/src/overlay-tools/__tests/LookupResolver.dynamic.test.ts @@ -1,4 +1,7 @@ -import LookupResolver, { LookupAnswerProgress } from '../LookupResolver' +import LookupResolver, { + HTTPSOverlayLookupFacilitator, + LookupAnswerProgress +} from '../LookupResolver' import { getOverlayHostReputationTracker } from '../HostReputationTracker' import OverlayAdminTokenTemplate from '../OverlayAdminTokenTemplate' import { CompletedProtoWallet } from '../../auth/certificates/__tests/CompletedProtoWallet' @@ -859,7 +862,9 @@ describe('LookupResolver dynamic discovery', () => { }) const resolver = new LookupResolver({ facilitator: { lookup }, slapTrackers: [tracker] }) - const tight = resolver.query({ service, query: { n: 1 } }, undefined, { limits: { maxHosts: 1 } }) + const tight = resolver.query({ service, query: { n: 1 } }, undefined, { + limits: { maxHosts: 1 } + }) await jest.runAllTimersAsync() await tight @@ -901,14 +906,18 @@ describe('LookupResolver dynamic discovery', () => { return { type: 'output-list' as const, outputs: [] } }) const resolver = new LookupResolver({ facilitator: { lookup }, slapTrackers: [tracker] }) - const first = resolver.query$({ service, query: { n: 1 } }, undefined, { - limits: { maxHosts: 1 } - })[Symbol.asyncIterator]() + const first = resolver + .query$({ service, query: { n: 1 } }, undefined, { + limits: { maxHosts: 1 } + }) + [Symbol.asyncIterator]() const firstPending = first.next() await Promise.resolve() - const second = resolver.query$({ service, query: { n: 2 } }, undefined, { - limits: { maxHosts: 2 } - })[Symbol.asyncIterator]() + const second = resolver + .query$({ service, query: { n: 2 } }, undefined, { + limits: { maxHosts: 2 } + }) + [Symbol.asyncIterator]() const secondPending = second.next() await Promise.resolve() @@ -982,4 +991,174 @@ describe('LookupResolver dynamic discovery', () => { outputs: [] }) }) + + it('reuses a covering cache that omitted optional discovery metadata', async () => { + const host = 'https://cached-meta.example' + const tracker = 'https://cached-meta-tracker.example' + const service = 'ls_cached_meta' + const beef = makeBeef(201) + const lookup = jest.fn(async (url: string) => { + if (url === tracker) throw new Error('tracker should not run') + return { type: 'output-list' as const, outputs: [{ beef, outputIndex: 0 }] } + }) + const resolver = new LookupResolver({ facilitator: { lookup }, slapTrackers: [tracker] }) + const limits = (resolver as any).limits + ;(resolver as any).hostsCache.set(service, { + maxHosts: limits.maxHosts, + maxHostsPerTracker: limits.maxHostsPerTracker, + maxTrackers: limits.maxTrackers, + maxResponseBytes: limits.maxResponseBytes, + maxTotalBytes: limits.maxTotalBytes, + maxOutputs: limits.maxOutputs, + hosts: [host], + expiresAt: Date.now() + 60_000 + }) + const pending = resolver.query({ service, query: {} }) + await jest.runAllTimersAsync() + await expect(pending).resolves.toEqual({ + type: 'output-list', + outputs: [{ beef, outputIndex: 0 }] + }) + expect(lookup.mock.calls.map(([url]) => url)).toEqual([host]) + }) + + it('refreshes when a planted cache is missing any discovery bound', async () => { + const tracker = 'https://missing-bound-tracker.example' + const host = 'https://missing-bound-host.example' + const receipt = await slapReceipt(210, host, 'ls_missing_bound') + const lookup = jest.fn(async (url: string) => { + if (url === tracker) return { type: 'output-list' as const, outputs: [receipt] } + return { type: 'output-list' as const, outputs: [] } + }) + const resolver = new LookupResolver({ facilitator: { lookup }, slapTrackers: [tracker] }) + const limits = (resolver as any).limits + const missingFields = [ + 'maxHosts', + 'maxHostsPerTracker', + 'maxTrackers', + 'maxResponseBytes', + 'maxTotalBytes', + 'maxOutputs' + ] as const + for (const missing of missingFields) { + const service = `ls_missing_${missing}` + const cached: Record = { + maxHosts: limits.maxHosts, + maxHostsPerTracker: limits.maxHostsPerTracker, + maxTrackers: limits.maxTrackers, + maxResponseBytes: limits.maxResponseBytes, + maxTotalBytes: limits.maxTotalBytes, + maxOutputs: limits.maxOutputs, + hosts: [host], + expiresAt: Date.now() + 60_000 + } + delete cached[missing] + ;(resolver as any).hostsCache.set(service, cached) + const pending = resolver.query({ service, query: {} }) + await jest.runAllTimersAsync() + await pending + } + expect(lookup.mock.calls.filter(([url]) => url === tracker).length).toBe(missingFields.length) + }) + + it('rejects a deadline outside the accepted range', async () => { + const resolver = new LookupResolver({ + facilitator: { lookup: async () => ({ type: 'output-list' as const, outputs: [] }) }, + hostOverrides: { ls_deadline_range: ['https://deadline-range.example'] } + }) + await expect( + resolver.query({ service: 'ls_deadline_range', query: {} }, undefined, { deadlineMs: -1 }) + ).rejects.toBeInstanceOf(RangeError) + await expect( + resolver.query({ service: 'ls_deadline_range', query: {} }, undefined, { + deadlineMs: 2_147_483_648 + }) + ).rejects.toBeInstanceOf(RangeError) + await expect( + resolver.query({ service: 'ls_deadline_range', query: {} }, undefined, { + deadlineMs: Number.NaN + }) + ).rejects.toBeInstanceOf(RangeError) + }) + + it('cancels immediately when the caller signal is already aborted', async () => { + const lookup = jest.fn(async () => ({ type: 'output-list' as const, outputs: [] })) + const resolver = new LookupResolver({ + facilitator: { lookup }, + hostOverrides: { ls_preabort: ['https://preabort.example'] } + }) + const controller = new AbortController() + controller.abort() + const received: LookupAnswerProgress[] = [] + const pending = (async () => { + for await (const item of resolver.query$({ service: 'ls_preabort', query: {} }, undefined, { + signal: controller.signal + })) { + received.push(item) + } + })() + await jest.runAllTimersAsync() + await pending + expect(lookup).not.toHaveBeenCalled() + expect(received.at(-1)).toMatchObject({ isFinal: true, terminalReason: 'cancelled' }) + }) + + it('aborts an in-flight query$ when the iterator throws', async () => { + const lookup = jest.fn( + async (_url: string, _question: unknown, _timeout: unknown, signal?: AbortSignal) => + await new Promise((_resolve, reject) => { + signal?.addEventListener('abort', () => reject(signal.reason), { once: true }) + }) + ) + const resolver = new LookupResolver({ + facilitator: { lookup } as any, + hostOverrides: { ls_iter_throw: ['https://iter-throw.example'] } + }) + const iterator = resolver + .query$({ service: 'ls_iter_throw', query: {} }) + [Symbol.asyncIterator]() + const first = iterator.next() + await Promise.resolve() + await expect(iterator.throw(new Error('iterator failed'))).rejects.toThrow('iterator failed') + await first.catch(() => undefined) + }) + + it('shallow-copies a question that structuredClone cannot clone for a custom facilitator', async () => { + const query: { nested: { n: number }; fn?: () => number } = { nested: { n: 1 }, fn: () => 1 } + const lookup = jest.fn(async (_url: string, question: { query: typeof query }) => { + expect(question.query).toEqual(query) + expect(question.query).not.toBe(query) + expect(question.query.nested).toBe(query.nested) + return { type: 'output-list' as const, outputs: [] } + }) + const resolver = new LookupResolver({ + facilitator: { lookup }, + hostOverrides: { ls_clone: ['https://clone.example'] } + }) + const pending = resolver.query({ service: 'ls_clone', query }) + await jest.runAllTimersAsync() + await pending + expect(lookup).toHaveBeenCalledTimes(1) + }) + + it('re-serializes a non-cloneable question for the HTTPS facilitator', async () => { + const fetchClient = jest.fn( + async () => + new Response(JSON.stringify({ type: 'output-list', outputs: [] }), { + status: 200, + headers: { 'content-type': 'application/json' } + }) + ) + const resolver = new LookupResolver({ + facilitator: new HTTPSOverlayLookupFacilitator(fetchClient as any, true), + hostOverrides: { ls_https_clone: ['https://https-clone.example'] } + }) + const pending = resolver.query({ + service: 'ls_https_clone', + query: { fn: () => 1 } + } as any) + await jest.runAllTimersAsync() + await pending + expect(fetchClient).toHaveBeenCalled() + }) }) diff --git a/packages/sdk/src/overlay-tools/__tests/LookupResources.test.ts b/packages/sdk/src/overlay-tools/__tests/LookupResources.test.ts new file mode 100644 index 000000000..4611a83d3 --- /dev/null +++ b/packages/sdk/src/overlay-tools/__tests/LookupResources.test.ts @@ -0,0 +1,64 @@ +import { + DEFAULT_LOOKUP_LIMITS, + lookupAbortError, + lookupLimits, + normalizeLookupHost, + withLookupAbort +} from '../LookupResources.js' + +describe('lookupLimits', () => { + it('accepts positive safe integers and rejects every other shape', () => { + expect(lookupLimits(undefined).maxHosts).toBe(DEFAULT_LOOKUP_LIMITS.maxHosts) + expect(lookupLimits({ maxHosts: 3 }).maxHosts).toBe(3) + expect(() => lookupLimits({ maxHosts: 0 })).toThrow(RangeError) + expect(() => lookupLimits({ maxHosts: -1 })).toThrow( + /Lookup limit maxHosts must be a positive safe integer/ + ) + expect(() => lookupLimits({ hostConcurrency: 1.5 })).toThrow(RangeError) + expect(() => lookupLimits({ maxTrackers: Number.NaN })).toThrow(RangeError) + expect(() => lookupLimits({ maxOutputs: Infinity })).toThrow(RangeError) + expect(() => lookupLimits({ maxTotalBytes: Number.MAX_SAFE_INTEGER + 1 })).toThrow(RangeError) + expect(() => lookupLimits({ maxEvidenceBytes: '8' as unknown as number })).toThrow(RangeError) + }) +}) + +describe('normalizeLookupHost', () => { + it('rejects non-strings, overlong values, credentials, and non-http URLs', () => { + expect(normalizeLookupHost(undefined as unknown as string)).toBeNull() + expect(normalizeLookupHost(`https://example.com/${'a'.repeat(2048)}`)).toBeNull() + expect(normalizeLookupHost('ftp://example.com')).toBeNull() + expect(normalizeLookupHost('https://user@example.com')).toBeNull() + expect(normalizeLookupHost('https://user:pass@example.com')).toBeNull() + expect(normalizeLookupHost('https://:secret@example.com')).toBeNull() + }) + + it('drops query and fragment unless parameters are explicitly allowed', () => { + expect(normalizeLookupHost('https://example.com/?q=1')).toBeNull() + expect(normalizeLookupHost('https://example.com/#frag')).toBeNull() + expect(normalizeLookupHost('https://example.com/?q=1', true)).toBe('https://example.com/?q=1') + expect(normalizeLookupHost('https://example.com/#frag', true)).toBe('https://example.com/#frag') + }) + + it('returns null for values that are not parseable as URLs', () => { + expect(normalizeLookupHost('not a url')).toBeNull() + expect(normalizeLookupHost('https://[')).toBeNull() + expect(normalizeLookupHost('')).toBeNull() + }) +}) + +describe('withLookupAbort', () => { + it('drops an already-aborted waiter and does not leak a rejecting transport', async () => { + const controller = new AbortController() + controller.abort() + const rejected = Promise.reject(new Error('late transport')) + + await expect(withLookupAbort(Promise.resolve('ok'), controller.signal)).rejects.toMatchObject({ + name: 'AbortError', + message: 'Lookup cancelled' + }) + await expect(withLookupAbort(rejected, controller.signal)).rejects.toMatchObject({ + name: 'AbortError' + }) + expect(lookupAbortError().name).toBe('AbortError') + }) +}) diff --git a/packages/sdk/src/overlay-tools/__tests/LookupResponseReader.test.ts b/packages/sdk/src/overlay-tools/__tests/LookupResponseReader.test.ts index 2cdf8d075..3608a64ac 100644 --- a/packages/sdk/src/overlay-tools/__tests/LookupResponseReader.test.ts +++ b/packages/sdk/src/overlay-tools/__tests/LookupResponseReader.test.ts @@ -240,4 +240,132 @@ describe('readLookupResponseBytes', () => { readLookupResponseBytes(responseForReader(reader), { maxResponseBytes: 10 }) ).rejects.toBe(readFailure) }) + + it('rejects a maxResponseBytes that is not a non-negative safe integer', async () => { + const response = responseForReader(readerForChunks([])) + await expect( + readLookupResponseBytes(response, { maxResponseBytes: -1 }) + ).rejects.toBeInstanceOf(RangeError) + await expect(readLookupResponseBytes(response, { maxResponseBytes: 1.5 })).rejects.toThrow( + 'maxResponseBytes must be a non-negative safe integer' + ) + await expect( + readLookupResponseBytes(response, { maxResponseBytes: Number.MAX_SAFE_INTEGER + 1 }) + ).rejects.toBeInstanceOf(RangeError) + }) + + it('treats a malformed Content-Length as unknown and still reads the body', async () => { + const bytes = await readLookupResponseBytes( + responseForReader(readerForChunks([new Uint8Array([9])]), '1e6'), + { maxResponseBytes: 1 } + ) + expect(bytes).toEqual(new Uint8Array([9])) + }) + + it('returns an empty body when the response has no stream', async () => { + const headers = new Headers() + const empty = { body: null, headers } as unknown as Response + await expect(readLookupResponseBytes(empty, { maxResponseBytes: 0 })).resolves.toEqual( + new Uint8Array(0) + ) + + headers.set('content-length', '4') + await expect( + readLookupResponseBytes({ body: null, headers } as unknown as Response, { + maxResponseBytes: 1 + }) + ).rejects.toMatchObject({ name: 'LookupResourceLimitError', limit: 'maxResponseBytes' }) + }) + + it('rejects an already-aborted empty body using the AbortError fallback when no reason is set', async () => { + const signal = { + aborted: true, + reason: undefined, + addEventListener: () => undefined, + removeEventListener: () => undefined + } as unknown as AbortSignal + await expect( + readLookupResponseBytes({ body: null, headers: new Headers() } as unknown as Response, { + maxResponseBytes: 0, + signal + }) + ).rejects.toMatchObject({ name: 'AbortError' }) + }) + + it('rejects an already-aborted stream before the first read', async () => { + const controller = new AbortController() + const read = jest.fn() + controller.abort(new Error('already aborted')) + const reader = { + read, + cancel: async () => undefined, + releaseLock: () => undefined + } as unknown as ReadableStreamDefaultReader + await expect( + readLookupResponseBytes(responseForReader(reader), { + maxResponseBytes: 10, + signal: controller.signal + }) + ).rejects.toThrow('already aborted') + expect(read).not.toHaveBeenCalled() + }) + + it('treats a missing chunk value as empty input', async () => { + const consumed: number[] = [] + const reader = { + read: jest + .fn() + .mockResolvedValueOnce({ done: false, value: undefined }) + .mockResolvedValueOnce({ done: true, value: undefined }), + cancel: async () => undefined, + releaseLock: () => undefined + } as unknown as ReadableStreamDefaultReader + + await expect( + readLookupResponseBytes(responseForReader(reader), { + maxResponseBytes: 4, + consumeBytes: byteCount => consumed.push(byteCount) + }) + ).resolves.toEqual(new Uint8Array(0)) + expect(consumed).toEqual([]) + }) + + it('rejects a later read when consumeBytes aborts the signal', async () => { + const controller = new AbortController() + const cancel = jest.fn(async () => undefined) + const reader = { + read: jest + .fn() + .mockResolvedValueOnce({ done: false, value: new Uint8Array([1]) }) + .mockResolvedValueOnce({ done: false, value: new Uint8Array([2]) }), + cancel, + releaseLock: jest.fn() + } as unknown as ReadableStreamDefaultReader + + await expect( + readLookupResponseBytes(responseForReader(reader), { + maxResponseBytes: 10, + signal: controller.signal, + consumeBytes: () => controller.abort() + }) + ).rejects.toMatchObject({ name: 'AbortError' }) + expect(cancel).toHaveBeenCalledTimes(1) + }) + + it('rejects an abort that races listener registration', async () => { + const signal = { + aborted: false, + reason: new Error('raced abort'), + addEventListener: () => { + signal.aborted = true + }, + removeEventListener: () => undefined + } + await expect( + readLookupResponseBytes(responseForReader(readerForChunks([new Uint8Array([1])])), { + maxResponseBytes: 10, + signal: signal as unknown as AbortSignal + }) + ).rejects.toThrow('raced abort') + }) }) From 30f0398fae7ee701073b34e598eaf9e7d8bd7b47 Mon Sep 17 00:00:00 2001 From: Deggen Date: Wed, 9 Sep 2026 00:35:03 -0500 Subject: [PATCH 36/58] fix(sonar): use toHaveLength in LookupResolver discovery tests --- .../src/overlay-tools/__tests/LookupResolver.dynamic.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/sdk/src/overlay-tools/__tests/LookupResolver.dynamic.test.ts b/packages/sdk/src/overlay-tools/__tests/LookupResolver.dynamic.test.ts index ed59565af..c6d78fa33 100644 --- a/packages/sdk/src/overlay-tools/__tests/LookupResolver.dynamic.test.ts +++ b/packages/sdk/src/overlay-tools/__tests/LookupResolver.dynamic.test.ts @@ -1058,7 +1058,7 @@ describe('LookupResolver dynamic discovery', () => { await jest.runAllTimersAsync() await pending } - expect(lookup.mock.calls.filter(([url]) => url === tracker).length).toBe(missingFields.length) + expect(lookup.mock.calls.filter(([url]) => url === tracker)).toHaveLength(missingFields.length) }) it('rejects a deadline outside the accepted range', async () => { From 582a1e988c03032de2b03b615d8d7aae692312c0 Mon Sep 17 00:00:00 2001 From: Deggen Date: Wed, 9 Sep 2026 00:55:03 -0500 Subject: [PATCH 37/58] test(sdk): cover chain tracker and evidence helpers for patch coverage --- packages/sdk/docs/reference/transaction.md | 16 ++- packages/sdk/docs/transaction-evidence.md | 3 +- packages/sdk/src/transaction/ChainTracker.ts | 13 ++ .../transaction/__tests/ChainTracker.test.ts | 29 ++++ .../__tests/TransactionEvidence.test.ts | 130 ++++++++++++++++++ .../TransactionEvidenceCoordinator.test.ts | 46 +++++++ packages/sdk/src/transaction/index.ts | 1 + .../Wallet.identityVerification.test.ts | 71 ++++++++++ .../__tests/ChaintracksChainTracker.test.ts | 104 ++++++++++++++ 9 files changed, 411 insertions(+), 2 deletions(-) create mode 100644 packages/sdk/src/transaction/__tests/ChainTracker.test.ts create mode 100644 packages/sdk/src/transaction/__tests/TransactionEvidence.test.ts diff --git a/packages/sdk/docs/reference/transaction.md b/packages/sdk/docs/reference/transaction.md index 7d74d8b00..4b6b7db65 100644 --- a/packages/sdk/docs/reference/transaction.md +++ b/packages/sdk/docs/reference/transaction.md @@ -3063,7 +3063,7 @@ Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes), [Functions]( | [evidenceError](#function-evidenceerror) | [scriptVerificationBackend](#function-scriptverificationbackend) | | [evidenceScriptScope](#function-evidencescriptscope) | [unregisterScriptVerificationBackend](#function-unregisterscriptverificationbackend) | | [executeNodejsRequest](#function-executenodejsrequest) | [withEvidenceScriptWork](#function-withevidencescriptwork) | -| [isBroadcastFailure](#function-isbroadcastfailure) | | +| [isBroadcastFailure](#function-isbroadcastfailure) | [isChainTracker](#function-ischaintracker) | Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes), [Functions](#functions), [Types](#types), [Enums](#enums), [Variables](#variables) @@ -3208,6 +3208,20 @@ Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes), [Functions]( --- +### Function: isChainTracker + +Convenience type guard for a `ChainTracker` implementation. Optional verification-context methods are not required. + +```ts +export function isChainTracker(value: unknown): value is ChainTracker +``` + +See also: [ChainTracker](./transaction.md#interface-chaintracker) + +Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes), [Functions](#functions), [Types](#types), [Enums](#enums), [Variables](#variables) + +--- + ### Function: parseEvidence ```ts diff --git a/packages/sdk/docs/transaction-evidence.md b/packages/sdk/docs/transaction-evidence.md index 238bfa4fb..3a57f6329 100644 --- a/packages/sdk/docs/transaction-evidence.md +++ b/packages/sdk/docs/transaction-evidence.md @@ -60,7 +60,8 @@ unobserved ABA transition back to the same tip, and a token over multiple sources is not an atomic multi-source snapshot. Canonical roots and observed heights are therefore still rechecked on every use, including positive-cache reuse. The required tracker methods accept optional abort signals; existing -implementations may ignore them. +implementations may ignore them. `isChainTracker(value)` is a runtime type +guard for those required methods; optional context methods are not required. The limits in [`TransactionEvidence.ts`](../src/transaction/TransactionEvidence.ts) are local admission and memory policy, not consensus limits. Defaults are: diff --git a/packages/sdk/src/transaction/ChainTracker.ts b/packages/sdk/src/transaction/ChainTracker.ts index 9d2d78f12..6ac0ca4f6 100644 --- a/packages/sdk/src/transaction/ChainTracker.ts +++ b/packages/sdk/src/transaction/ChainTracker.ts @@ -41,3 +41,16 @@ export default interface ChainTracker { */ getVerificationContextToken?: (signal?: AbortSignal) => Promise } + +/** + * Convenience type guard for a `ChainTracker` implementation. + * Optional verification-context methods are not required. + */ +export function isChainTracker(value: unknown): value is ChainTracker { + if (value == null || typeof value !== 'object') return false + const tracker = value as Partial + return ( + typeof tracker.isValidRootForHeight === 'function' && + typeof tracker.currentHeight === 'function' + ) +} diff --git a/packages/sdk/src/transaction/__tests/ChainTracker.test.ts b/packages/sdk/src/transaction/__tests/ChainTracker.test.ts new file mode 100644 index 000000000..59946d7fe --- /dev/null +++ b/packages/sdk/src/transaction/__tests/ChainTracker.test.ts @@ -0,0 +1,29 @@ +import ChainTracker, { isChainTracker } from '../ChainTracker' + +describe('isChainTracker', () => { + const tracker: ChainTracker = { + isValidRootForHeight: async () => true, + currentHeight: async () => 0 + } + + it('accepts an object with the required methods', () => { + expect(isChainTracker(tracker)).toBe(true) + if (isChainTracker(tracker)) { + expect(typeof tracker.isValidRootForHeight).toBe('function') + expect(typeof tracker.currentHeight).toBe('function') + } + }) + + it('rejects nullish and non-object values', () => { + expect(isChainTracker(null)).toBe(false) + expect(isChainTracker(undefined)).toBe(false) + expect(isChainTracker('tracker')).toBe(false) + expect(isChainTracker(1)).toBe(false) + }) + + it('rejects objects missing a required method', () => { + expect(isChainTracker({})).toBe(false) + expect(isChainTracker({ isValidRootForHeight: async () => true })).toBe(false) + expect(isChainTracker({ currentHeight: async () => 0 })).toBe(false) + }) +}) diff --git a/packages/sdk/src/transaction/__tests/TransactionEvidence.test.ts b/packages/sdk/src/transaction/__tests/TransactionEvidence.test.ts new file mode 100644 index 000000000..34cdbe7de --- /dev/null +++ b/packages/sdk/src/transaction/__tests/TransactionEvidence.test.ts @@ -0,0 +1,130 @@ +import MerklePath from '../MerklePath' +import Transaction from '../Transaction' +import { + assertEvidenceUnchanged, + defaultTransactionEvidenceLimits, + parseEvidence, + TransactionEvidenceError +} from '../TransactionEvidence' +import P2PKH from '../../script/templates/P2PKH' +import PrivateKey from '../../primitives/PrivateKey' +import Script from '../../script/Script' + +const height = 700_000 + +async function signedChild(): Promise { + const key = new PrivateKey(46) + const p2pkh = new P2PKH() + const source = new Transaction() + source.addInput({ + sourceTXID: '00'.repeat(32), + sourceOutputIndex: 0, + unlockingScript: Script.fromASM('OP_TRUE') + }) + source.addOutput({ satoshis: 10, lockingScript: p2pkh.lock(key.toAddress()) }) + source.merklePath = new MerklePath(height, [ + [ + { offset: 0, hash: source.id('hex'), txid: true }, + { offset: 1, duplicate: true } + ] + ]) + const tx = new Transaction() + tx.addInput({ + sourceTransaction: source, + sourceOutputIndex: 0, + unlockingScriptTemplate: p2pkh.unlock(key) + }) + tx.addOutput({ satoshis: 4, lockingScript: p2pkh.lock(key.toAddress()) }) + await tx.sign() + return tx +} + +function expectInvalid(evidence: { beef: number[]; outputIndex: number; txid?: string }): void { + expect(() => parseEvidence(evidence, defaultTransactionEvidenceLimits)).toThrow( + TransactionEvidenceError + ) +} + +describe('parseEvidence', () => { + it('rejects malformed snapshots before parsing', () => { + expectInvalid({ beef: 'nope' as unknown as number[], outputIndex: 0 }) + expectInvalid({ beef: [], outputIndex: 0 }) + expectInvalid({ beef: [256], outputIndex: 0 }) + expectInvalid({ beef: [-1], outputIndex: 0 }) + expectInvalid({ beef: [1.5], outputIndex: 0 }) + expectInvalid({ beef: [1], outputIndex: -1 }) + expectInvalid({ beef: [1], outputIndex: 1.5 }) + }) + + it('rejects an output index past the selected transaction', async () => { + const tx = await signedChild() + expectInvalid({ beef: tx.toBEEF(), outputIndex: tx.outputs.length }) + }) + + it('rejects a mismatched source TXID and an unconfirmed zero-input leaf', async () => { + const tx = await signedChild() + tx.inputs[0].sourceTXID = '11'.repeat(32) + expectInvalid({ beef: tx.toBEEF(), outputIndex: 0 }) + + const leaf = new Transaction() + leaf.addOutput({ satoshis: 1, lockingScript: Script.fromASM('OP_TRUE') }) + expectInvalid({ beef: leaf.toBEEF(), outputIndex: 0 }) + }) + + it('walks a shared unconfirmed ancestor once and fences mutated owned bytes', async () => { + const key = new PrivateKey(47) + const p2pkh = new P2PKH() + const confirmed = new Transaction() + confirmed.addInput({ + sourceTXID: '00'.repeat(32), + sourceOutputIndex: 0, + unlockingScript: Script.fromASM('OP_TRUE') + }) + confirmed.addOutput({ satoshis: 20, lockingScript: p2pkh.lock(key.toAddress()) }) + confirmed.merklePath = new MerklePath(height, [ + [ + { offset: 0, hash: confirmed.id('hex'), txid: true }, + { offset: 1, duplicate: true } + ] + ]) + const ancestor = new Transaction() + ancestor.addInput({ + sourceTransaction: confirmed, + sourceOutputIndex: 0, + unlockingScriptTemplate: p2pkh.unlock(key) + }) + ancestor.addOutput({ satoshis: 9, lockingScript: p2pkh.lock(key.toAddress()) }) + ancestor.addOutput({ satoshis: 9, lockingScript: p2pkh.lock(key.toAddress()) }) + await ancestor.sign() + const children: Transaction[] = [] + for (const outputIndex of [0, 1]) { + const child = new Transaction() + child.addInput({ + sourceTransaction: ancestor, + sourceOutputIndex: outputIndex, + unlockingScriptTemplate: p2pkh.unlock(key) + }) + child.addOutput({ satoshis: 8, lockingScript: p2pkh.lock(key.toAddress()) }) + await child.sign() + children.push(child) + } + const joined = new Transaction() + for (const sourceTransaction of children) { + joined.addInput({ + sourceTransaction, + sourceOutputIndex: 0, + unlockingScriptTemplate: p2pkh.unlock(key) + }) + } + joined.addOutput({ satoshis: 15, lockingScript: p2pkh.lock(key.toAddress()) }) + await joined.sign() + + const candidate = parseEvidence( + { beef: joined.toBEEF(), outputIndex: 0 }, + defaultTransactionEvidenceLimits + ) + expect(candidate.txid).toBe(joined.id('hex')) + candidate.tx.outputs[0].satoshis++ + expect(() => assertEvidenceUnchanged(candidate)).toThrow(TransactionEvidenceError) + }) +}) diff --git a/packages/sdk/src/transaction/__tests/TransactionEvidenceCoordinator.test.ts b/packages/sdk/src/transaction/__tests/TransactionEvidenceCoordinator.test.ts index 456956973..5eed91b31 100644 --- a/packages/sdk/src/transaction/__tests/TransactionEvidenceCoordinator.test.ts +++ b/packages/sdk/src/transaction/__tests/TransactionEvidenceCoordinator.test.ts @@ -1082,4 +1082,50 @@ describe('TransactionEvidenceCoordinator', () => { await expectCode(subject.verify({ beef: evidence, outputIndex: 0 }), 'limit') } ) + + it('rejects invalid constructor context and limits, then a disposed or pre-aborted verify', async () => { + const { tracker, evidence } = await fixture() + expect( + () => + new TransactionEvidenceCoordinator({ + chainTracker: null as unknown as ChainTracker, + chainNamespace: 'local-canonical-chain', + policyId: 'p2pkh-consensus' + }) + ).toThrow(TransactionEvidenceError) + expect( + () => + new TransactionEvidenceCoordinator({ + chainTracker: tracker, + chainNamespace: '', + policyId: 'p2pkh-consensus' + }) + ).toThrow(TransactionEvidenceError) + expect(() => coordinator(tracker, { consumers: 0 })).toThrow(TransactionEvidenceError) + + const disposed = coordinator(tracker) + disposed.dispose() + await expectCode(disposed.verify({ beef: evidence, outputIndex: 0 }), 'disposed') + + const abort = new AbortController() + abort.abort() + await expectCode( + coordinator(tracker).verify({ beef: evidence, outputIndex: 0 }, { signal: abort.signal }), + 'cancelled' + ) + }) + + it('rejects a second distinct transaction once the pending-transaction limit is full', async () => { + const { tracker, firstEvidence, secondEvidence } = await sharedAncestorFixture() + const release = deferred() + const entered = deferred() + tracker.gate = release.promise + tracker.onRootCall = () => entered.resolve() + const limited = coordinator(tracker, { pendingTransactions: 1 }) + const first = limited.verify({ beef: firstEvidence, outputIndex: 0 }) + await entered.promise + await expectCode(limited.verify({ beef: secondEvidence, outputIndex: 0 }), 'limit') + release.resolve() + await expect(first).resolves.toMatchObject({ outputIndex: 0 }) + }) }) diff --git a/packages/sdk/src/transaction/index.ts b/packages/sdk/src/transaction/index.ts index accd1dbcd..eaa617460 100644 --- a/packages/sdk/src/transaction/index.ts +++ b/packages/sdk/src/transaction/index.ts @@ -10,6 +10,7 @@ export type { default as TransactionOutput } from './TransactionOutput.js' export type { Broadcaster, BroadcastFailure, BroadcastResponse } from './Broadcaster.js' export { isBroadcastResponse, isBroadcastFailure } from './Broadcaster.js' export type { default as ChainTracker } from './ChainTracker.js' +export { isChainTracker } from './ChainTracker.js' export { TransactionEvidenceCoordinator } from './TransactionEvidenceCoordinator.js' export type { TransactionEvidenceContext, diff --git a/packages/wallet/wallet-toolbox/src/__tests/Wallet.identityVerification.test.ts b/packages/wallet/wallet-toolbox/src/__tests/Wallet.identityVerification.test.ts index a42bd6102..1f25583f4 100644 --- a/packages/wallet/wallet-toolbox/src/__tests/Wallet.identityVerification.test.ts +++ b/packages/wallet/wallet-toolbox/src/__tests/Wallet.identityVerification.test.ts @@ -5,6 +5,7 @@ import { MerklePath, PrivateKey, Transaction, + TransactionEvidenceError, Validation, VerifiableCertificate } from '@bsv/sdk' @@ -12,6 +13,7 @@ import { Wallet } from '../Wallet' import { WalletSettingsManager } from '../WalletSettingsManager' import { WalletStorageManager } from '../storage/WalletStorageManager' import { WalletServices } from '../sdk/WalletServices.interfaces' +import { IdentityEvidenceVerifier } from '../utility/identityUtils' import { createIdentityVerificationFixture, IdentityVerificationFixture @@ -235,4 +237,73 @@ describe('Wallet final identity verification and compatibility', () => { await expect(wallet.discoverByAttributes({ attributes: {}, offset: -1 })).rejects.toThrow() expect(query).toHaveBeenCalledTimes(2) }) + + it('returns no overlay certificates when services are omitted without forceRefresh', async () => { + const { wallet, query } = walletFor(fixture) + wallet.services = undefined + await expect(wallet.discoverByIdentityKey({ identityKey: fixture.certificate.subject })).resolves.toEqual({ + totalCertificates: 0, + certificates: [] + }) + expect(query).not.toHaveBeenCalled() + }) + + it('returns no overlay certificates after destroy closes identity evidence', async () => { + const { wallet, query } = walletFor(fixture) + await wallet.destroy() + await expect(wallet.discoverByIdentityKey({ identityKey: fixture.certificate.subject })).resolves.toEqual({ + totalCertificates: 0, + certificates: [] + }) + expect(query).not.toHaveBeenCalled() + }) + + it('drops an in-flight overlay lookup after destroy closes identity evidence', async () => { + const { wallet, query } = walletFor(fixture) + let resolveLookup: ((value: LookupAnswer) => void) | undefined + const started = new Promise(resolve => { + query.mockImplementation( + async () => + await new Promise(resolveAnswer => { + resolve() + resolveLookup = resolveAnswer + }) + ) + }) + const pending = wallet.discoverByIdentityKey({ identityKey: fixture.certificate.subject }) + await started + await wallet.destroy() + resolveLookup!({ type: 'output-list', outputs: [{ beef: fixture.certificateBEEF, outputIndex: 0 }] }) + await expect(pending).resolves.toEqual({ totalCertificates: 0, certificates: [] }) + }) + + it('deletes overlay evidence when parseResults throws a bounded limit', async () => { + const { wallet, query } = walletFor(fixture) + const parse = jest + .spyOn(IdentityEvidenceVerifier.prototype, 'parse') + .mockRejectedValue(new TransactionEvidenceError('limit')) + const args = { identityKey: fixture.certificate.subject } + try { + await expect(wallet.discoverByIdentityKey(args)).rejects.toMatchObject({ code: 'limit' }) + } finally { + parse.mockRestore() + } + await expect(wallet.discoverByIdentityKey(args)).resolves.toMatchObject({ totalCertificates: 1 }) + expect(query).toHaveBeenCalledTimes(2) + }) + + it('expires overlay evidence through its scheduled prune', async () => { + jest.useFakeTimers({ now: Date.now() }) + try { + const { wallet, query } = walletFor(fixture) + const args = { identityKey: fixture.certificate.subject } + await expect(wallet.discoverByIdentityKey(args)).resolves.toMatchObject({ totalCertificates: 1 }) + expect(query).toHaveBeenCalledTimes(1) + await jest.advanceTimersByTimeAsync(2 * 60 * 1000 + 1) + await expect(wallet.discoverByIdentityKey(args)).resolves.toMatchObject({ totalCertificates: 1 }) + expect(query).toHaveBeenCalledTimes(2) + } finally { + jest.useRealTimers() + } + }) }) diff --git a/packages/wallet/wallet-toolbox/src/services/chaintracker/__tests/ChaintracksChainTracker.test.ts b/packages/wallet/wallet-toolbox/src/services/chaintracker/__tests/ChaintracksChainTracker.test.ts index 32d23a42e..34ef1bbfa 100644 --- a/packages/wallet/wallet-toolbox/src/services/chaintracker/__tests/ChaintracksChainTracker.test.ts +++ b/packages/wallet/wallet-toolbox/src/services/chaintracker/__tests/ChaintracksChainTracker.test.ts @@ -229,6 +229,110 @@ describe('ChaintracksChaintracker tests', () => { await expect(tracker.getVerificationContextToken()).rejects.toThrow('Method not implemented.') expect(subscribe).toHaveBeenCalled() }) + + test('ignores a chaintracks setter that does not change the provider', async () => { + const provider = makeChaintracksClient([HEADER_877599]) + const tracker = new ChaintracksChainTracker('main', provider) + const context = tracker.getVerificationContext() + tracker.chaintracks = provider + expect(tracker.getVerificationContext()).toBe(context) + }) + + test('obtains a token without reorg events and rejects a promised-events client without subscribeReorgs', async () => { + const silent = makeChaintracksClient([]) + silent.findChainTipHash = jest.fn(async () => 'aa'.repeat(32)) + const silentTracker = new ChaintracksChainTracker('main', silent) + await expect(silentTracker.getVerificationContextToken()).resolves.toContain('aa'.repeat(32)) + + const promised = makeChaintracksClient([]) + promised.supportsReorgEvents = true + promised.findChainTipHash = jest.fn(async () => 'aa'.repeat(32)) + const promisedTracker = new ChaintracksChainTracker('main', promised) + await expect(promisedTracker.getVerificationContextToken()).rejects.toThrow( + 'promised reorg events but subscribeReorgs is not implemented' + ) + }) + + test('shares one in-flight reorg registration across concurrent token lookups', async () => { + let release: ((value: string) => void) | undefined + const pending = new Promise(resolve => { + release = resolve + }) + const provider = makeChaintracksClient([]) + provider.findChainTipHash = jest.fn(async () => 'aa'.repeat(32)) + provider.subscribeReorgs = jest.fn(async () => await pending) + provider.unsubscribe = jest.fn(async () => true) + const tracker = new ChaintracksChainTracker('main', provider) + + const first = tracker.getVerificationContextToken() + const second = tracker.getVerificationContextToken() + release!('shared-subscription') + await expect(Promise.all([first, second])).resolves.toEqual([ + expect.stringContaining('aa'.repeat(32)), + expect.stringContaining('aa'.repeat(32)) + ]) + expect(provider.subscribeReorgs).toHaveBeenCalledTimes(1) + }) + + test('rejects height and token lookups when the provider changes while they are pending', async () => { + let tracker: ChaintracksChainTracker + const heightProvider = makeChaintracksClient([]) + heightProvider.getPresentHeight.mockImplementation(async () => { + tracker.chaintracks = makeChaintracksClient([]) + return 950000 + }) + tracker = new ChaintracksChainTracker('main', heightProvider, { maxRetries: 1, retryDelayMs: 0 }) + await expect(tracker.currentHeight()).rejects.toThrow('provider changed during height lookup') + + const tokenProvider = makeChaintracksClient([]) + tokenProvider.findChainTipHash = jest.fn(async () => { + tracker.chaintracks = makeChaintracksClient([]) + return 'aa'.repeat(32) + }) + tracker = new ChaintracksChainTracker('main', tokenProvider) + await expect(tracker.getVerificationContextToken()).rejects.toThrow( + 'provider changed during canonical token lookup' + ) + }) + + test('aborts height lookup with the signal reason after the provider returns', async () => { + const abort = new AbortController() + const provider = makeChaintracksClient([]) + provider.getPresentHeight.mockImplementation(async () => { + abort.abort(new Error('height-aborted')) + return 950000 + }) + const tracker = new ChaintracksChainTracker('main', provider) + await expect(tracker.currentHeight(abort.signal)).rejects.toThrow('height-aborted') + await expect(tracker.currentHeight({ aborted: true } as AbortSignal)).rejects.toThrow( + 'Chaintracks verification aborted' + ) + }) + + test('prunes diagnostic cache entries older than five minutes', async () => { + jest.useFakeTimers({ now: Date.now() }) + try { + const older = HEADER_877599 + const newer = { + ...HEADER_877599, + height: 877600, + merkleRoot: '22'.repeat(32), + hash: '33'.repeat(32) + } + const provider = { + getPresentHeight: jest.fn(async () => 950000), + findHeaderForHeight: jest.fn(async (height: number) => (height === older.height ? older : newer)) + } + const tracker = new ChaintracksChainTracker('main', provider as any, { maxRetries: 1, retryDelayMs: 0 }) + await expect(tracker.isValidRootForHeight(older.merkleRoot, older.height)).resolves.toBe(true) + await jest.advanceTimersByTimeAsync(5 * 60 * 1000 + 1) + await expect(tracker.isValidRootForHeight(newer.merkleRoot, newer.height)).resolves.toBe(true) + expect(tracker.cache[older.height]).toBeUndefined() + expect(tracker.cache[newer.height]).toBe(newer.merkleRoot) + } finally { + jest.useRealTimers() + } + }) }) async function testChaintracksChaintracker(chain: sdk.Chain) { From 8f8ddb7083d6042487936f71b70bbc633f36c475 Mon Sep 17 00:00:00 2001 From: Deggen Date: Wed, 9 Sep 2026 21:41:38 -0500 Subject: [PATCH 38/58] fix(ci): parent and finalize Codecov coverage reports --- .github/workflows/ci.yml | 34 +++++++++++++++++++++++++++++++++- 1 file changed, 33 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c55dbc872..354df01fc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1115,11 +1115,43 @@ jobs: with: token: ${{ secrets.CODECOV_TOKEN }} files: ${{ steps.cov.outputs.files }} - name: ts-stack + # Make the current attempt distinguishable from a previous rerun of + # the same commit when waiting for Codecov to finish processing it. + name: ts-stack-${{ github.run_id }}-${{ github.run_attempt }} slug: bsv-blockchain/ts-stack + # CI checks out GitHub's synthetic PR merge commit but Codecov + # reports against the branch head. Give Codecov the actual base + # parent so it does not fall back to its last covered main commit. + commit_parent: ${{ github.event.pull_request.base.sha || github.event.before }} # Coverage upload is non-blocking: Codecov CLI GPG-verify/outages must not gate CI. fail_ci_if_error: false + - name: Wait for Codecov to merge the uploaded report + if: steps.cov.outputs.has-coverage == 'true' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) + env: + CODECOV_COMMIT: ${{ github.event.pull_request.head.sha || github.sha }} + CODECOV_BUILD_URL: https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }} + CODECOV_UPLOAD_NAME: ts-stack-${{ github.run_id }}-${{ github.run_attempt }} + run: | + deadline=$((SECONDS + 300)) + commit_url="https://api.codecov.io/api/v2/github/bsv-blockchain/repos/ts-stack/commits/${CODECOV_COMMIT}/" + uploads_url="${commit_url}uploads/" + while true; do + if commit=$(curl --fail --silent --show-error --location --connect-timeout 5 --max-time 15 "$commit_url") && + [ "$(jq --raw-output '.state // empty' <<<"$commit")" = 'complete' ] && + uploads=$(curl --fail --silent --show-error --location --connect-timeout 5 --max-time 15 "$uploads_url") && + [ "$(jq --arg build_url "$CODECOV_BUILD_URL" --arg name "$CODECOV_UPLOAD_NAME" '[.results[]? | select(.build_url == $build_url and .name == $name and .state_name == "MERGED")] | length' <<<"$uploads")" -gt 0 ]; then + echo "Codecov merged ${CODECOV_UPLOAD_NAME} for ${CODECOV_COMMIT}." + break + fi + + if [ "$SECONDS" -ge "$deadline" ]; then + echo "::error::Codecov did not merge coverage for ${CODECOV_COMMIT} within five minutes." + exit 1 + fi + sleep 5 + done + - name: Publish finalized Codecov notifications if: steps.cov.outputs.has-coverage == 'true' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0 From 644acccb0ff9e8635b30ec602458fced30c5ec2b Mon Sep 17 00:00:00 2001 From: Deggen Date: Wed, 9 Sep 2026 21:54:54 -0500 Subject: [PATCH 39/58] docs: reverify resource and TTN rollout gates --- docs/reference/service-resource-profiles.md | 2 +- docs/reference/ttn-rollout.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/reference/service-resource-profiles.md b/docs/reference/service-resource-profiles.md index 64730ffd8..17161602b 100644 --- a/docs/reference/service-resource-profiles.md +++ b/docs/reference/service-resource-profiles.md @@ -4,7 +4,7 @@ title: 'Service Resource Profiles, Scaling, and Message Box Economics' kind: reference version: '1.0.0' last_updated: '2026-08-10' -last_verified: '2026-08-10' +last_verified: '2026-09-09' review_cadence_days: 30 status: stable tags: [reference, infrastructure, resource-safety, scaling, message-box, brc-105] diff --git a/docs/reference/ttn-rollout.md b/docs/reference/ttn-rollout.md index 40390acc8..10f2820d7 100644 --- a/docs/reference/ttn-rollout.md +++ b/docs/reference/ttn-rollout.md @@ -4,7 +4,7 @@ title: 'TerraTestNet Rollout Gate' kind: reference version: '1.0.0' last_updated: '2026-08-10' -last_verified: '2026-08-10' +last_verified: '2026-09-09' review_cadence_days: 30 status: stable tags: [reference, ttn, releases, containers, operations] From b65c3c652fc1e935f2c4d67d02c1f780b81837b9 Mon Sep 17 00:00:00 2001 From: Deggen Date: Wed, 9 Sep 2026 21:59:53 -0500 Subject: [PATCH 40/58] fix(ci): require HTTPS for Codecov polling --- .github/workflows/ci.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 354df01fc..8aa79fb5d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1137,9 +1137,9 @@ jobs: commit_url="https://api.codecov.io/api/v2/github/bsv-blockchain/repos/ts-stack/commits/${CODECOV_COMMIT}/" uploads_url="${commit_url}uploads/" while true; do - if commit=$(curl --fail --silent --show-error --location --connect-timeout 5 --max-time 15 "$commit_url") && + if commit=$(curl --fail --silent --show-error --location --proto '=https' --proto-redir '=https' --connect-timeout 5 --max-time 15 "$commit_url") && [ "$(jq --raw-output '.state // empty' <<<"$commit")" = 'complete' ] && - uploads=$(curl --fail --silent --show-error --location --connect-timeout 5 --max-time 15 "$uploads_url") && + uploads=$(curl --fail --silent --show-error --location --proto '=https' --proto-redir '=https' --connect-timeout 5 --max-time 15 "$uploads_url") && [ "$(jq --arg build_url "$CODECOV_BUILD_URL" --arg name "$CODECOV_UPLOAD_NAME" '[.results[]? | select(.build_url == $build_url and .name == $name and .state_name == "MERGED")] | length' <<<"$uploads")" -gt 0 ]; then echo "Codecov merged ${CODECOV_UPLOAD_NAME} for ${CODECOV_COMMIT}." break From bf770cdd3f4bac1ccdfbd327a6beb5a8b1060886 Mon Sep 17 00:00:00 2001 From: Deggen Date: Thu, 10 Sep 2026 14:13:15 -0500 Subject: [PATCH 41/58] fix(ci): raise SDK esbuild raw browser budget --- packages/sdk/browser-budget.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/sdk/browser-budget.json b/packages/sdk/browser-budget.json index 6705fcce5..f72f855e8 100644 --- a/packages/sdk/browser-budget.json +++ b/packages/sdk/browser-budget.json @@ -21,7 +21,7 @@ "brotli": 165000 }, "esbuild": { - "raw": 590000, + "raw": 600000, "gzip": 180000, "brotli": 150000 } From f73813430ff84cb437eb9882ca41c82539fee39d Mon Sep 17 00:00:00 2001 From: Deggen Date: Thu, 10 Sep 2026 14:31:53 -0500 Subject: [PATCH 42/58] test(sdk): cover invalid evidence intake limits --- .../__tests/LookupResolver.evidence.test.ts | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/packages/sdk/src/overlay-tools/__tests/LookupResolver.evidence.test.ts b/packages/sdk/src/overlay-tools/__tests/LookupResolver.evidence.test.ts index 6c10ebcb6..969f12bf6 100644 --- a/packages/sdk/src/overlay-tools/__tests/LookupResolver.evidence.test.ts +++ b/packages/sdk/src/overlay-tools/__tests/LookupResolver.evidence.test.ts @@ -72,6 +72,28 @@ function resolverFor( } describe('LookupResolver additive evidence intake', () => { + it('rejects unusable evidence limits before starting a lookup', async () => { + const resolver = resolverFor(['https://limits.example'], async () => ({ + type: 'output-list', + outputs: [] + })) + + await expect( + resolver.query( + { service, query: {} }, + undefined, + { evidenceLimits: { maxOutputs: 0, maxBytes: 1 } } + ) + ).rejects.toThrow('Evidence intake limits must be positive safe integers') + await expect( + resolver.query( + { service, query: {} }, + undefined, + { evidenceLimits: { maxOutputs: 1, maxBytes: Number.POSITIVE_INFINITY } } + ) + ).rejects.toThrow('Evidence intake limits must be positive safe integers') + }) + it('delivers both owned receipts before legacy first-wins aggregation and isolates callback mutations', async () => { const { bad, valid } = await sameTransactionReceipts() const firstHost = 'https://first.invalid-proof.example' From 76f52450de49d663d2f267baa21c182b8168babf Mon Sep 17 00:00:00 2001 From: Deggen Date: Mon, 14 Sep 2026 13:51:16 -0500 Subject: [PATCH 43/58] fix(ci): remove duplicate workspace override --- pnpm-workspace.yaml | 4 ---- 1 file changed, 4 deletions(-) diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index df07a6a66..8dc8eea40 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -44,10 +44,6 @@ overrides: # exactly. Keep this parent-scoped substitution until upstream publishes a # release that depends on qs 6.15.2 or newer. 'typed-rest-client@2.3.1>qs': 6.15.3 - # Jest's istanbul reporting chain (@istanbuljs/load-nyc-config, ts-jest, - # jsdom, webpack-cli) still pulls js-yaml 3.15.1, affected by unbounded CPU - # use on empty merge sources. Select the first patched 3.x release. - js-yaml@<3.15.2: 3.15.2 patchedDependencies: image-size@1.2.1: patches/image-size@1.2.1.patch auditConfig: From 46fa3cafb6e62a68aeff314a734e6764183e16c2 Mon Sep 17 00:00:00 2001 From: Deggen Date: Mon, 14 Sep 2026 14:06:00 -0500 Subject: [PATCH 44/58] fix: avoid eager fetch dependency in broadcaster --- packages/sdk/src/overlay-tools/SHIPBroadcaster.ts | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/packages/sdk/src/overlay-tools/SHIPBroadcaster.ts b/packages/sdk/src/overlay-tools/SHIPBroadcaster.ts index 7179a1c51..0cbb99b83 100644 --- a/packages/sdk/src/overlay-tools/SHIPBroadcaster.ts +++ b/packages/sdk/src/overlay-tools/SHIPBroadcaster.ts @@ -89,8 +89,13 @@ export class HTTPSOverlayBroadcastFacilitator implements OverlayBroadcastFacilit httpClient: typeof fetch allowHTTP: boolean - constructor(httpClient = fetch, allowHTTP: boolean = false) { - this.httpClient = httpClient + constructor(httpClient?: typeof fetch, allowHTTP: boolean = false) { + const defaultClient = + typeof globalThis !== 'undefined' && typeof globalThis.fetch === 'function' + ? globalThis.fetch.bind(globalThis) + : undefined + if (!httpClient && !defaultClient) throw new Error('Fetch API is unavailable') + this.httpClient = httpClient ?? defaultClient! this.allowHTTP = allowHTTP } From 7c0542d2036cd866844ed6a30b2930e2b5ce7004 Mon Sep 17 00:00:00 2001 From: Deggen Date: Mon, 14 Sep 2026 14:07:09 -0500 Subject: [PATCH 45/58] fix: defer unavailable fetch failure until send --- packages/sdk/src/overlay-tools/SHIPBroadcaster.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/packages/sdk/src/overlay-tools/SHIPBroadcaster.ts b/packages/sdk/src/overlay-tools/SHIPBroadcaster.ts index 0cbb99b83..839dc658d 100644 --- a/packages/sdk/src/overlay-tools/SHIPBroadcaster.ts +++ b/packages/sdk/src/overlay-tools/SHIPBroadcaster.ts @@ -93,9 +93,10 @@ export class HTTPSOverlayBroadcastFacilitator implements OverlayBroadcastFacilit const defaultClient = typeof globalThis !== 'undefined' && typeof globalThis.fetch === 'function' ? globalThis.fetch.bind(globalThis) - : undefined - if (!httpClient && !defaultClient) throw new Error('Fetch API is unavailable') - this.httpClient = httpClient ?? defaultClient! + : (async () => { + throw new Error('Fetch API is unavailable') + }) as typeof fetch + this.httpClient = httpClient ?? defaultClient this.allowHTTP = allowHTTP } From 07ea459955e9f1f9bfe55400bb185b90476df70e Mon Sep 17 00:00:00 2001 From: Deggen Date: Fri, 18 Sep 2026 15:13:28 -0500 Subject: [PATCH 46/58] fix(sdk): keep the evidence branch esbuild budget at its measured ceiling The main integration merge raised the SDK esbuild raw ceiling to 600000, a value that belongs to the lookup discovery change (#518). This branch measured 590000; restore it so the diff carries only its own budget. Co-Authored-By: Claude Fable 5.1 --- packages/sdk/browser-budget.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/sdk/browser-budget.json b/packages/sdk/browser-budget.json index f72f855e8..6705fcce5 100644 --- a/packages/sdk/browser-budget.json +++ b/packages/sdk/browser-budget.json @@ -21,7 +21,7 @@ "brotli": 165000 }, "esbuild": { - "raw": 600000, + "raw": 590000, "gzip": 180000, "brotli": 150000 } From 6e748c653189d6f7b7a7667de8988f947911e65c Mon Sep 17 00:00:00 2001 From: Deggen Date: Fri, 18 Sep 2026 15:13:50 -0500 Subject: [PATCH 47/58] fix(sdk): scope lookup discovery docs and budget to this change Restore the eleven ts2md reference pages for modules this change does not touch to the incoming copies; the generator run had imported unrelated drift that already exists on main. Keep the regenerated overlay-tools page. Reinstate this change's own 600000 esbuild raw ceiling after restacking on the corrected evidence branch. Co-Authored-By: Claude Fable 5.1 --- packages/sdk/browser-budget.json | 2 +- packages/sdk/docs/reference/auth.md | 28 +- packages/sdk/docs/reference/compat.md | 43 +- packages/sdk/docs/reference/identity.md | 112 +--- packages/sdk/docs/reference/kvstore.md | 56 +- packages/sdk/docs/reference/messages.md | 2 +- packages/sdk/docs/reference/primitives.md | 666 +++++++++------------- packages/sdk/docs/reference/registry.md | 3 +- packages/sdk/docs/reference/remittance.md | 10 + packages/sdk/docs/reference/script.md | 252 +++----- packages/sdk/docs/reference/storage.md | 4 +- packages/sdk/docs/reference/wallet.md | 457 +++++---------- 12 files changed, 560 insertions(+), 1075 deletions(-) diff --git a/packages/sdk/browser-budget.json b/packages/sdk/browser-budget.json index 6705fcce5..f72f855e8 100644 --- a/packages/sdk/browser-budget.json +++ b/packages/sdk/browser-budget.json @@ -21,7 +21,7 @@ "brotli": 165000 }, "esbuild": { - "raw": 590000, + "raw": 600000, "gzip": 180000, "brotli": 150000 } diff --git a/packages/sdk/docs/reference/auth.md b/packages/sdk/docs/reference/auth.md index ad1db90be..9e351ee89 100644 --- a/packages/sdk/docs/reference/auth.md +++ b/packages/sdk/docs/reference/auth.md @@ -67,31 +67,9 @@ export interface PeerSession { lastUpdate: number; certificatesRequired?: boolean; certificatesValidated?: boolean; - certificatePolicy?: RequestedCertificateSet; - pendingCertificateRequests?: Record; } ``` -See also: [RequestedCertificateSet](./auth.md#interface-requestedcertificateset) - -#### Property certificatePolicy - -Local handshake policy snapshot. Session stores must retain this field; never sent on the wire. - -```ts -certificatePolicy?: RequestedCertificateSet -``` -See also: [RequestedCertificateSet](./auth.md#interface-requestedcertificateset) - -#### Property pendingCertificateRequests - -Locally issued standalone requests, keyed by their nonce. Not a wire correlation field. - -```ts -pendingCertificateRequests?: Record -``` -See also: [RequestedCertificateSet](./auth.md#interface-requestedcertificateset) - Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes), [Functions](#functions), [Types](#types), [Enums](#enums), [Variables](#variables) --- @@ -833,11 +811,7 @@ Argument Details #### Method listenForCertificatesReceived -Registers an observer for certificates received from peers, not an acceptance hook. -Local certificate validation is committed and its waiters are released before observers -run. Throwing rejects message handling and stops subsequent observers; it does not -roll back validation or revoke the session. Apply acceptance policy through the locally -requested certificate set and explicit application authorization before protected work. +Registers a callback to listen for certificates received from peers. ```ts listenForCertificatesReceived(callback: (senderPublicKey: string, certs: VerifiableCertificate[]) => void | Promise): number diff --git a/packages/sdk/docs/reference/compat.md b/packages/sdk/docs/reference/compat.md index 0cb8231b1..da7a70b2c 100644 --- a/packages/sdk/docs/reference/compat.md +++ b/packages/sdk/docs/reference/compat.md @@ -470,7 +470,6 @@ export default class Mnemonic { public toString(): string public toSeed(passphrase?: string): number[] public entropy2Mnemonic(buf: number[]): this - public toEntropy(): number[] public check(): boolean public mnemonic2Seed(passphrase = ""): this public isValid(passphrase = ""): boolean @@ -652,11 +651,6 @@ Argument Details + **mnemonic** + The mnemonic phrase as a string. -Throws - -If the mnemonic does not pass BIP-39 validation -(unknown words, invalid length, or bad checksum). - #### Method fromString Static method to create a Mnemonic instance from a mnemonic string. @@ -748,22 +742,6 @@ Returns The binary representation of the mnemonic and seed. -#### Method toEntropy - -Recovers the original entropy bytes from the instance's mnemonic phrase. - -```ts -public toEntropy(): number[] -``` - -Returns - -The entropy buffer that was originally used to generate the mnemonic. - -Throws - -If the mnemonic is invalid or contains unknown words. - #### Method toSeed Converts the mnemonic to a seed. @@ -859,9 +837,20 @@ Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes), [Functions]( ### Variable: magicHash ```ts -magicHash = (messageBuf: number[]): number[] => computeMagicHash(messageBuf) +magicHash = (messageBuf: number[]): number[] => { + const bw = new Writer(); + bw.writeVarIntNum(prefix.length); + bw.write(toArray(prefix, "utf8")); + bw.writeVarIntNum(messageBuf.length); + bw.write(messageBuf); + const buf = bw.toArray(); + const hashBuf = Hash.hash256(buf); + return hashBuf; +} ``` +See also: [Writer](./primitives.md#class-writer), [hash256](./primitives.md#variable-hash256), [toArray](./primitives.md#variable-toarray) + Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes), [Functions](#functions), [Types](#types), [Enums](#enums), [Variables](#variables) --- @@ -869,7 +858,7 @@ Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes), [Functions]( ```ts sign = (message: number[], privateKey: PrivateKey, mode: "raw" | "base64" = "base64"): Signature | string => { - const hashBuf = computeMagicHash(message); + const hashBuf = magicHash(message); const sig = ECDSA.sign(new BigNumber(hashBuf), privateKey, true); if (mode === "raw") { return sig; @@ -880,7 +869,7 @@ sign = (message: number[], privateKey: PrivateKey, mode: "raw" | "base64" = "bas } ``` -See also: [BigNumber](./primitives.md#class-bignumber), [PrivateKey](./primitives.md#class-privatekey), [Signature](./primitives.md#class-signature) +See also: [BigNumber](./primitives.md#class-bignumber), [PrivateKey](./primitives.md#class-privatekey), [Signature](./primitives.md#class-signature), [magicHash](./compat.md#variable-magichash) Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes), [Functions](#functions), [Types](#types), [Enums](#enums), [Variables](#variables) @@ -889,12 +878,12 @@ Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes), [Functions]( ```ts verify = (message: number[], sig: Signature, pubKey: PublicKey): boolean => { - const hashBuf = computeMagicHash(message); + const hashBuf = magicHash(message); return ECDSA.verify(new BigNumber(hashBuf), sig, pubKey); } ``` -See also: [BigNumber](./primitives.md#class-bignumber), [PublicKey](./primitives.md#class-publickey), [Signature](./primitives.md#class-signature) +See also: [BigNumber](./primitives.md#class-bignumber), [PublicKey](./primitives.md#class-publickey), [Signature](./primitives.md#class-signature), [magicHash](./compat.md#variable-magichash) Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes), [Functions](#functions), [Types](#types), [Enums](#enums), [Variables](#variables) diff --git a/packages/sdk/docs/reference/identity.md b/packages/sdk/docs/reference/identity.md index 3550c83ad..0f1a4fffa 100644 --- a/packages/sdk/docs/reference/identity.md +++ b/packages/sdk/docs/reference/identity.md @@ -8,8 +8,6 @@ Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes), [Functions]( | --- | | [DisplayableIdentity](#interface-displayableidentity) | | [IdentityClientOptions](#interface-identityclientoptions) | -| [ResolveByAttributesOptions](#interface-resolvebyattributesoptions) | -| [ResolveByIdentityKeyOptions](#interface-resolvebyidentitykeyoptions) | Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes), [Functions](#functions), [Types](#types), [Enums](#enums), [Variables](#variables) @@ -40,59 +38,11 @@ export interface IdentityClientOptions { keyID: string; tokenAmount: number; outputIndex: number; - networkPreset?: LookupNetworkPreset; + networkPreset?: "mainnet" | "testnet" | "teratestnet" | "local"; } ``` -See also: [LookupNetworkPreset](./overlay-tools.md#type-lookupnetworkpreset), [WalletProtocol](./wallet.md#type-walletprotocol) - -#### Property networkPreset - -Override wallet-reported testnet routing for overlays such as TerraTestNet. - -```ts -networkPreset?: LookupNetworkPreset -``` -See also: [LookupNetworkPreset](./overlay-tools.md#type-lookupnetworkpreset) - -Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes), [Functions](#functions), [Types](#types), [Enums](#enums), [Variables](#variables) - ---- -### Interface: ResolveByAttributesOptions - -```ts -export interface ResolveByAttributesOptions { - useContacts?: boolean; - overrideWithContacts?: boolean; - parallel?: boolean; -} -``` - -Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes), [Functions](#functions), [Types](#types), [Enums](#enums), [Variables](#variables) - ---- -### Interface: ResolveByIdentityKeyOptions - -```ts -export interface ResolveByIdentityKeyOptions { - useContacts?: boolean; - overrideWithContacts?: boolean; - parallel?: boolean; -} -``` - -#### Property useContacts - -Opt-in to consulting personal contacts before/alongside the overlay. Default `false`. - -Most callers (including any client without a populated contacts basket) pay no benefit -from the contacts path and incur its setup cost. Set `true` only in UI contexts where -the user has likely saved contacts and a local cache hit is preferable to a fresh overlay -answer. - -```ts -useContacts?: boolean -``` +See also: [WalletProtocol](./wallet.md#type-walletprotocol) Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes), [Functions](#functions), [Types](#types), [Enums](#enums), [Variables](#variables) @@ -123,18 +73,17 @@ See also: [Contact](./identity.md#type-contact), [DisplayableIdentity](./identit #### Method getContacts -Load all records from the contacts basket. - -Concurrent calls share a single in-flight load (no thundering herd). After -the basket has been observed empty once, subsequent calls return `[]` -synchronously without hitting the wallet — until `forceRefresh` is passed -or a contact is saved/removed. +Load all records from the contacts basket ```ts async getContacts(identityKey?: PubKeyHex, forceRefresh = false, limit = 1000): Promise ``` See also: [Contact](./identity.md#type-contact), [PubKeyHex](./wallet.md#type-pubkeyhex) +Returns + +A promise that resolves with an array of contacts + Argument Details + **identityKey** @@ -182,21 +131,19 @@ IdentityClient lets you discover who others are, and let the world know who you ```ts export class IdentityClient { - constructor(wallet?: WalletInterface, options: Partial = {}, private readonly originator?: OriginatorDomainNameStringUnder250Bytes) + constructor(wallet?: WalletInterface, options?: Partial, private readonly originator?: OriginatorDomainNameStringUnder250Bytes) async publiclyRevealAttributes(certificate: WalletCertificate, fieldsToReveal: CertificateFieldNameUnder50Bytes[]): Promise - async resolveByIdentityKey(args: DiscoverByIdentityKeyArgs, opts: boolean | ResolveByIdentityKeyOptions = false): Promise - async resolveByAttributes(args: DiscoverByAttributesArgs, opts: boolean | ResolveByAttributesOptions = false): Promise + async resolveByIdentityKey(args: DiscoverByIdentityKeyArgs, overrideWithContacts = true): Promise + async resolveByAttributes(args: DiscoverByAttributesArgs, overrideWithContacts = true): Promise async revokeCertificateRevelation(serialNumber: Base64String): Promise public async getContacts(identityKey?: PubKeyHex, forceRefresh = false, limit = 1000): Promise public async saveContact(contact: DisplayableIdentity, metadata?: Record): Promise public async removeContact(identityKey: PubKeyHex): Promise - static async parseIdentities(certs: IdentityCertificate[]): Promise - static async parseIdentitiesWithOverrides(certs: IdentityCertificate[], contactByKey: Map): Promise static parseIdentity(identityToParse: IdentityCertificate): DisplayableIdentity } ``` -See also: [Base64String](./wallet.md#type-base64string), [BroadcastFailure](./transaction.md#interface-broadcastfailure), [BroadcastResponse](./transaction.md#interface-broadcastresponse), [CertificateFieldNameUnder50Bytes](./wallet.md#type-certificatefieldnameunder50bytes), [Contact](./identity.md#type-contact), [DiscoverByAttributesArgs](./wallet.md#interface-discoverbyattributesargs), [DiscoverByIdentityKeyArgs](./wallet.md#interface-discoverbyidentitykeyargs), [DisplayableIdentity](./identity.md#interface-displayableidentity), [IdentityCertificate](./wallet.md#interface-identitycertificate), [IdentityClientOptions](./identity.md#interface-identityclientoptions), [OriginatorDomainNameStringUnder250Bytes](./wallet.md#type-originatordomainnamestringunder250bytes), [PubKeyHex](./wallet.md#type-pubkeyhex), [ResolveByAttributesOptions](./identity.md#interface-resolvebyattributesoptions), [ResolveByIdentityKeyOptions](./identity.md#interface-resolvebyidentitykeyoptions), [WalletCertificate](./wallet.md#interface-walletcertificate), [WalletInterface](./wallet.md#interface-walletinterface) +See also: [Base64String](./wallet.md#type-base64string), [BroadcastFailure](./transaction.md#interface-broadcastfailure), [BroadcastResponse](./transaction.md#interface-broadcastresponse), [CertificateFieldNameUnder50Bytes](./wallet.md#type-certificatefieldnameunder50bytes), [Contact](./identity.md#type-contact), [DEFAULT_IDENTITY_CLIENT_OPTIONS](./identity.md#variable-default_identity_client_options), [DiscoverByAttributesArgs](./wallet.md#interface-discoverbyattributesargs), [DiscoverByIdentityKeyArgs](./wallet.md#interface-discoverbyidentitykeyargs), [DisplayableIdentity](./identity.md#interface-displayableidentity), [IdentityCertificate](./wallet.md#interface-identitycertificate), [OriginatorDomainNameStringUnder250Bytes](./wallet.md#type-originatordomainnamestringunder250bytes), [PubKeyHex](./wallet.md#type-pubkeyhex), [WalletCertificate](./wallet.md#interface-walletcertificate), [WalletInterface](./wallet.md#interface-walletinterface) #### Method getContacts @@ -279,42 +226,43 @@ Argument Details #### Method resolveByAttributes +Resolves displayable identity certificates by specific identity attributes, issued by a trusted entity. + ```ts -async resolveByAttributes(args: DiscoverByAttributesArgs, opts: boolean | ResolveByAttributesOptions = false): Promise +async resolveByAttributes(args: DiscoverByAttributesArgs, overrideWithContacts = true): Promise ``` -See also: [DiscoverByAttributesArgs](./wallet.md#interface-discoverbyattributesargs), [DisplayableIdentity](./identity.md#interface-displayableidentity), [ResolveByAttributesOptions](./identity.md#interface-resolvebyattributesoptions) +See also: [DiscoverByAttributesArgs](./wallet.md#interface-discoverbyattributesargs), [DisplayableIdentity](./identity.md#interface-displayableidentity) + +Returns + +The promise resolves to displayable identities. Argument Details + **args** + Attributes and optional parameters used to discover certificates. -+ **opts** - + Boolean (legacy) or options object. Boolean `true` ≡ `{ useContacts: true }`. ++ **overrideWithContacts** + + Whether to override the results with personal contacts if available. #### Method resolveByIdentityKey -Resolves displayable identity certificates issued to a given identity key. - -**Default behavior (changed): contacts are NOT consulted.** Most clients have no -contacts saved locally, so the previous "contacts-first" default paid setup cost for no -gain. Pass `{ useContacts: true }` to opt in — appropriate when you know the user has -saved contacts and prefers a local hit over a fresh overlay answer. - -When `useContacts: true`: - - Default short-circuits: if a contact matches, the overlay is skipped entirely. - - `{ parallel: true }` fires contacts and overlay in parallel; contact wins on hit. +Resolves displayable identity certificates, issued to a given identity key by a trusted certifier. ```ts -async resolveByIdentityKey(args: DiscoverByIdentityKeyArgs, opts: boolean | ResolveByIdentityKeyOptions = false): Promise +async resolveByIdentityKey(args: DiscoverByIdentityKeyArgs, overrideWithContacts = true): Promise ``` -See also: [DiscoverByIdentityKeyArgs](./wallet.md#interface-discoverbyidentitykeyargs), [DisplayableIdentity](./identity.md#interface-displayableidentity), [ResolveByIdentityKeyOptions](./identity.md#interface-resolvebyidentitykeyoptions) +See also: [DiscoverByIdentityKeyArgs](./wallet.md#interface-discoverbyidentitykeyargs), [DisplayableIdentity](./identity.md#interface-displayableidentity) + +Returns + +The promise resolves to displayable identities. Argument Details + **args** + Arguments for requesting the discovery based on the identity key. -+ **opts** - + Boolean (legacy) or options object. Boolean `true` ≡ `{ useContacts: true }`. ++ **overrideWithContacts** + + Whether to override the results with personal contacts if available. #### Method revokeCertificateRevelation diff --git a/packages/sdk/docs/reference/kvstore.md b/packages/sdk/docs/reference/kvstore.md index 5f3beb69c..6fb6c3cd5 100644 --- a/packages/sdk/docs/reference/kvstore.md +++ b/packages/sdk/docs/reference/kvstore.md @@ -48,10 +48,7 @@ export interface KVStoreConfig { topics?: string[]; originator?: string; wallet?: WalletInterface; - networkPreset?: LookupNetworkPreset; - lookupResolver?: LookupResolver; - hostOverrides?: Record; - slapTrackers?: string[]; + networkPreset?: "mainnet" | "testnet" | "teratestnet" | "local"; acceptDelayedBroadcast?: boolean; overlayBroadcast?: boolean; tokenSetDescription?: string; @@ -60,7 +57,7 @@ export interface KVStoreConfig { } ``` -See also: [LookupNetworkPreset](./overlay-tools.md#type-lookupnetworkpreset), [LookupResolver](./overlay-tools.md#class-lookupresolver), [WalletInterface](./wallet.md#interface-walletinterface), [WalletProtocol](./wallet.md#type-walletprotocol) +See also: [WalletInterface](./wallet.md#interface-walletinterface), [WalletProtocol](./wallet.md#type-walletprotocol) #### Property acceptDelayedBroadcast @@ -70,44 +67,13 @@ Whether to accept delayed broadcast acceptDelayedBroadcast?: boolean ``` -#### Property hostOverrides - -Per-service overlay host overrides (`serviceName -> hosts`), applied when -the store builds its default lookup resolver. This pins which hosts answer -*lookup* queries for a given service (e.g. read lookups via `ls_kvstore`), -instead of discovering them via SLAP. - -Note this does not by itself pin the *broadcast* target: writes are -submitted to the hosts that the `ls_ship` SHIP lookup returns, so an -`ls_ship` override only changes which tracker answers — the broadcast host -is whatever advertisements that lookup names. To force writes to a specific -backend, use a resolver / SHIP setup whose `ls_ship` results return the -desired host. Ignored when `lookupResolver` is supplied. - -```ts -hostOverrides?: Record -``` - -#### Property lookupResolver - -A pre-built lookup resolver to use for all overlay queries — both reads and -write-host (SHIP) discovery. When provided, it takes precedence and -`hostOverrides` / `slapTrackers` are ignored for resolver construction. -Use this to fully control overlay host resolution. - -```ts -lookupResolver?: LookupResolver -``` -See also: [LookupResolver](./overlay-tools.md#class-lookupresolver) - #### Property networkPreset Network preset for overlay services ```ts -networkPreset?: LookupNetworkPreset +networkPreset?: "mainnet" | "testnet" | "teratestnet" | "local" ``` -See also: [LookupNetworkPreset](./overlay-tools.md#type-lookupnetworkpreset) #### Property originator @@ -150,15 +116,6 @@ Service name for overlay submission serviceName?: string ``` -#### Property slapTrackers - -Override the SLAP trackers used by the default lookup resolver. Ignored when -`lookupResolver` is supplied. - -```ts -slapTrackers?: string[] -``` - #### Property tokenAmount Amount of satoshis for each token @@ -292,8 +249,7 @@ Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes), [Functions]( ### Interface: KVStoreQuery Query parameters for KVStore lookups from overlay services. -Must include at least one selector: key, controller, protocolID, or non-empty tags. -Pagination and ordering fields only refine selector-based lookups. +Used when searching for existing key-value pairs in the network. ```ts export interface KVStoreQuery { @@ -658,9 +614,9 @@ Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes), [Functions]( kvStoreInterpreter: InterpreterFunction = async (transaction: Transaction, outputIndex: number, ctx?: KVContext): Promise => { try { const output = transaction.outputs[outputIndex]; - if (output?.lockingScript == null) + if (output == null || output.lockingScript == null) return undefined; - if (ctx?.key == null) + if (ctx == null || ctx.key == null) return undefined; const decoded = PushDrop.decode(output.lockingScript); const expectedFieldCount = Object.keys(kvProtocol).length; diff --git a/packages/sdk/docs/reference/messages.md b/packages/sdk/docs/reference/messages.md index 8db915d13..35eea667b 100644 --- a/packages/sdk/docs/reference/messages.md +++ b/packages/sdk/docs/reference/messages.md @@ -134,7 +134,7 @@ verify = (message: number[], sig: number[], recipient?: PrivateKey): boolean => const verifierRest = reader.read(32); const verifierDER = toHex([verifierFirst, ...verifierRest]); if (typeof recipient !== "object") { - throw new TypeError(`This signature can only be verified with knowledge of a specific private key. The associated public key is: ${verifierDER}`); + throw new Error(`This signature can only be verified with knowledge of a specific private key. The associated public key is: ${verifierDER}`); } const recipientDER = recipient.toPublicKey().encode(true, "hex") as string; if (verifierDER !== recipientDER) { diff --git a/packages/sdk/docs/reference/primitives.md b/packages/sdk/docs/reference/primitives.md index f851db4a0..02566f574 100644 --- a/packages/sdk/docs/reference/primitives.md +++ b/packages/sdk/docs/reference/primitives.md @@ -6,8 +6,6 @@ Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes), [Functions]( | | | --- | -| [AsyncCryptoBackend](#interface-asynccryptobackend) | -| [DigestVerification](#interface-digestverification) | | [JacobianPointBI](#interface-jacobianpointbi) | | [SignatureHashCache](#interface-signaturehashcache) | @@ -15,47 +13,6 @@ Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes), [Functions]( --- -### Interface: AsyncCryptoBackend - -Optional high-performance implementation of generic secp256k1 primitives. - -Implementations must treat a returned result as authoritative. The SDK only -falls back before selecting a backend: when it is absent, cold, or does not -advertise the requested operation. - -```ts -export interface AsyncCryptoBackend { - preload: () => Promise; - isReady: () => boolean; - supportsCrypto: (operation: AsyncCryptoOperation) => boolean; - signDigest: (privateKey: Uint8Array, digest: Uint8Array) => Promise; - verifyDigest: (publicKey: Uint8Array, digest: Uint8Array, signature: Uint8Array) => Promise; - verifyDigestBatch: (items: readonly DigestVerification[]) => Promise; - publicKeyFromPrivate: (privateKey: Uint8Array) => Promise; - multiplyPublicKey: (publicKey: Uint8Array, scalar: Uint8Array) => Promise; - tweakPublicKeyAdd: (publicKey: Uint8Array, tweak: Uint8Array) => Promise; - tweakPrivateKeyAdd: (privateKey: Uint8Array, tweak: Uint8Array) => Promise; -} -``` - -See also: [AsyncCryptoOperation](./primitives.md#type-asynccryptooperation), [DigestVerification](./primitives.md#interface-digestverification) - -Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes), [Functions](#functions), [Types](#types), [Enums](#enums), [Variables](#variables) - ---- -### Interface: DigestVerification - -```ts -export interface DigestVerification { - publicKey: Uint8Array; - digest: Uint8Array; - signature: Uint8Array; -} -``` - -Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes), [Functions](#functions), [Types](#types), [Enums](#enums), [Variables](#variables) - ---- ### Interface: JacobianPointBI ```ts @@ -71,12 +28,6 @@ Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes), [Functions]( --- ### Interface: SignatureHashCache -Reusable BIP143 hash components for one immutable transaction context. - -Callers sharing a cache across inputs must not mutate transaction prevouts, -sequences, outputs, or other signed fields until that signing or verification -pass is complete. Create a fresh cache for a changed transaction context. - ```ts export interface SignatureHashCache { hashPrevouts?: number[]; @@ -150,7 +101,7 @@ export default class BigNumber { static readonly groupSizes: number[] static readonly groupBases: number[] static readonly wordSize: number = 26; - public red: ReductionContext | null = null; + public red: ReductionContext | null; public get negative(): number public set negative(val: number) public get words(): number[] @@ -165,23 +116,27 @@ export default class BigNumber { clone(): BigNumber expand(size: number): this strip(): this - normSign(): this + normSign(): this { if (this._magnitude === 0n) + this._sign = 0; return this; } inspect(): string toString(base: number | "hex" = 10, padding: number = 1): string toNumber(): number toBigInt(): bigint toJSON(): string toArray(endian: "le" | "be" = "be", length?: number): number[] - bitLength(): number + bitLength(): number { if (this._magnitude === 0n) + return 0; return this._magnitude.toString(2).length; } static toBitArray(num: BigNumber): Array<0 | 1> toBitArray(): Array<0 | 1> zeroBits(): number - byteLength(): number + byteLength(): number { if (this._magnitude === 0n) + return 0; return Math.ceil(this.bitLength() / 8); } toTwos(width: number): BigNumber fromTwos(width: number): BigNumber isNeg(): boolean neg(): BigNumber - ineg(): this + ineg(): this { if (this._magnitude !== 0n) + this._sign = this._sign === 1 ? 0 : 1; return this; } iuor(num: BigNumber): this iuand(num: BigNumber): this iuxor(num: BigNumber): this @@ -196,7 +151,10 @@ export default class BigNumber { uxor(num: BigNumber): BigNumber inotn(width: number): this notn(width: number): BigNumber - setn(bit: number, val: any): this + 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 add(num: BigNumber): BigNumber isub(num: BigNumber): this @@ -246,9 +204,15 @@ export default class BigNumber { andln(num: number): number bincn(bit: number): this isZero(): boolean - cmpn(num: number): CompareResult - cmp(num: BigNumber): CompareResult - ucmp(num: BigNumber): CompareResult + cmpn(num: number): 1 | 0 | -1 { 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): 1 | 0 | -1 { const tV = this._getSignedValue(); const nV = num._getSignedValue(); if (tV < nV) + return -1; if (tV > nV) + return 1; return 0; } + ucmp(num: BigNumber): 1 | 0 | -1 { if (this._magnitude < num._magnitude) + return -1; if (this._magnitude > num._magnitude) + return 1; return 0; } gtn(num: number): boolean gt(num: BigNumber): boolean gten(num: number): boolean @@ -313,7 +277,7 @@ Argument Details Reduction context of the big number. ```ts -public red: ReductionContext | null = null +public red: ReductionContext | null ``` See also: [ReductionContext](./primitives.md#class-reductioncontext) @@ -362,7 +326,8 @@ Argument Details Calculates the number of bits required to represent the BigNumber. ```ts -bitLength(): number +bitLength(): number { if (this._magnitude === 0n) + return 0; return this._magnitude.toString(2).length; } ``` Returns @@ -374,7 +339,8 @@ The bit length of the BigNumber. Calculates the number of bytes required to represent the BigNumber. ```ts -byteLength(): number +byteLength(): number { if (this._magnitude === 0n) + return 0; return Math.ceil(this.bitLength() / 8); } ``` Returns @@ -799,19 +765,19 @@ Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes), [Functions]( ```ts export default class Curve { - p!: BigNumber; - red!: ReductionContext; - redN!: BigNumber | null; - zero!: BigNumber; - one!: BigNumber; - two!: BigNumber; - g!: Point; - n!: BigNumber; - a!: BigNumber; - b!: BigNumber; - tinv!: BigNumber; - zeroA!: boolean; - threeA!: boolean; + p: BigNumber; + red: ReductionContext; + redN: BigNumber | null; + zero: BigNumber; + one: BigNumber; + two: BigNumber; + g: Point; + n: BigNumber; + a: BigNumber; + b: BigNumber; + tinv: BigNumber; + zeroA: boolean; + threeA: boolean; endo: { beta: BigNumber; lambda: BigNumber; @@ -820,21 +786,21 @@ export default class Curve { b: BigNumber; }>; } | undefined; - _endoWnafT1!: BigNumber[]; - _endoWnafT2!: BigNumber[]; - _wnafT1!: BigNumber[]; - _wnafT2!: BigNumber[]; - _wnafT3!: BigNumber[]; - _wnafT4!: BigNumber[]; - _bitLength!: number; + _endoWnafT1: BigNumber[]; + _endoWnafT2: BigNumber[]; + _wnafT1: BigNumber[]; + _wnafT2: BigNumber[]; + _wnafT3: BigNumber[]; + _wnafT4: BigNumber[]; + _bitLength: number; static assert(expression: unknown, message: string = "Elliptic curve assertion failed"): void getNAF(num: BigNumber, w: number, bits: number): number[] getJSF(k1: BigNumber, k2: BigNumber): number[][] - static cachedProperty(obj: any, name: string, computer: (this: any) => unknown): void + static cachedProperty(obj, name: string, computer): void static parseBytes(bytes: string | number[]): number[] static intFromLE(bytes: number[]): BigNumber constructor() - _getEndomorphism(conf: EndomorphismConfig): { + _getEndomorphism(conf): { beta: BigNumber; lambda: BigNumber; basis: Array<{ @@ -991,7 +957,7 @@ export default class JacobianPoint extends BasePoint { y: BigNumber; z: BigNumber; zOne: boolean; - constructor(x: JacobianCoord, y: JacobianCoord, z: JacobianCoord) + constructor(x: string | BigNumber | null, y: string | BigNumber | null, z: string | BigNumber | null) toP(): Point neg(): JacobianPoint add(p: JacobianPoint): JacobianPoint @@ -1012,8 +978,9 @@ See also: [BasePoint](./primitives.md#class-basepoint), [BigNumber](./primitives Constructs a new `JacobianPoint` instance. ```ts -constructor(x: JacobianCoord, y: JacobianCoord, z: JacobianCoord) +constructor(x: string | BigNumber | null, y: string | BigNumber | null, z: string | BigNumber | null) ``` +See also: [BigNumber](./primitives.md#class-bignumber) Argument Details @@ -1321,8 +1288,8 @@ const k256 = new K256(); ```ts export default class K256 extends Mersenne { constructor() - override split(input: BigNumber, output: BigNumber): void - override imulK(num: BigNumber): BigNumber + split(input: BigNumber, output: BigNumber): void + imulK(num: BigNumber): BigNumber } ``` @@ -1349,7 +1316,7 @@ Multiplies a BigNumber ('num') with the constant 'K' in-place and returns the re 'K' is equal to 0x1000003d1 or in decimal representation: [ 64, 977 ]. ```ts -override imulK(num: BigNumber): BigNumber +imulK(num: BigNumber): BigNumber ``` See also: [BigNumber](./primitives.md#class-bignumber) @@ -1375,7 +1342,7 @@ Splits a BigNumber into a new BigNumber based on specific computation rules. This method modifies the input and output big numbers. ```ts -override split(input: BigNumber, output: BigNumber): void +split(input: BigNumber, output: BigNumber): void ``` See also: [BigNumber](./primitives.md#class-bignumber) @@ -1588,11 +1555,11 @@ export default class MontgomoryMethod extends ReductionContext { rinv: BigNumber; minv: BigNumber; constructor(m: BigNumber | "k256") - override convertTo(num: BigNumber): BigNumber - override convertFrom(num: BigNumber): BigNumber - override imul(a: BigNumber, b: BigNumber): BigNumber - override mul(a: BigNumber, b: BigNumber): BigNumber - override invm(a: BigNumber): BigNumber + convertTo(num: BigNumber): BigNumber + convertFrom(num: BigNumber): BigNumber + imul(a: BigNumber, b: BigNumber): BigNumber + mul(a: BigNumber, b: BigNumber): BigNumber + invm(a: BigNumber): BigNumber } ``` @@ -1659,7 +1626,7 @@ shift: number Converts a number from the Montgomery domain back to the original domain. ```ts -override convertFrom(num: BigNumber): BigNumber +convertFrom(num: BigNumber): BigNumber ``` See also: [BigNumber](./primitives.md#class-bignumber) @@ -1684,7 +1651,7 @@ const convertedNum = montMethod.convertFrom(num); Converts a number into the Montgomery domain. ```ts -override convertTo(num: BigNumber): BigNumber +convertTo(num: BigNumber): BigNumber ``` See also: [BigNumber](./primitives.md#class-bignumber) @@ -1709,7 +1676,7 @@ const convertedNum = montMethod.convertTo(num); Performs an in-place multiplication of two numbers in the Montgomery domain. ```ts -override imul(a: BigNumber, b: BigNumber): BigNumber +imul(a: BigNumber, b: BigNumber): BigNumber ``` See also: [BigNumber](./primitives.md#class-bignumber) @@ -1736,7 +1703,7 @@ const product = montMethod.imul(a, b); Calculates the modular multiplicative inverse of a number in the Montgomery domain. ```ts -override invm(a: BigNumber): BigNumber +invm(a: BigNumber): BigNumber ``` See also: [BigNumber](./primitives.md#class-bignumber) @@ -1761,7 +1728,7 @@ const inverse = montMethod.invm(a); Performs the multiplication of two numbers in the Montgomery domain. ```ts -override mul(a: BigNumber, b: BigNumber): BigNumber +mul(a: BigNumber, b: BigNumber): BigNumber ``` See also: [BigNumber](./primitives.md#class-bignumber) @@ -1800,12 +1767,12 @@ export default class Point extends BasePoint { static _assertOnCurve(p: Point): Point static fromDER(bytes: number[]): Point static fromString(str: string): Point - static fromX(x: PointInput, odd: boolean): Point + static fromX(x: BigNumber | number | number[] | string, odd: boolean): Point static fromJSON(obj: string | any[], isRed: boolean): Point - constructor(x: PointInput | null, y: PointInput | null, isRed: boolean = true) + constructor(x: BigNumber | number | number[] | string | null, y: BigNumber | number | number[] | string | null, isRed: boolean = true) validate(): boolean encode(compact: boolean = true, enc?: "hex"): number[] | string - override toString(): string + toString(): string toJSON(): [ BigNumber | null, BigNumber | null, @@ -1826,8 +1793,8 @@ export default class Point extends BasePoint { dbl(): Point getX(): BigNumber getY(): BigNumber - mul(k: PointInput): Point - mulCT(k: PointInput): Point + mul(k: BigNumber | number | number[] | string): Point + mulCT(k: BigNumber | number | number[] | string): Point mulAdd(k1: BigNumber, p2: Point, k2: BigNumber): Point jmulAdd(k1: BigNumber, p2: Point, k2: BigNumber): JPoint eq(p: Point): boolean @@ -1842,8 +1809,9 @@ See also: [BasePoint](./primitives.md#class-basepoint), [BigNumber](./primitives #### Constructor ```ts -constructor(x: PointInput | null, y: PointInput | null, isRed: boolean = true) +constructor(x: BigNumber | number | number[] | string | null, y: BigNumber | number | number[] | string | null, isRed: boolean = true) ``` +See also: [BigNumber](./primitives.md#class-bignumber) Argument Details @@ -2116,9 +2084,9 @@ Generates a point from an x coordinate and a boolean indicating whether the corr y coordinate is odd. ```ts -static fromX(x: PointInput, odd: boolean): Point +static fromX(x: BigNumber | number | number[] | string, odd: boolean): Point ``` -See also: [Point](./primitives.md#class-point) +See also: [BigNumber](./primitives.md#class-bignumber), [Point](./primitives.md#class-point) Returns @@ -2248,9 +2216,9 @@ const result = p1.jmulAdd(2, p2, 3); Multiplies this Point by a scalar value, returning a new Point. ```ts -mul(k: PointInput): Point +mul(k: BigNumber | number | number[] | string): Point ``` -See also: [Point](./primitives.md#class-point) +See also: [BigNumber](./primitives.md#class-bignumber), [Point](./primitives.md#class-point) Returns @@ -2376,7 +2344,7 @@ Converts the point coordinates to a hexadecimal string. A wrapper method for encode. Byte 0x02 or 0x03 is used as prefix based on the 'y' coordinate being even or odd respectively. ```ts -override toString(): string +toString(): string ``` Returns @@ -2478,8 +2446,8 @@ create a corresponding public key and derive a shared secret from a public key. ```ts export default class PrivateKey extends BigNumber { static fromRandom(): PrivateKey - static override fromString(str: string, base: number | "hex" = "hex"): PrivateKey - static override fromHex(str: string): PrivateKey + static fromString(str: string, base: number | "hex" = "hex"): PrivateKey + static fromHex(str: string): PrivateKey static fromWif(wif: string, prefixLength: number = 1): PrivateKey constructor(number: BigNumber | number | string | number[] = 0, base: number | "be" | "le" | "hex" = 10, endian: "be" | "le" = "be", modN: "apply" | "nocheck" | "error" = "apply") checkInField(): { @@ -2492,10 +2460,10 @@ export default class PrivateKey extends BigNumber { toPublicKey(): PublicKey toWif(prefix: number[] = [128]): string toAddress(prefix: number[] | string = [0]): string - override toHex(): string - override toString(base: number | "hex" = "hex", padding: number = 64): string + toHex(): string + toString(base: number | "hex" = "hex", padding: number = 64): string deriveSharedSecret(key: PublicKey): Point - deriveChild(publicKey: PublicKey, invoiceNumber: string, cacheSharedSecret?: (priv: PrivateKey, pub: Point, point: Point) => void, retrieveCachedSharedSecret?: (priv: PrivateKey, pub: Point) => Point | undefined): PrivateKey + deriveChild(publicKey: PublicKey, invoiceNumber: string, cacheSharedSecret?: ((priv: PrivateKey, pub: Point, point: Point) => void), retrieveCachedSharedSecret?: ((priv: PrivateKey, pub: Point) => (Point | undefined))): PrivateKey toKeyShares(threshold: number, totalShares: number): KeyShares toBackupShares(threshold: number, totalShares: number): string[] static fromBackupShares(shares: string[]): PrivateKey @@ -2578,7 +2546,7 @@ peer authentication will require a versioned, breaking change. Derives a child key with BRC-42. ```ts -deriveChild(publicKey: PublicKey, invoiceNumber: string, cacheSharedSecret?: (priv: PrivateKey, pub: Point, point: Point) => void, retrieveCachedSharedSecret?: (priv: PrivateKey, pub: Point) => Point | undefined): PrivateKey +deriveChild(publicKey: PublicKey, invoiceNumber: string, cacheSharedSecret?: ((priv: PrivateKey, pub: Point, point: Point) => void), retrieveCachedSharedSecret?: ((priv: PrivateKey, pub: Point) => (Point | undefined))): PrivateKey ``` See also: [Point](./primitives.md#class-point), [PrivateKey](./primitives.md#class-privatekey), [PublicKey](./primitives.md#class-publickey) @@ -2652,7 +2620,7 @@ const recoveredKey = PrivateKey.fromBackupShares([share1, share2]) Generates a private key from a hexadecimal string. ```ts -static override fromHex(str: string): PrivateKey +static fromHex(str: string): PrivateKey ``` See also: [PrivateKey](./primitives.md#class-privatekey) @@ -2713,7 +2681,7 @@ const privateKey = PrivateKey.fromRandom(); Generates a private key from a string. ```ts -static override fromString(str: string, base: number | "hex" = "hex"): PrivateKey +static fromString(str: string, base: number | "hex" = "hex"): PrivateKey ``` See also: [PrivateKey](./primitives.md#class-privatekey) @@ -2842,7 +2810,7 @@ Argument Details Converts this PrivateKey to a hexadecimal string. ```ts -override toHex(): string +toHex(): string ``` Returns @@ -2919,7 +2887,7 @@ function toString() { [native code] } Converts this PrivateKey to a string representation. ```ts -override toString(base: number | "hex" = "hex", padding: number = 64): string +toString(base: number | "hex" = "hex", padding: number = 64): string ``` Returns @@ -3006,15 +2974,15 @@ The class comes with static methods to generate PublicKey instances from private ```ts export default class PublicKey extends Point { static fromPrivateKey(key: PrivateKey): PublicKey - static override fromString(str: string): PublicKey - static override fromDER(bytes: number[]): PublicKey + static fromString(str: string): PublicKey + static fromDER(bytes: number[]): PublicKey constructor(x: Point | BigNumber | number | number[] | string | null, y: BigNumber | number | number[] | string | null = null, isRed: boolean = true) deriveSharedSecret(priv: PrivateKey): Point verify(msg: number[] | string, sig: Signature, enc?: "hex" | "utf8"): boolean toDER(enc?: "hex" | undefined): number[] | string toHash(enc?: "hex"): number[] | string toAddress(prefix: number[] | string = [0]): string - deriveChild(privateKey: PrivateKey, invoiceNumber: string, cacheSharedSecret?: (priv: PrivateKey, pub: Point, point: Point) => void, retrieveCachedSharedSecret?: (priv: PrivateKey, pub: Point) => Point | undefined): PublicKey + deriveChild(privateKey: PrivateKey, invoiceNumber: string, cacheSharedSecret?: ((priv: PrivateKey, pub: Point, point: Point) => void), retrieveCachedSharedSecret?: ((priv: PrivateKey, pub: Point) => (Point | undefined))): PublicKey static fromMsgHashAndCompactSignature(msgHash: BigNumber, signature: number[] | string, enc?: "hex" | "base64"): PublicKey } ``` @@ -3049,7 +3017,7 @@ new PublicKey('abc123', 'def456'); Derives a child key with BRC-42. ```ts -deriveChild(privateKey: PrivateKey, invoiceNumber: string, cacheSharedSecret?: (priv: PrivateKey, pub: Point, point: Point) => void, retrieveCachedSharedSecret?: (priv: PrivateKey, pub: Point) => Point | undefined): PublicKey +deriveChild(privateKey: PrivateKey, invoiceNumber: string, cacheSharedSecret?: ((priv: PrivateKey, pub: Point, point: Point) => void), retrieveCachedSharedSecret?: ((priv: PrivateKey, pub: Point) => (Point | undefined))): PublicKey ``` See also: [Point](./primitives.md#class-point), [PrivateKey](./primitives.md#class-privatekey), [PublicKey](./primitives.md#class-publickey) @@ -3103,7 +3071,7 @@ const sharedSecret = myPubKey.deriveSharedSecret(myPrivKey) Static factory method to create a PublicKey instance from a number array. ```ts -static override fromDER(bytes: number[]): PublicKey +static fromDER(bytes: number[]): PublicKey ``` See also: [PublicKey](./primitives.md#class-publickey) @@ -3186,7 +3154,7 @@ const myPubKey = PublicKey.fromPrivateKey(myPrivKey) Static factory method to create a PublicKey instance from a string. ```ts -static override fromString(str: string): PublicKey +static fromString(str: string): PublicKey ``` See also: [PublicKey](./primitives.md#class-publickey) @@ -3386,9 +3354,6 @@ export class ReaderUint8Array { constructor(bin: Uint8Array | number[] = new Uint8Array(0), pos: number = 0) public eof(): boolean public read(len = this.length): Uint8Array - public readView(len = this.length - this.pos): Uint8Array - public skip(len: number): void - public remaining(): number public readReverse(len = this.length): Uint8Array public readUInt8(): number public readInt8(): number @@ -3411,14 +3376,6 @@ export class ReaderUint8Array { See also: [BigNumber](./primitives.md#class-bignumber), [Reader](./primitives.md#class-reader) -#### Method skip - -Advances without allocating. - -```ts -public skip(len: number): void -``` - Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes), [Functions](#functions), [Types](#types), [Enums](#enums), [Variables](#variables) --- @@ -4040,7 +3997,7 @@ export class SHA1HMAC { outer: SHA1; blockSize = 64; constructor(key: number[] | string) - update(msg: number[] | string, enc?: "hex"): this + update(msg: number[] | string, enc?: "hex"): SHA1HMAC digest(): number[] digestHex(): string } @@ -4067,7 +4024,7 @@ const sha256 = new SHA256(); ```ts export class SHA256 { constructor() - update(msg: HashInput, enc?: "hex" | "utf8"): this + update(msg: Uint8Array | number[] | string, enc?: "hex" | "utf8"): this digest(): number[] digestHex(): string } @@ -4088,8 +4045,8 @@ This class also uses the SHA-256 cryptographic hash algorithm that produces a 25 export class SHA256HMAC { blockSize = 64; outSize = 32; - constructor(key: HashInput) - update(msg: HashInput, enc?: "hex"): this + constructor(key: Uint8Array | number[] | string) + update(msg: Uint8Array | number[] | string, enc?: "hex"): SHA256HMAC digest(): number[] digestHex(): string } @@ -4104,7 +4061,7 @@ If the key size is larger than the blockSize, it is digested using SHA-256. If the key size is less than the blockSize, it is padded with zeroes. ```ts -constructor(key: HashInput) +constructor(key: Uint8Array | number[] | string) ``` Argument Details @@ -4175,8 +4132,9 @@ let hashedMessage = myHMAC.digestHex(); Updates the `SHA256HMAC` object with part of the message to be hashed. ```ts -update(msg: HashInput, enc?: "hex"): this +update(msg: Uint8Array | number[] | string, enc?: "hex"): SHA256HMAC ``` +See also: [SHA256HMAC](./primitives.md#class-sha256hmac) Returns @@ -4214,7 +4172,7 @@ const sha512 = new SHA512(); ```ts export class SHA512 { constructor() - update(msg: HashInput, enc?: "hex" | "utf8"): this + update(msg: number[] | string, enc?: "hex" | "utf8"): this digest(): number[] digestHex(): string } @@ -4235,8 +4193,8 @@ This class also uses the SHA-512 cryptographic hash algorithm that produces a 51 export class SHA512HMAC { blockSize = 128; outSize = 32; - constructor(key: HashInput) - update(msg: HashInput, enc?: "hex" | "utf8"): this + constructor(key: Uint8Array | number[] | string) + update(msg: Uint8Array | number[] | string, enc?: "hex" | "utf8"): SHA512HMAC digest(): number[] digestHex(): string } @@ -4251,7 +4209,7 @@ If the key size is larger than the blockSize, it is digested using SHA-512. If the key size is less than the blockSize, it is padded with zeroes. ```ts -constructor(key: HashInput) +constructor(key: Uint8Array | number[] | string) ``` Argument Details @@ -4322,8 +4280,9 @@ let hashedMessage = myHMAC.digestHex(); Updates the `SHA512HMAC` object with part of the message to be hashed. ```ts -update(msg: HashInput, enc?: "hex" | "utf8"): this +update(msg: Uint8Array | number[] | string, enc?: "hex" | "utf8"): SHA512HMAC ``` +See also: [SHA512HMAC](./primitives.md#class-sha512hmac) Returns @@ -4989,7 +4948,6 @@ export default class TransactionSignature extends Signature { static formatBip143(params: TransactionSignatureFormatParams): Uint8Array static format(params: TransactionSignatureFormatParams): number[] static formatBytes(params: TransactionSignatureFormatParams): Uint8Array - static usesOtdaSingleBug(params: TransactionSignatureFormatParams): boolean static fromChecksigFormat(buf: number[]): TransactionSignature constructor(r: BigNumber, s: BigNumber, scope: number) public hasLowS(): boolean @@ -5109,7 +5067,6 @@ export class WriterUint8Array { toUint8Array(): Uint8Array toArray(): number[] toUint8ArrayZeroCopy(): Uint8Array - reserve(additionalBytes: number): void write(bytes: WriterChunk): this writeReverse(buf: WriterChunk): this writeUInt8(value: number): this @@ -5141,14 +5098,6 @@ Returns the current length of written data getLength(): number ``` -#### Method reserve - -Ensures room for `additionalBytes` without changing the written length. - -```ts -reserve(additionalBytes: number): void -``` - #### Method reset Resets the writer to empty state (reuses the buffer) @@ -5192,17 +5141,14 @@ Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes), [Functions]( | | | | --- | --- | -| [AES](#function-aes) | [readyAsyncCryptoBackend](#function-readyasynccryptobackend) | -| [AESGCM](#function-aesgcm) | [realHtonl](#function-realhtonl) | -| [AESGCMDecrypt](#function-aesgcmdecrypt) | [red](#function-red) | -| [assertValidHex](#function-assertvalidhex) | [registerAsyncCryptoBackend](#function-registerasynccryptobackend) | +| [AES](#function-aes) | [normalizeHex](#function-normalizehex) | +| [AESGCM](#function-aesgcm) | [pbkdf2](#function-pbkdf2) | +| [AESGCMDecrypt](#function-aesgcmdecrypt) | [realHtonl](#function-realhtonl) | +| [assertValidHex](#function-assertvalidhex) | [red](#function-red) | | [base64ToArray](#function-base64toarray) | [swapBytes32](#function-swapbytes32) | | [constantTimeEquals](#function-constanttimeequals) | [toArray](#function-toarray) | | [ghash](#function-ghash) | [toBase64](#function-tobase64) | -| [htonl](#function-htonl) | [unregisterAsyncCryptoBackend](#function-unregisterasynccryptobackend) | -| [isAsyncCryptoDigest](#function-isasynccryptodigest) | [validateAsyncCryptoBytes](#function-validateasynccryptobytes) | -| [normalizeHex](#function-normalizehex) | [verifyNotNull](#function-verifynotnull) | -| [pbkdf2](#function-pbkdf2) | | +| [htonl](#function-htonl) | [verifyNotNull](#function-verifynotnull) | Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes), [Functions](#functions), [Types](#types), [Enums](#enums), [Variables](#variables) @@ -5328,17 +5274,6 @@ export function htonl(w: number): number Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes), [Functions](#functions), [Types](#types), [Enums](#enums), [Variables](#variables) ---- -### Function: isAsyncCryptoDigest - -True when a caller supplied the canonical 32-byte digest representation. - -```ts -export function isAsyncCryptoDigest(digest: readonly number[]): boolean -``` - -Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes), [Functions](#functions), [Types](#types), [Enums](#enums), [Variables](#variables) - --- ### Function: normalizeHex @@ -5376,20 +5311,6 @@ Argument Details Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes), [Functions](#functions), [Types](#types), [Enums](#enums), [Variables](#variables) ---- -### Function: readyAsyncCryptoBackend - -Returns a warm backend supporting `operation`. A cold backend is prepared in -the background while the current call retains the existing JavaScript path. - -```ts -export function readyAsyncCryptoBackend(operation: AsyncCryptoOperation): AsyncCryptoBackend | undefined -``` - -See also: [AsyncCryptoBackend](./primitives.md#interface-asynccryptobackend), [AsyncCryptoOperation](./primitives.md#type-asynccryptooperation) - -Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes), [Functions](#functions), [Types](#types), [Enums](#enums), [Variables](#variables) - --- ### Function: realHtonl @@ -5435,19 +5356,6 @@ export function red(x: bigint): bigint Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes), [Functions](#functions), [Types](#types), [Enums](#enums), [Variables](#variables) ---- -### Function: registerAsyncCryptoBackend - -Installs an optional process/page-wide backend for opportunistic SDK use. - -```ts -export function registerAsyncCryptoBackend(backend: AsyncCryptoBackend): void -``` - -See also: [AsyncCryptoBackend](./primitives.md#interface-asynccryptobackend) - -Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes), [Functions](#functions), [Types](#types), [Enums](#enums), [Variables](#variables) - --- ### Function: swapBytes32 @@ -5514,7 +5422,7 @@ console.log(toBase64(bytes)); // Outputs: SGVsbG8= ``` ```ts -export function toBase64(byteArray: number[] | Uint8Array): string +export function toBase64(byteArray: number[]): string ``` Returns @@ -5528,33 +5436,6 @@ Argument Details Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes), [Functions](#functions), [Types](#types), [Enums](#enums), [Variables](#variables) ---- -### Function: unregisterAsyncCryptoBackend - -Removes `backend` if it is still the active optional implementation. - -```ts -export function unregisterAsyncCryptoBackend(backend: AsyncCryptoBackend): void -``` - -See also: [AsyncCryptoBackend](./primitives.md#interface-asynccryptobackend) - -Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes), [Functions](#functions), [Types](#types), [Enums](#enums), [Variables](#variables) - ---- -### Function: validateAsyncCryptoBytes - -Reject malformed output from an optional cryptography backend before it can -be interpreted as key or signature material. - -```ts -export function validateAsyncCryptoBytes(operation: AsyncCryptoOperation, value: Uint8Array, expectedLength?: number): Uint8Array -``` - -See also: [AsyncCryptoOperation](./primitives.md#type-asynccryptooperation) - -Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes), [Functions](#functions), [Types](#types), [Enums](#enums), [Variables](#variables) - --- ### Function: verifyNotNull @@ -5590,27 +5471,6 @@ Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes), [Functions]( --- ## Types -| | -| --- | -| [AsyncCryptoOperation](#type-asynccryptooperation) | -| [P256Point](#type-p256point) | - -Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes), [Functions](#functions), [Types](#types), [Enums](#enums), [Variables](#variables) - ---- - -### Type: AsyncCryptoOperation - -Generic operations that an optional asynchronous cryptography backend can -accelerate without changing the SDK's synchronous primitive APIs. - -```ts -export type AsyncCryptoOperation = "signDigest" | "verifyDigest" | "verifyDigestBatch" | "publicKeyFromPrivate" | "multiplyPublicKey" | "tweakPublicKeyAdd" | "tweakPrivateKeyAdd" -``` - -Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes), [Functions](#functions), [Types](#types), [Enums](#enums), [Variables](#variables) - ---- ### Type: P256Point ```ts @@ -5629,25 +5489,25 @@ Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes), [Functions]( | | | | | --- | --- | --- | -| [BI_EIGHT](#variable-bi_eight) | [biModSub](#variable-bimodsub) | [multiply](#variable-multiply) | -| [BI_FOUR](#variable-bi_four) | [checkBit](#variable-checkbit) | [rightShift](#variable-rightshift) | -| [BI_ONE](#variable-bi_one) | [encode](#variable-encode) | [ripemd160](#variable-ripemd160) | -| [BI_THREE](#variable-bi_three) | [exclusiveOR](#variable-exclusiveor) | [scalarMultiplyWNAF](#variable-scalarmultiplywnaf) | -| [BI_TWO](#variable-bi_two) | [fromBase58](#variable-frombase58) | [sha1](#variable-sha1) | -| [BI_ZERO](#variable-bi_zero) | [fromBase58Check](#variable-frombase58check) | [sha256](#variable-sha256) | -| [GX_BIGINT](#variable-gx_bigint) | [getBytes](#variable-getbytes) | [sha256hmac](#variable-sha256hmac) | -| [GY_BIGINT](#variable-gy_bigint) | [getBytes64](#variable-getbytes64) | [sha512](#variable-sha512) | -| [MASK_256](#variable-mask_256) | [hash160](#variable-hash160) | [sha512hmac](#variable-sha512hmac) | -| [N_BIGINT](#variable-n_bigint) | [hash256](#variable-hash256) | [sign](#variable-sign) | -| [P_BIGINT](#variable-p_bigint) | [hexToUint8Array](#variable-hextouint8array) | [toArray](#variable-toarray) | -| [P_PLUS1_DIV4](#variable-p_plus1_div4) | [incrementLeastSignificantThirtyTwoBits](#variable-incrementleastsignificantthirtytwobits) | [toBase58](#variable-tobase58) | -| [biMod](#variable-bimod) | [jpAdd](#variable-jpadd) | [toBase58Check](#variable-tobase58check) | -| [biModAdd](#variable-bimodadd) | [jpDouble](#variable-jpdouble) | [toHex](#variable-tohex) | -| [biModInv](#variable-bimodinv) | [jpNeg](#variable-jpneg) | [toSafeString](#variable-tosafestring) | -| [biModMul](#variable-bimodmul) | [minimallyEncode](#variable-minimallyencode) | [toUTF8](#variable-toutf8) | -| [biModPow](#variable-bimodpow) | [modInvN](#variable-modinvn) | [toUint8Array](#variable-touint8array) | -| [biModSqr](#variable-bimodsqr) | [modMulN](#variable-modmuln) | [verify](#variable-verify) | -| [biModSqrt](#variable-bimodsqrt) | [modN](#variable-modn) | [zero2](#variable-zero2) | +| [BI_EIGHT](#variable-bi_eight) | [biModSub](#variable-bimodsub) | [rightShift](#variable-rightshift) | +| [BI_FOUR](#variable-bi_four) | [checkBit](#variable-checkbit) | [ripemd160](#variable-ripemd160) | +| [BI_ONE](#variable-bi_one) | [encode](#variable-encode) | [scalarMultiplyWNAF](#variable-scalarmultiplywnaf) | +| [BI_THREE](#variable-bi_three) | [exclusiveOR](#variable-exclusiveor) | [sha1](#variable-sha1) | +| [BI_TWO](#variable-bi_two) | [fromBase58](#variable-frombase58) | [sha256](#variable-sha256) | +| [BI_ZERO](#variable-bi_zero) | [fromBase58Check](#variable-frombase58check) | [sha256hmac](#variable-sha256hmac) | +| [GX_BIGINT](#variable-gx_bigint) | [getBytes](#variable-getbytes) | [sha512](#variable-sha512) | +| [GY_BIGINT](#variable-gy_bigint) | [getBytes64](#variable-getbytes64) | [sha512hmac](#variable-sha512hmac) | +| [MASK_256](#variable-mask_256) | [hash160](#variable-hash160) | [sign](#variable-sign) | +| [N_BIGINT](#variable-n_bigint) | [hash256](#variable-hash256) | [toArray](#variable-toarray) | +| [P_BIGINT](#variable-p_bigint) | [incrementLeastSignificantThirtyTwoBits](#variable-incrementleastsignificantthirtytwobits) | [toBase58](#variable-tobase58) | +| [P_PLUS1_DIV4](#variable-p_plus1_div4) | [jpAdd](#variable-jpadd) | [toBase58Check](#variable-tobase58check) | +| [biMod](#variable-bimod) | [jpDouble](#variable-jpdouble) | [toHex](#variable-tohex) | +| [biModAdd](#variable-bimodadd) | [jpNeg](#variable-jpneg) | [toUTF8](#variable-toutf8) | +| [biModInv](#variable-bimodinv) | [minimallyEncode](#variable-minimallyencode) | [toUint8Array](#variable-touint8array) | +| [biModMul](#variable-bimodmul) | [modInvN](#variable-modinvn) | [verify](#variable-verify) | +| [biModPow](#variable-bimodpow) | [modMulN](#variable-modmuln) | [zero2](#variable-zero2) | +| [biModSqr](#variable-bimodsqr) | [modN](#variable-modn) | | +| [biModSqrt](#variable-bimodsqrt) | [multiply](#variable-multiply) | | Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes), [Functions](#functions), [Types](#types), [Enums](#enums), [Variables](#variables) @@ -5766,7 +5626,7 @@ Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes), [Functions]( ### Variable: biMod ```ts -biMod = (a: bigint): bigint => red(((a % P_BIGINT) + P_BIGINT) % P_BIGINT) +biMod = (a: bigint): bigint => red((a % P_BIGINT + P_BIGINT) % P_BIGINT) ``` See also: [P_BIGINT](./primitives.md#variable-p_bigint), [red](./primitives.md#function-red) @@ -5883,7 +5743,7 @@ Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes), [Functions]( ```ts checkBit = function (byteArray: number[], byteIndex: number, bitIndex: number): 1 | 0 { - return (byteArray[byteIndex] & (1 << bitIndex)) === 0 ? 0 : 1; + return (byteArray[byteIndex] & (1 << bitIndex)) !== 0 ? 1 : 0; } ``` @@ -5933,19 +5793,19 @@ fromBase58 = (str: string): number[] => { if (str === "" || typeof str !== "string") { throw new Error(`Expected base58 string but got “${str}”`); } - const match: string[] | null = str.match(/[^1-9A-HJ-NP-Za-km-z]/gmu); + const match: string[] | null = str.match(/[IOl0]/gmu); if (match !== null) { throw new Error(`Invalid base58 character “${match.join("")}”`); } const lz = str.match(/^1+/gmu); - const psz: number = lz === null ? 0 : lz[0].length; + const psz: number = (lz !== null) ? lz[0].length : 0; const size = ((str.length - psz) * (Math.log(58) / Math.log(256)) + 1) >>> 0; const uint8 = new Uint8Array([ ...new Uint8Array(psz), - ...Array.from(str) - .map(i => base58chars.indexOf(i)) + ...(str.match(/./gmu) ?? []) + .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; @@ -5953,7 +5813,7 @@ fromBase58 = (str: string): number[] => { return acc; }, new Uint8Array(size)) .reverse() - .filter((lastValue => value => (lastValue = lastValue || value))(false)) + .filter(((lastValue) => (value) => (lastValue = lastValue || value))(false)) ]); return [...uint8]; } @@ -6036,16 +5896,13 @@ Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes), [Functions]( ### Variable: hash160 ```ts -hash160 = (msg: HashInput, enc?: "hex" | "utf8"): number[] => { - const first = sha256Bytes(msg, enc); - const native = ripemd160Bytes(first); - if (native != null) - return Array.from(native); +hash160 = (msg: Uint8Array | number[] | string, enc?: "hex" | "utf8"): number[] => { + const first = new SHA256().update(msg, enc).digest(); return new RIPEMD160().update(first).digest(); } ``` -See also: [RIPEMD160](./primitives.md#class-ripemd160) +See also: [RIPEMD160](./primitives.md#class-ripemd160), [SHA256](./primitives.md#class-sha256) Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes), [Functions](#functions), [Types](#types), [Enums](#enums), [Variables](#variables) @@ -6053,36 +5910,13 @@ Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes), [Functions]( ### Variable: hash256 ```ts -hash256 = (msg: HashInput, enc?: "hex" | "utf8"): number[] => { - return Array.from(sha256Bytes(sha256Bytes(msg, enc))); -} -``` - -Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes), [Functions](#functions), [Types](#types), [Enums](#enums), [Variables](#variables) - ---- -### Variable: hexToUint8Array - -```ts -hexToUint8Array = (msg: string): Uint8Array => { - assertValidHex(msg); - const normalized = msg.length % 2 === 0 ? msg : "0" + msg; - if (CAN_USE_BUFFER) { - const decoded = BufferCtor.from(normalized, "hex"); - return new Uint8Array(decoded.buffer, decoded.byteOffset, decoded.byteLength); - } - const out = new Uint8Array(normalized.length / 2); - let o = 0; - for (let i = 0; i < normalized.length; i += 2) { - const hi = HEX_CHAR_TO_VALUE[normalized.codePointAt(i) as number]; - const lo = HEX_CHAR_TO_VALUE[normalized.codePointAt(i + 1) as number]; - out[o++] = (hi << 4) | lo; - } - return out; +hash256 = (msg: Uint8Array | number[] | string, enc?: "hex" | "utf8"): number[] => { + const first = new SHA256().update(msg, enc).digest(); + return new SHA256().update(first).digest(); } ``` -See also: [assertValidHex](./primitives.md#function-assertvalidhex) +See also: [SHA256](./primitives.md#class-sha256) Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes), [Functions](#functions), [Types](#types), [Enums](#enums), [Variables](#variables) @@ -6185,26 +6019,26 @@ minimallyEncode = (buf: number[]): number[] => { if (buf.length === 0) { return buf; } - const last = buf.at(-1)!; + const last = buf[buf.length - 1]; if ((last & 127) !== 0) { return buf; } if (buf.length === 1) { return []; } - if ((buf.at(-2)! & 128) !== 0) { + if ((buf[buf.length - 2] & 128) !== 0) { return buf; } for (let i = buf.length - 1; i > 0; i--) { if (buf[i - 1] !== 0) { - if ((buf[i - 1] & 128) === 0) { - buf[i - 1]! |= last; - return buf.slice(0, i); - } - else { + if ((buf[i - 1] & 128) !== 0) { buf[i] = last; return buf.slice(0, i + 1); } + else { + buf[i - 1] |= last; + return buf.slice(0, i); + } } } return []; @@ -6319,9 +6153,6 @@ Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes), [Functions]( ```ts 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(); } ``` @@ -6338,15 +6169,48 @@ scalarMultiplyWNAF = (k: bigint, P0: { x: bigint; y: bigint; }, window: number = 5): JacobianPointBI => { - const table = wnafTable(window, P0); - const wnaf = wnafDigits(k, window); + const key = `${window}:${P0.x.toString(16)}:${P0.y.toString(16)}`; + let tbl = WNAF_TABLE_CACHE.get(key); + let P: JacobianPointBI; + if (tbl === undefined) { + const tblSize = 1 << (window - 1); + tbl = new Array(tblSize); + P = { X: P0.x, Y: P0.y, Z: BI_ONE }; + tbl[0] = P; + const twoP = jpDouble(P); + for (let i = 1; i < tblSize; i++) { + tbl[i] = jpAdd(tbl[i - 1], twoP); + } + WNAF_TABLE_CACHE.set(key, tbl); + } + else { + P = tbl[0]; + } + const wnaf: number[] = []; + const wBig = 1n << BigInt(window); + const wHalf = wBig >> 1n; + let kTmp = k; + while (kTmp > 0n) { + if ((kTmp & BI_ONE) === BI_ZERO) { + wnaf.push(0); + kTmp >>= BI_ONE; + } + else { + let z = kTmp & (wBig - 1n); + if (z > wHalf) + z -= wBig; + wnaf.push(Number(z)); + kTmp -= z; + kTmp >>= BI_ONE; + } + } let Q: JacobianPointBI = { X: BI_ZERO, Y: BI_ONE, Z: BI_ZERO }; for (let i = wnaf.length - 1; i >= 0; i--) { Q = jpDouble(Q); const di = wnaf[i]; if (di !== 0) { const idx = Math.abs(di) >> 1; - const addend = di > 0 ? table[idx] : jpNeg(table[idx]); + const addend = di > 0 ? tbl[idx] : jpNeg(tbl[idx]); Q = jpAdd(Q, addend); } } @@ -6375,21 +6239,20 @@ Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes), [Functions]( ### Variable: sha256 ```ts -sha256 = (msg: HashInput, enc?: "hex" | "utf8"): number[] => { - return Array.from(sha256Bytes(msg, enc)); +sha256 = (msg: Uint8Array | number[] | string, enc?: "hex" | "utf8"): number[] => { + return new SHA256().update(msg, enc).digest(); } ``` +See also: [SHA256](./primitives.md#class-sha256) + Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes), [Functions](#functions), [Types](#types), [Enums](#enums), [Variables](#variables) --- ### Variable: sha256hmac ```ts -sha256hmac = (key: HashInput, msg: HashInput, enc?: "hex"): number[] => { - const native = digestWithNodeHmac("sha256", key, msg, enc); - if (native != null) - return Array.from(native); +sha256hmac = (key: Uint8Array | number[] | string, msg: Uint8Array | number[] | string, enc?: "hex"): number[] => { return new SHA256HMAC(key).update(msg, enc).digest(); } ``` @@ -6402,21 +6265,20 @@ Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes), [Functions]( ### Variable: sha512 ```ts -sha512 = (msg: HashInput, enc?: "hex" | "utf8"): number[] => { - return Array.from(sha512Bytes(msg, enc)); +sha512 = (msg: number[] | string, enc?: "hex" | "utf8"): number[] => { + return new SHA512().update(msg, enc).digest(); } ``` +See also: [SHA512](./primitives.md#class-sha512) + Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes), [Functions](#functions), [Types](#types), [Enums](#enums), [Variables](#variables) --- ### Variable: sha512hmac ```ts -sha512hmac = (key: HashInput, msg: HashInput, enc?: "hex"): number[] => { - const native = digestWithNodeHmac("sha512", key, msg, enc); - if (native != null) - return Array.from(native); +sha512hmac = (key: Uint8Array | number[] | string, msg: Uint8Array | number[] | string, enc?: "hex"): number[] => { return new SHA512HMAC(key).update(msg, enc).digest(); } ``` @@ -6440,16 +6302,59 @@ sign = (msg: BigNumber, key: BigNumber, forceLowS: boolean = false, customK?: Bi const bkey = key.toArray("be", bytes); const nonce = msg.toArray("be", bytes); const drbg = new DRBG(bkey, nonce); - const fixedK = BigNumber.isBN(customK); for (let iter = 0;; iter++) { - const signature = signatureFromK(selectK(customK, iter, drbg), msgBig, keyBig, forceLowS, fixedK); - if (signature != null) - return signature; + let kBN = typeof customK === "function" + ? customK(iter) + : BigNumber.isBN(customK) + ? customK + : new BigNumber(drbg.generate(bytes), 16); + if (kBN == null) { + throw new Error("k is undefined"); + } + kBN = truncateToN(kBN, true); + if (kBN.cmpn(1) < 0 || kBN.cmp(ns1) > 0) { + if (BigNumber.isBN(customK)) { + throw new Error("Invalid fixed custom K value (must be >1 and halfN) { + sBig = N_BIGINT - sBig; + } + const r = new BigNumber(rBig.toString(16), 16); + const s = new BigNumber(sBig.toString(16), 16); + return new Signature(r, s); } } ``` -See also: [BigNumber](./primitives.md#class-bignumber), [DRBG](./primitives.md#class-drbg), [Signature](./primitives.md#class-signature), [toArray](./primitives.md#variable-toarray) +See also: [BigNumber](./primitives.md#class-bignumber), [DRBG](./primitives.md#class-drbg), [N_BIGINT](./primitives.md#variable-n_bigint), [Signature](./primitives.md#class-signature), [modInvN](./primitives.md#variable-modinvn), [modMulN](./primitives.md#variable-modmuln), [modN](./primitives.md#variable-modn), [toArray](./primitives.md#variable-toarray) Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes), [Functions](#functions), [Types](#types), [Enums](#enums), [Variables](#variables) @@ -6463,7 +6368,7 @@ toArray = (msg: any, enc?: "hex" | "utf8" | "base64"): any[] => { if (msg === undefined) return []; if (typeof msg !== "string") { - return Array.from(msg, (item: any) => Math.trunc(item)); + return Array.from(msg, (item: any) => item | 0); } switch (enc) { case "hex": @@ -6485,35 +6390,31 @@ Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes), [Functions]( ```ts toBase58 = (bin: number[]): string => { - const base58Map = Array.from({ length: 256 }, () => -1); + const base58Map = Array(256).fill(-1); for (let i = 0; i < base58chars.length; ++i) { - base58Map[base58chars.codePointAt(i) as number] = i; + base58Map[base58chars.charCodeAt(i)] = i; } const result: number[] = []; for (const byte of bin) { let carry = byte; for (let j = 0; j < result.length; ++j) { const x = (base58Map[result[j]] << 8) + carry; - const quotient = Math.trunc(x / 58); - const remainder = x - quotient * 58; - result[j] = base58chars.codePointAt(remainder) as number; - carry = quotient; + result[j] = base58chars.charCodeAt(x % 58); + carry = (x / 58) | 0; } while (carry !== 0) { - const quotient = Math.trunc(carry / 58); - const remainder = carry - quotient * 58; - result.push(base58chars.codePointAt(remainder) as number); - carry = quotient; + result.push(base58chars.charCodeAt(carry % 58)); + carry = (carry / 58) | 0; } } for (const byte of bin) { - if (byte === 0) - result.push("1".codePointAt(0) as number); - else + if (byte !== 0) break; + else + result.push("1".charCodeAt(0)); } result.reverse(); - return String.fromCodePoint(...result); + return String.fromCharCode(...result); } ``` @@ -6544,42 +6445,11 @@ toHex = (msg: number[] | Uint8Array): string => { } if (msg.length === 0) return ""; - return Array.from(msg, byte => HEX_BYTE_STRINGS[byte & 255]).join(""); -} -``` - -Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes), [Functions](#functions), [Types](#types), [Enums](#enums), [Variables](#variables) - ---- -### Variable: toSafeString - -```ts -toSafeString = (value: unknown, fallback = "Unknown value"): string => { - if (value === null) - return "null"; - if (value === undefined) - return "undefined"; - if (typeof value === "string") - return value; - if (typeof value === "number" || typeof value === "bigint") - return value.toString(); - if (typeof value === "boolean") - return value ? "true" : "false"; - if (typeof value === "symbol") - return value.description ?? value.toString(); - if (value instanceof Error && value.message.length > 0) - return value.message; - const message = (value as { - message?: unknown; - }).message; - if (typeof message === "string" && message.length > 0) - return message; - try { - return JSON.stringify(value) ?? fallback; - } - catch { - return fallback; + const out = new Array(msg.length); + for (let i = 0; i < msg.length; i++) { + out[i] = HEX_BYTE_STRINGS[msg[i] & 255]; } + return out.join(""); } ``` @@ -6589,8 +6459,8 @@ Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes), [Functions]( ### Variable: toUTF8 ```ts -toUTF8 = (arr: number[] | Uint8Array): string => { - return new TextDecoder().decode(arr instanceof Uint8Array ? arr : new Uint8Array(arr)); +toUTF8 = (arr: number[]): string => { + return new TextDecoder().decode(new Uint8Array(arr)); } ``` @@ -6603,13 +6473,11 @@ Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes), [Functions]( toUint8Array = (msg: any, enc?: "hex" | "utf8" | "base64"): Uint8Array => { if (msg instanceof Uint8Array) return msg; - if (typeof msg === "string" && enc === "hex") - return hexToUint8Array(msg); return new Uint8Array(toArray(msg, enc)); } ``` -See also: [hexToUint8Array](./primitives.md#variable-hextouint8array), [toArray](./primitives.md#variable-toarray) +See also: [toArray](./primitives.md#variable-toarray) Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes), [Functions](#functions), [Types](#types), [Enums](#enums), [Variables](#variables) @@ -6623,7 +6491,7 @@ verify = (msg: BigNumber, sig: Signature, key: Point): boolean => { return false; } const hash = bnToBigInt(msg); - if (key.x == null || key.y == null) { + if ((key.x == null) || (key.y == null)) { throw new Error("Invalid public key: missing coordinates."); } const publicKey = { diff --git a/packages/sdk/docs/reference/registry.md b/packages/sdk/docs/reference/registry.md index 606482f1b..df4ab4e91 100644 --- a/packages/sdk/docs/reference/registry.md +++ b/packages/sdk/docs/reference/registry.md @@ -216,7 +216,6 @@ export class RegistryClient { constructor(private readonly wallet: WalletInterface = new WalletClient(), options: { acceptDelayedBroadcast?: boolean; resolver?: LookupResolver; - networkPreset?: LookupNetworkPreset; } = {}, private readonly originator?: OriginatorDomainNameStringUnder250Bytes) async registerDefinition(data: DefinitionData): Promise async resolve(definitionType: T, query: RegistryQueryMapping[T]): Promise @@ -226,7 +225,7 @@ export class RegistryClient { } ``` -See also: [BroadcastFailure](./transaction.md#interface-broadcastfailure), [BroadcastResponse](./transaction.md#interface-broadcastresponse), [DefinitionData](./registry.md#type-definitiondata), [DefinitionType](./registry.md#type-definitiontype), [LookupNetworkPreset](./overlay-tools.md#type-lookupnetworkpreset), [LookupResolver](./overlay-tools.md#class-lookupresolver), [OriginatorDomainNameStringUnder250Bytes](./wallet.md#type-originatordomainnamestringunder250bytes), [RegistryQueryMapping](./registry.md#interface-registryquerymapping), [RegistryRecord](./registry.md#type-registryrecord), [WalletClient](./wallet.md#class-walletclient), [WalletInterface](./wallet.md#interface-walletinterface) +See also: [BroadcastFailure](./transaction.md#interface-broadcastfailure), [BroadcastResponse](./transaction.md#interface-broadcastresponse), [DefinitionData](./registry.md#type-definitiondata), [DefinitionType](./registry.md#type-definitiontype), [LookupResolver](./overlay-tools.md#class-lookupresolver), [OriginatorDomainNameStringUnder250Bytes](./wallet.md#type-originatordomainnamestringunder250bytes), [RegistryQueryMapping](./registry.md#interface-registryquerymapping), [RegistryRecord](./registry.md#type-registryrecord), [WalletClient](./wallet.md#class-walletclient), [WalletInterface](./wallet.md#interface-walletinterface) #### Method listOwnRegistryEntries diff --git a/packages/sdk/docs/reference/remittance.md b/packages/sdk/docs/reference/remittance.md index 82cc387d1..d27ef18dc 100644 --- a/packages/sdk/docs/reference/remittance.md +++ b/packages/sdk/docs/reference/remittance.md @@ -191,6 +191,16 @@ Description applied to created actions. description?: string ``` +#### Property internalizeProtocol + +Deprecated. BRC-29 settlements must be internalized as `wallet payment` so the +recipient can verify and manage the derived output. The module rejects +`basket insertion`; it is reserved for application-managed custom outputs. + +```ts +internalizeProtocol?: "wallet payment" | "basket insertion" +``` + #### Property labels Labels applied to created actions. diff --git a/packages/sdk/docs/reference/script.md b/packages/sdk/docs/reference/script.md index b7943846f..b3c065526 100644 --- a/packages/sdk/docs/reference/script.md +++ b/packages/sdk/docs/reference/script.md @@ -29,8 +29,7 @@ export interface FormatPreimageParams { sourceTXID: string; sourceSatoshis: number; lockingScript: Script; - otherInputs?: Transaction["inputs"]; - allInputs?: Transaction["inputs"]; + otherInputs: Transaction["inputs"]; inputSequence?: number; } ``` @@ -48,7 +47,6 @@ A representation of a chunk of a script, which includes an opcode. For push oper export default interface ScriptChunk { op: number; data?: number[]; - invalidLength?: boolean; } ``` @@ -107,12 +105,8 @@ Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes), [Functions]( --- ### Interface: SpendVerificationContext -Explicit chain context for script verification. - -Transaction version is script data, not a reliable signal for whether a -caller is asking for consensus or policy validation. Backends should use -this context when it is supplied and retain their compatibility behavior -only when it is omitted. +Explicit chain context for script verification. Transaction version is script +data and is not a policy/consensus selector. ```ts export default interface SpendVerificationContext { @@ -123,75 +117,25 @@ export default interface SpendVerificationContext { } ``` -#### Property blockHeight - -Height of the block against which the spend is evaluated. - -```ts -blockHeight?: number -``` - -#### Property consensus - -`true` selects consensus rules; `false` permits policy validation. - -```ts -consensus: boolean -``` - -#### Property utxoHeight - -Height at which the source output was mined. - -```ts -utxoHeight?: number -``` - -#### Property verifyFlags - -Optional backend-specific script verification flags. - -```ts -verifyFlags?: string | string[] -``` - Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes), [Functions](#functions), [Types](#types), [Enums](#enums), [Variables](#variables) --- ### Interface: SpendVerifierInterface +An asynchronous backend capable of validating a single Spend-shaped input. +The explicit context is authoritative when supplied. + ```ts export default interface SpendVerifierInterface { isReady?: () => boolean; shouldVerifySpend?: (spend: Spend, context?: SpendVerificationContext) => boolean; verifySpend: (spend: Spend, context?: SpendVerificationContext) => Promise; - verifySpendsBatch?: (items: ReadonlyArray & { - spend: Spend; - }>) => Promise; verifySpendSync?: (spend: Spend, context?: SpendVerificationContext) => boolean; } ``` See also: [Spend](./script.md#class-spend), [SpendVerificationContext](./script.md#interface-spendverificationcontext) -#### Property isReady - -Optional synchronous readiness signal for compatibility APIs. - -```ts -isReady?: () => boolean -``` - -#### Property shouldVerifySpend - -Optionally decide whether this backend should handle the Spend now. -Returning false preserves the existing synchronous JavaScript validator. - -```ts -shouldVerifySpend?: (spend: Spend, context?: SpendVerificationContext) => boolean -``` -See also: [Spend](./script.md#class-spend), [SpendVerificationContext](./script.md#interface-spendverificationcontext) - Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes), [Functions](#functions), [Types](#types), [Enums](#enums), [Variables](#variables) --- @@ -222,8 +166,8 @@ Inherits all properties and methods from the Script class. ```ts export default class LockingScript extends Script { - override isLockingScript(): boolean - override isUnlockingScript(): boolean + isLockingScript(): boolean + isUnlockingScript(): boolean } ``` @@ -232,7 +176,7 @@ See also: [Script](./script.md#class-script) #### Method isLockingScript ```ts -override isLockingScript(): boolean +isLockingScript(): boolean ``` Returns @@ -242,7 +186,7 @@ Always returns true for a LockingScript instance. #### Method isUnlockingScript ```ts -override isUnlockingScript(): boolean +isUnlockingScript(): boolean ``` Returns @@ -541,8 +485,7 @@ Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes), [Functions]( export default class Script { static fromASM(asm: string): Script static fromHex(hex: string): Script - static fromBinary(bin: number[] | Uint8Array): Script - static fromBinaryView(bin: Uint8Array): Script + static fromBinary(bin: number[]): Script constructor(chunks: ScriptChunk[] = [], rawBytesCache?: Uint8Array, hexCache?: string, parsed: boolean = true) get chunks(): ScriptChunk[] set chunks(value: ScriptChunk[]) @@ -550,14 +493,14 @@ export default class Script { toHex(): string toBinary(): number[] toUint8Array(): Uint8Array - writeScript(script: Script): this - writeOpCode(op: number): this - setChunkOpCode(i: number, op: number): this - writeBn(bn: BigNumber): this - writeBin(bin: number[]): this - writeNumber(num: number): this - removeCodeseparators(): this - findAndDelete(script: Script): this + writeScript(script: Script): Script + writeOpCode(op: number): Script + setChunkOpCode(i: number, op: number): Script + writeBn(bn: BigNumber): Script + writeBin(bin: number[]): Script + writeNumber(num: number): Script + removeCodeseparators(): Script + findAndDelete(script: Script): Script isPushOnly(): boolean isLockingScript(): boolean isUnlockingScript(): boolean @@ -589,7 +532,7 @@ Argument Details Deletes the given item wherever it appears in the current script. ```ts -findAndDelete(script: Script): this +findAndDelete(script: Script): Script ``` See also: [Script](./script.md#class-script) @@ -627,7 +570,7 @@ const script = Script.fromASM("OP_DUP OP_HASH160 abcd... OP_EQUALVERIFY OP_CHECK #### Method fromBinary ```ts -static fromBinary(bin: number[] | Uint8Array): Script +static fromBinary(bin: number[]): Script ``` See also: [Script](./script.md#class-script) @@ -646,16 +589,6 @@ Example const script = Script.fromBinary([0x76, 0xa9, ...]) ``` -#### Method fromBinaryView - -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. - -```ts -static fromBinaryView(bin: Uint8Array): Script -``` -See also: [Script](./script.md#class-script) - #### Method fromHex ```ts @@ -711,8 +644,9 @@ True if the script is an unlocking script, otherwise false. #### Method removeCodeseparators ```ts -removeCodeseparators(): this +removeCodeseparators(): Script ``` +See also: [Script](./script.md#class-script) Returns @@ -721,8 +655,9 @@ This script instance for chaining. #### Method setChunkOpCode ```ts -setChunkOpCode(i: number, op: number): this +setChunkOpCode(i: number, op: number): Script ``` +See also: [Script](./script.md#class-script) Returns @@ -768,8 +703,9 @@ The script in hexadecimal format. #### Method writeBin ```ts -writeBin(bin: number[]): this +writeBin(bin: number[]): Script ``` +See also: [Script](./script.md#class-script) Returns @@ -787,9 +723,9 @@ Throws an error if the data is too large to be pushed. #### Method writeBn ```ts -writeBn(bn: BigNumber): this +writeBn(bn: BigNumber): Script ``` -See also: [BigNumber](./primitives.md#class-bignumber) +See also: [BigNumber](./primitives.md#class-bignumber), [Script](./script.md#class-script) Returns @@ -803,8 +739,9 @@ Argument Details #### Method writeNumber ```ts -writeNumber(num: number): this +writeNumber(num: number): Script ``` +See also: [Script](./script.md#class-script) Returns @@ -818,8 +755,9 @@ Argument Details #### Method writeOpCode ```ts -writeOpCode(op: number): this +writeOpCode(op: number): Script ``` +See also: [Script](./script.md#class-script) Returns @@ -833,7 +771,7 @@ Argument Details #### Method writeScript ```ts -writeScript(script: Script): this +writeScript(script: Script): Script ``` See also: [Script](./script.md#class-script) @@ -882,19 +820,19 @@ Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes), [Functions]( --- ### Class: ScriptResourceLimitError -Raised when an explicitly configured local interpreter budget is exhausted. - -This is deliberately distinct from ScriptEvaluationError: exhausting a -caller-supplied resource budget does not prove that a script is invalid. +Raised when a caller-supplied local interpreter budget or a host representation +limit is exhausted. This is deliberately distinct from +`ScriptEvaluationError`: resource exhaustion does not prove script invalidity. ```ts export default class ScriptResourceLimitError extends Error { - constructor(public readonly resource: ScriptResource, public readonly limit: number | bigint, public readonly attempted: number | bigint) + readonly resource: ScriptResource; + readonly limit: number | bigint; + readonly attempted: number | bigint; + constructor(resource: ScriptResource, limit: number | bigint, attempted: number | bigint) } ``` -See also: [ScriptResource](./script.md#type-scriptresource) - Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes), [Functions](#functions), [Types](#types), [Enums](#enums), [Variables](#variables) --- @@ -912,27 +850,22 @@ export default class Spend { lockingScript: LockingScript; transactionVersion: number; otherInputs: TransactionInput[]; - allInputs?: TransactionInput[]; outputs: TransactionOutput[]; inputIndex: number; unlockingScript: UnlockingScript; inputSequence: number; lockTime: number; - context!: "UnlockingScript" | "LockingScript"; - programCounter!: number; - lastCodeSeparator!: number | null; + context: "UnlockingScript" | "LockingScript"; + programCounter: number; + lastCodeSeparator: number | null; stack: number[][]; altStack: number[][]; ifStack: boolean[]; - elseStack: boolean[]; memoryLimit: number; readonly hasExplicitMemoryLimit: boolean; stackMem: number; altStackMem: number; isRelaxedOverride: boolean; - verifyFlags?: Set; - executedOpCount: number; - returningFromConditional: boolean; constructor(params: { sourceTXID: string; sourceOutputIndex: number; @@ -940,7 +873,6 @@ export default class Spend { lockingScript: LockingScript; transactionVersion: number; otherInputs: TransactionInput[]; - allInputs?: TransactionInput[]; outputs: TransactionOutput[]; unlockingScript: UnlockingScript; inputSequence: number; @@ -948,19 +880,16 @@ export default class Spend { lockTime: number; memoryLimit?: number; isRelaxed?: boolean; - verifyFlags?: string | string[]; - sigHashCache?: SignatureHashCache; }) reset(): void step(): boolean - validate(context?: SpendVerificationContext): boolean - validateJavaScript(): boolean - async validateWith(verifier: SpendVerifierInterface, context?: SpendVerificationContext): Promise - toTransactionUint8Array(): Uint8Array + validate(context?: SpendVerificationContext): boolean + validateWith(verifier: SpendVerifierInterface, context?: SpendVerificationContext): Promise + toTransactionUint8Array(): Uint8Array } ``` -See also: [LockingScript](./script.md#class-lockingscript), [SignatureHashCache](./primitives.md#interface-signaturehashcache), [SpendVerificationContext](./script.md#interface-spendverificationcontext), [SpendVerifierInterface](./script.md#interface-spendverifierinterface), [TransactionInput](./transaction.md#interface-transactioninput), [TransactionOutput](./transaction.md#interface-transactionoutput), [UnlockingScript](./script.md#class-unlockingscript) +See also: [LockingScript](./script.md#class-lockingscript), [SpendVerificationContext](./script.md#interface-spendverificationcontext), [SpendVerifierInterface](./script.md#interface-spendverifierinterface), [TransactionInput](./transaction.md#interface-transactioninput), [TransactionOutput](./transaction.md#interface-transactionoutput), [UnlockingScript](./script.md#class-unlockingscript) #### Constructor @@ -972,7 +901,6 @@ constructor(params: { lockingScript: LockingScript; transactionVersion: number; otherInputs: TransactionInput[]; - allInputs?: TransactionInput[]; outputs: TransactionOutput[]; unlockingScript: UnlockingScript; inputSequence: number; @@ -980,11 +908,9 @@ constructor(params: { lockTime: number; memoryLimit?: number; isRelaxed?: boolean; - verifyFlags?: string | string[]; - sigHashCache?: SignatureHashCache; }) ``` -See also: [LockingScript](./script.md#class-lockingscript), [SignatureHashCache](./primitives.md#interface-signaturehashcache), [TransactionInput](./transaction.md#interface-transactioninput), [TransactionOutput](./transaction.md#interface-transactionoutput), [UnlockingScript](./script.md#class-unlockingscript) +See also: [LockingScript](./script.md#class-lockingscript), [TransactionInput](./transaction.md#interface-transactioninput), [TransactionOutput](./transaction.md#interface-transactionoutput), [UnlockingScript](./script.md#class-unlockingscript) Argument Details @@ -1013,9 +939,8 @@ The outputs of the current transaction. + **params.lockTime** + The lock time of the transaction. + **params.memoryLimit** - + Optional caller-supplied local -interpreter budget. Resource exhaustion is reported separately from -script invalidity. + + Optional caller-supplied local interpreter budget. If omitted, +post-Genesis validation has no arbitrary SDK memory cap. + **params.isRelaxed** + Optional. If true, disables all the unlocking script maleability restrictions consitent with Chronicle release. Maleability restrictions are neve appliced to locking scripts. @@ -1037,70 +962,48 @@ const spend = new Spend({ }); ``` -#### Method toTransactionUint8Array - -Serializes the ordinary transaction represented by this Spend. The source -output is intentionally excluded and is supplied separately to a Spend -verifier, avoiding an EF construction and parse for one-input validation. - -```ts -toTransactionUint8Array(): Uint8Array -``` - #### Method validate ```ts -validate(context?: SpendVerificationContext): boolean +validate(context?: SpendVerificationContext): boolean ``` + See also: [SpendVerificationContext](./script.md#interface-spendverificationcontext) Returns -Returns true when the spend is valid. - -Argument Details - -+ **context** - + Optional explicit consensus or -policy context passed to a registered script backend. - -Throws - -If script validation fails. - -If a local interpreter resource is -exhausted before validity can be determined. +Returns true if the scripts are valid and the spend is legitimate, otherwise false. Example ```ts -spend.validate() -console.log("Spend is valid!") +if (spend.validate()) { + console.log("Spend is valid!"); +} else { + console.log("Invalid spend!"); +} ``` -#### Method validateJavaScript +#### Method validateWith -Runs the original TypeScript interpreter explicitly, bypassing any -registered optional backend. +Validates this spend with an asynchronous pluggable backend. Backend errors are +propagated without silently falling back to the JavaScript interpreter. ```ts -validateJavaScript(): boolean +validateWith(verifier: SpendVerifierInterface, context?: SpendVerificationContext): Promise ``` -#### Method validateWith - -```ts -async validateWith(verifier: SpendVerifierInterface, context?: SpendVerificationContext): Promise -``` See also: [SpendVerificationContext](./script.md#interface-spendverificationcontext), [SpendVerifierInterface](./script.md#interface-spendverifierinterface) -Argument Details +#### Method toTransactionUint8Array + +Serializes the ordinary transaction represented by this Spend. The source +output is supplied separately to a Spend verifier, avoiding EF construction +for one-input validation. -+ **verifier** - + The backend used when it accepts this Spend. -+ **context** - + Optional explicit consensus or policy context. Transaction -version is never used as a substitute for this context. +```ts +toTransactionUint8Array(): Uint8Array +``` Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes), [Functions](#functions), [Types](#types), [Enums](#enums), [Variables](#variables) @@ -1114,8 +1017,8 @@ Inherits all properties and methods from the Script class. ```ts export default class UnlockingScript extends Script { - override isLockingScript(): boolean - override isUnlockingScript(): boolean + isLockingScript(): boolean + isUnlockingScript(): boolean } ``` @@ -1124,7 +1027,7 @@ See also: [Script](./script.md#class-script) #### Method isLockingScript ```ts -override isLockingScript(): boolean +isLockingScript(): boolean ``` Returns @@ -1134,7 +1037,7 @@ Always returns false for an UnlockingScript instance. #### Method isUnlockingScript ```ts -override isUnlockingScript(): boolean +isUnlockingScript(): boolean ``` Returns @@ -1191,7 +1094,6 @@ export function resolveSourceDetails(tx: Transaction, inputIndex: number, provid sourceSatoshis: number; lockingScript: Script; otherInputs: typeof tx.inputs; - allInputs: typeof tx.inputs; } ``` @@ -1208,10 +1110,6 @@ Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes), [Functions]( export type ScriptResource = "stack" | "alt-stack" | "element-size" ``` -Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes), [Functions](#functions), [Types](#types), [Enums](#enums), [Variables](#variables) - ---- ## Enums ## Variables - diff --git a/packages/sdk/docs/reference/storage.md b/packages/sdk/docs/reference/storage.md index c6407b2a6..c1579f7a1 100644 --- a/packages/sdk/docs/reference/storage.md +++ b/packages/sdk/docs/reference/storage.md @@ -37,12 +37,10 @@ Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes), [Functions]( ```ts export interface DownloaderConfig { - networkPreset: LookupNetworkPreset; + networkPreset: "mainnet" | "testnet" | "teratestnet" | "local"; } ``` -See also: [LookupNetworkPreset](./overlay-tools.md#type-lookupnetworkpreset) - Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes), [Functions](#functions), [Types](#types), [Enums](#enums), [Variables](#variables) --- diff --git a/packages/sdk/docs/reference/wallet.md b/packages/sdk/docs/reference/wallet.md index 2c2140882..067a1ed89 100644 --- a/packages/sdk/docs/reference/wallet.md +++ b/packages/sdk/docs/reference/wallet.md @@ -6,40 +6,40 @@ Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes), [Functions]( | | | | | --- | --- | --- | -| [AbortActionArgs](#interface-abortactionargs) | [ListCertificatesArgs](#interface-listcertificatesargs) | [ValidInternalizeActionArgs](#interface-validinternalizeactionargs) | -| [AbortActionResult](#interface-abortactionresult) | [ListCertificatesResult](#interface-listcertificatesresult) | [ValidInternalizeOutput](#interface-validinternalizeoutput) | -| [AcquireCertificateArgs](#interface-acquirecertificateargs) | [ListOutputsArgs](#interface-listoutputsargs) | [ValidListActionsArgs](#interface-validlistactionsargs) | -| [AuthenticatedResult](#interface-authenticatedresult) | [ListOutputsResult](#interface-listoutputsresult) | [ValidListCertificatesArgs](#interface-validlistcertificatesargs) | -| [BasketInsertion](#interface-basketinsertion) | [OutPoint](#interface-outpoint) | [ValidListOutputsArgs](#interface-validlistoutputsargs) | -| [CertificateResult](#interface-certificateresult) | [PrivateKeyDerivation](#interface-privatekeyderivation) | [ValidProcessActionArgs](#interface-validprocessactionargs) | -| [CreateActionArgs](#interface-createactionargs) | [ProveCertificateArgs](#interface-provecertificateargs) | [ValidProcessActionOptions](#interface-validprocessactionoptions) | -| [CreateActionInput](#interface-createactioninput) | [ProveCertificateResult](#interface-provecertificateresult) | [ValidProveCertificateArgs](#interface-validprovecertificateargs) | -| [CreateActionOptions](#interface-createactionoptions) | [RelinquishCertificateArgs](#interface-relinquishcertificateargs) | [ValidRelinquishCertificateArgs](#interface-validrelinquishcertificateargs) | -| [CreateActionOutput](#interface-createactionoutput) | [RelinquishCertificateResult](#interface-relinquishcertificateresult) | [ValidRelinquishOutputArgs](#interface-validrelinquishoutputargs) | -| [CreateActionResult](#interface-createactionresult) | [RelinquishOutputArgs](#interface-relinquishoutputargs) | [ValidSignActionArgs](#interface-validsignactionargs) | -| [CreateHmacArgs](#interface-createhmacargs) | [RelinquishOutputResult](#interface-relinquishoutputresult) | [ValidSignActionOptions](#interface-validsignactionoptions) | -| [CreateHmacResult](#interface-createhmacresult) | [RevealCounterpartyKeyLinkageArgs](#interface-revealcounterpartykeylinkageargs) | [ValidWalletPayment](#interface-validwalletpayment) | -| [CreateSignatureArgs](#interface-createsignatureargs) | [RevealCounterpartyKeyLinkageResult](#interface-revealcounterpartykeylinkageresult) | [ValidWalletSignerArgs](#interface-validwalletsignerargs) | -| [CreateSignatureResult](#interface-createsignatureresult) | [RevealSpecificKeyLinkageArgs](#interface-revealspecifickeylinkageargs) | [VerifyHmacArgs](#interface-verifyhmacargs) | -| [DiscoverByAttributesArgs](#interface-discoverbyattributesargs) | [RevealSpecificKeyLinkageResult](#interface-revealspecifickeylinkageresult) | [VerifyHmacResult](#interface-verifyhmacresult) | -| [DiscoverByIdentityKeyArgs](#interface-discoverbyidentitykeyargs) | [ReviewActionResult](#interface-reviewactionresult) | [VerifySignatureArgs](#interface-verifysignatureargs) | -| [DiscoverCertificatesResult](#interface-discovercertificatesresult) | [SendWithResult](#interface-sendwithresult) | [VerifySignatureResult](#interface-verifysignatureresult) | -| [GetHeaderArgs](#interface-getheaderargs) | [SignActionArgs](#interface-signactionargs) | [WalletAction](#interface-walletaction) | -| [GetHeaderResult](#interface-getheaderresult) | [SignActionOptions](#interface-signactionoptions) | [WalletActionInput](#interface-walletactioninput) | -| [GetHeightResult](#interface-getheightresult) | [SignActionResult](#interface-signactionresult) | [WalletActionOutput](#interface-walletactionoutput) | -| [GetNetworkResult](#interface-getnetworkresult) | [SignActionSpend](#interface-signactionspend) | [WalletCertificate](#interface-walletcertificate) | -| [GetPublicKeyArgs](#interface-getpublickeyargs) | [SignableTransaction](#interface-signabletransaction) | [WalletDecryptArgs](#interface-walletdecryptargs) | -| [GetPublicKeyResult](#interface-getpublickeyresult) | [ValidAbortActionArgs](#interface-validabortactionargs) | [WalletDecryptResult](#interface-walletdecryptresult) | -| [GetVersionResult](#interface-getversionresult) | [ValidAcquireCertificateArgs](#interface-validacquirecertificateargs) | [WalletEncryptArgs](#interface-walletencryptargs) | -| [IdentityCertificate](#interface-identitycertificate) | [ValidAcquireDirectCertificateArgs](#interface-validacquiredirectcertificateargs) | [WalletEncryptResult](#interface-walletencryptresult) | -| [IdentityCertifier](#interface-identitycertifier) | [ValidAcquireIssuanceCertificateArgs](#interface-validacquireissuancecertificateargs) | [WalletEncryptionArgs](#interface-walletencryptionargs) | -| [InternalizeActionArgs](#interface-internalizeactionargs) | [ValidBasketInsertion](#interface-validbasketinsertion) | [WalletErrorObject](#interface-walleterrorobject) | -| [InternalizeActionResult](#interface-internalizeactionresult) | [ValidCreateActionArgs](#interface-validcreateactionargs) | [WalletInterface](#interface-walletinterface) | -| [InternalizeOutput](#interface-internalizeoutput) | [ValidCreateActionInput](#interface-validcreateactioninput) | [WalletLoggerInterface](#interface-walletloggerinterface) | -| [KeyDeriverApi](#interface-keyderiverapi) | [ValidCreateActionOptions](#interface-validcreateactionoptions) | [WalletLoggerLog](#interface-walletloggerlog) | -| [KeyLinkageResult](#interface-keylinkageresult) | [ValidCreateActionOutput](#interface-validcreateactionoutput) | [WalletOutput](#interface-walletoutput) | -| [ListActionsArgs](#interface-listactionsargs) | [ValidDiscoverByAttributesArgs](#interface-validdiscoverbyattributesargs) | [WalletPayment](#interface-walletpayment) | -| [ListActionsResult](#interface-listactionsresult) | [ValidDiscoverByIdentityKeyArgs](#interface-validdiscoverbyidentitykeyargs) | [WalletWire](#interface-walletwire) | +| [AbortActionArgs](#interface-abortactionargs) | [ListCertificatesArgs](#interface-listcertificatesargs) | [ValidInternalizeOutput](#interface-validinternalizeoutput) | +| [AbortActionResult](#interface-abortactionresult) | [ListCertificatesResult](#interface-listcertificatesresult) | [ValidListActionsArgs](#interface-validlistactionsargs) | +| [AcquireCertificateArgs](#interface-acquirecertificateargs) | [ListOutputsArgs](#interface-listoutputsargs) | [ValidListCertificatesArgs](#interface-validlistcertificatesargs) | +| [AuthenticatedResult](#interface-authenticatedresult) | [ListOutputsResult](#interface-listoutputsresult) | [ValidListOutputsArgs](#interface-validlistoutputsargs) | +| [BasketInsertion](#interface-basketinsertion) | [OutPoint](#interface-outpoint) | [ValidProcessActionArgs](#interface-validprocessactionargs) | +| [CertificateResult](#interface-certificateresult) | [ProveCertificateArgs](#interface-provecertificateargs) | [ValidProcessActionOptions](#interface-validprocessactionoptions) | +| [CreateActionArgs](#interface-createactionargs) | [ProveCertificateResult](#interface-provecertificateresult) | [ValidProveCertificateArgs](#interface-validprovecertificateargs) | +| [CreateActionInput](#interface-createactioninput) | [RelinquishCertificateArgs](#interface-relinquishcertificateargs) | [ValidRelinquishCertificateArgs](#interface-validrelinquishcertificateargs) | +| [CreateActionOptions](#interface-createactionoptions) | [RelinquishCertificateResult](#interface-relinquishcertificateresult) | [ValidRelinquishOutputArgs](#interface-validrelinquishoutputargs) | +| [CreateActionOutput](#interface-createactionoutput) | [RelinquishOutputArgs](#interface-relinquishoutputargs) | [ValidSignActionArgs](#interface-validsignactionargs) | +| [CreateActionResult](#interface-createactionresult) | [RelinquishOutputResult](#interface-relinquishoutputresult) | [ValidSignActionOptions](#interface-validsignactionoptions) | +| [CreateHmacArgs](#interface-createhmacargs) | [RevealCounterpartyKeyLinkageArgs](#interface-revealcounterpartykeylinkageargs) | [ValidWalletPayment](#interface-validwalletpayment) | +| [CreateHmacResult](#interface-createhmacresult) | [RevealCounterpartyKeyLinkageResult](#interface-revealcounterpartykeylinkageresult) | [ValidWalletSignerArgs](#interface-validwalletsignerargs) | +| [CreateSignatureArgs](#interface-createsignatureargs) | [RevealSpecificKeyLinkageArgs](#interface-revealspecifickeylinkageargs) | [VerifyHmacArgs](#interface-verifyhmacargs) | +| [CreateSignatureResult](#interface-createsignatureresult) | [RevealSpecificKeyLinkageResult](#interface-revealspecifickeylinkageresult) | [VerifyHmacResult](#interface-verifyhmacresult) | +| [DiscoverByAttributesArgs](#interface-discoverbyattributesargs) | [ReviewActionResult](#interface-reviewactionresult) | [VerifySignatureArgs](#interface-verifysignatureargs) | +| [DiscoverByIdentityKeyArgs](#interface-discoverbyidentitykeyargs) | [SendWithResult](#interface-sendwithresult) | [VerifySignatureResult](#interface-verifysignatureresult) | +| [DiscoverCertificatesResult](#interface-discovercertificatesresult) | [SignActionArgs](#interface-signactionargs) | [WalletAction](#interface-walletaction) | +| [GetHeaderArgs](#interface-getheaderargs) | [SignActionOptions](#interface-signactionoptions) | [WalletActionInput](#interface-walletactioninput) | +| [GetHeaderResult](#interface-getheaderresult) | [SignActionResult](#interface-signactionresult) | [WalletActionOutput](#interface-walletactionoutput) | +| [GetHeightResult](#interface-getheightresult) | [SignActionSpend](#interface-signactionspend) | [WalletCertificate](#interface-walletcertificate) | +| [GetNetworkResult](#interface-getnetworkresult) | [SignableTransaction](#interface-signabletransaction) | [WalletDecryptArgs](#interface-walletdecryptargs) | +| [GetPublicKeyArgs](#interface-getpublickeyargs) | [ValidAbortActionArgs](#interface-validabortactionargs) | [WalletDecryptResult](#interface-walletdecryptresult) | +| [GetPublicKeyResult](#interface-getpublickeyresult) | [ValidAcquireCertificateArgs](#interface-validacquirecertificateargs) | [WalletEncryptArgs](#interface-walletencryptargs) | +| [GetVersionResult](#interface-getversionresult) | [ValidAcquireDirectCertificateArgs](#interface-validacquiredirectcertificateargs) | [WalletEncryptResult](#interface-walletencryptresult) | +| [IdentityCertificate](#interface-identitycertificate) | [ValidAcquireIssuanceCertificateArgs](#interface-validacquireissuancecertificateargs) | [WalletEncryptionArgs](#interface-walletencryptionargs) | +| [IdentityCertifier](#interface-identitycertifier) | [ValidBasketInsertion](#interface-validbasketinsertion) | [WalletErrorObject](#interface-walleterrorobject) | +| [InternalizeActionArgs](#interface-internalizeactionargs) | [ValidCreateActionArgs](#interface-validcreateactionargs) | [WalletInterface](#interface-walletinterface) | +| [InternalizeActionResult](#interface-internalizeactionresult) | [ValidCreateActionInput](#interface-validcreateactioninput) | [WalletLoggerInterface](#interface-walletloggerinterface) | +| [InternalizeOutput](#interface-internalizeoutput) | [ValidCreateActionOptions](#interface-validcreateactionoptions) | [WalletLoggerLog](#interface-walletloggerlog) | +| [KeyDeriverApi](#interface-keyderiverapi) | [ValidCreateActionOutput](#interface-validcreateactionoutput) | [WalletOutput](#interface-walletoutput) | +| [KeyLinkageResult](#interface-keylinkageresult) | [ValidDiscoverByAttributesArgs](#interface-validdiscoverbyattributesargs) | [WalletPayment](#interface-walletpayment) | +| [ListActionsArgs](#interface-listactionsargs) | [ValidDiscoverByIdentityKeyArgs](#interface-validdiscoverbyidentitykeyargs) | [WalletWire](#interface-walletwire) | +| [ListActionsResult](#interface-listactionsresult) | [ValidInternalizeActionArgs](#interface-validinternalizeactionargs) | | Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes), [Functions](#functions), [Types](#types), [Enums](#enums), [Variables](#variables) @@ -60,23 +60,9 @@ Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes), [Functions]( --- ### Interface: AbortActionResult -Result of an `abortAction` call. - -`aborted` is informative: `true` indicates the wallet successfully invalidated -the action (it will not be broadcast and its inputs are released), `false` -indicates the wallet refused to abort because the underlying transaction was -found to already be on chain (mined or known to mempool). On a refusal the -caller should typically invoke `internalizeAction` instead, which will treat -the call as explicit authorization to advance the nosend lifecycle. - -Note that confirming on-chain status requires network reachability. When -confirmation is impossible (services unreachable or returning errors), the -wallet proceeds with the abort and returns `aborted: true` rather than -refusing — refusal is reserved for positive on-chain confirmation. - ```ts export interface AbortActionResult { - aborted: boolean; + aborted: true; } ``` @@ -237,27 +223,6 @@ export interface CreateActionResult { See also: [AtomicBEEF](./wallet.md#type-atomicbeef), [OutpointString](./wallet.md#type-outpointstring), [SendWithResult](./wallet.md#interface-sendwithresult), [SignableTransaction](./wallet.md#interface-signabletransaction), [TXIDHexString](./wallet.md#type-txidhexstring) -#### Property tx - -AtomicBEEF-encoded transaction bytes (BRC-95), produced by the wallet's -`createAction` / `signAction` methods (`wallet-toolbox` -`signer/methods/createAction.ts:66` and `signer/methods/signAction.ts:35`, -both invoking `beef.toBinaryAtomic(txid)`). The envelope carries the -broadcast transaction plus every input's `sourceTransaction` chain — a -subsequent broadcaster does not need to re-fetch parents to construct EF -or BEEF wire format. - -Parse with `Transaction.fromAtomicBEEF(tx)`. Treating these bytes as raw -tx binary (e.g. `Transaction.fromBinary(tx)`) will fail or produce a -transaction with empty `sourceTransaction` on every input. - -Absent when `options.returnTXIDOnly === true`. - -```ts -tx?: AtomicBEEF -``` -See also: [AtomicBEEF](./wallet.md#type-atomicbeef) - Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes), [Functions](#functions), [Types](#types), [Enums](#enums), [Variables](#variables) --- @@ -539,16 +504,13 @@ export interface KeyDeriverApi { identityKey: string; derivePublicKey: (protocolID: WalletProtocol, keyID: string, counterparty: Counterparty, forSelf?: boolean) => PublicKey; derivePrivateKey: (protocolID: WalletProtocol, keyID: string, counterparty: Counterparty) => PrivateKey; - derivePrivateKeys?: (derivations: readonly PrivateKeyDerivation[]) => PrivateKey[]; deriveSymmetricKey: (protocolID: WalletProtocol, keyID: string, counterparty: Counterparty) => SymmetricKey; - derivePublicKeyAsync?: (protocolID: WalletProtocol, keyID: string, counterparty: Counterparty, forSelf?: boolean) => Promise; - deriveSymmetricKeyAsync?: (protocolID: WalletProtocol, keyID: string, counterparty: Counterparty) => Promise; revealCounterpartySecret: (counterparty: Counterparty) => number[]; revealSpecificSecret: (counterparty: Counterparty, protocolID: WalletProtocol, keyID: string) => number[]; } ``` -See also: [Counterparty](./wallet.md#type-counterparty), [PrivateKey](./primitives.md#class-privatekey), [PrivateKeyDerivation](./wallet.md#interface-privatekeyderivation), [PublicKey](./primitives.md#class-publickey), [SymmetricKey](./primitives.md#class-symmetrickey), [WalletProtocol](./wallet.md#type-walletprotocol) +See also: [Counterparty](./wallet.md#type-counterparty), [PrivateKey](./primitives.md#class-privatekey), [PublicKey](./primitives.md#class-publickey), [SymmetricKey](./primitives.md#class-symmetrickey), [WalletProtocol](./wallet.md#type-walletprotocol) #### Property derivePrivateKey @@ -559,17 +521,6 @@ derivePrivateKey: (protocolID: WalletProtocol, keyID: string, counterparty: Coun ``` See also: [Counterparty](./wallet.md#type-counterparty), [PrivateKey](./primitives.md#class-privatekey), [WalletProtocol](./wallet.md#type-walletprotocol) -#### Property derivePrivateKeys - -Derives several private keys while sharing each counterparty ECDH result. -Implementations that do not provide this additive lane retain the -per-key `derivePrivateKey` contract. - -```ts -derivePrivateKeys?: (derivations: readonly PrivateKeyDerivation[]) => PrivateKey[] -``` -See also: [PrivateKey](./primitives.md#class-privatekey), [PrivateKeyDerivation](./wallet.md#interface-privatekeyderivation) - #### Property derivePublicKey Derives a public key based on protocol ID, key ID, and counterparty. @@ -579,15 +530,6 @@ derivePublicKey: (protocolID: WalletProtocol, keyID: string, counterparty: Count ``` See also: [Counterparty](./wallet.md#type-counterparty), [PublicKey](./primitives.md#class-publickey), [WalletProtocol](./wallet.md#type-walletprotocol) -#### Property derivePublicKeyAsync - -Asynchronous acceleration lane; synchronous-only implementations may omit it. - -```ts -derivePublicKeyAsync?: (protocolID: WalletProtocol, keyID: string, counterparty: Counterparty, forSelf?: boolean) => Promise -``` -See also: [Counterparty](./wallet.md#type-counterparty), [PublicKey](./primitives.md#class-publickey), [WalletProtocol](./wallet.md#type-walletprotocol) - #### Property deriveSymmetricKey Derives a symmetric key based on protocol ID, key ID, and counterparty. @@ -598,15 +540,6 @@ deriveSymmetricKey: (protocolID: WalletProtocol, keyID: string, counterparty: Co ``` See also: [Counterparty](./wallet.md#type-counterparty), [SymmetricKey](./primitives.md#class-symmetrickey), [WalletProtocol](./wallet.md#type-walletprotocol) -#### Property deriveSymmetricKeyAsync - -Asynchronous acceleration lane; synchronous-only implementations may omit it. - -```ts -deriveSymmetricKeyAsync?: (protocolID: WalletProtocol, keyID: string, counterparty: Counterparty) => Promise -``` -See also: [Counterparty](./wallet.md#type-counterparty), [SymmetricKey](./primitives.md#class-symmetrickey), [WalletProtocol](./wallet.md#type-walletprotocol) - #### Property identityKey The identity of this key deriver which is normally the public key associated with the `rootKey` @@ -798,21 +731,6 @@ vout: number Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes), [Functions](#functions), [Types](#types), [Enums](#enums), [Variables](#variables) ---- -### Interface: PrivateKeyDerivation - -```ts -export interface PrivateKeyDerivation { - protocolID: WalletProtocol; - keyID: string; - counterparty: Counterparty; -} -``` - -See also: [Counterparty](./wallet.md#type-counterparty), [WalletProtocol](./wallet.md#type-walletprotocol) - -Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes), [Functions](#functions), [Types](#types), [Enums](#enums), [Variables](#variables) - --- ### Interface: ProveCertificateArgs @@ -1077,21 +995,6 @@ export interface SignableTransaction { See also: [AtomicBEEF](./wallet.md#type-atomicbeef), [Base64String](./wallet.md#type-base64string) -#### Property tx - -AtomicBEEF-encoded transaction bytes (BRC-95). The envelope carries the -unsigned transaction plus every input's `sourceTransaction` chain so a -caller can complete signing without a separate parent-fetch round-trip. - -Parse with `Transaction.fromAtomicBEEF(tx)`. Treating these bytes as raw -tx binary (e.g. `Transaction.fromBinary(tx)`) will fail or produce a -transaction with empty `sourceTransaction` on every input. - -```ts -tx: AtomicBEEF -``` -See also: [AtomicBEEF](./wallet.md#type-atomicbeef) - Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes), [Functions](#functions), [Types](#types), [Enums](#enums), [Variables](#variables) --- @@ -2289,15 +2192,9 @@ export default interface WalletWire { } ``` -#### Property transmitToWalletUint8Array - -Optional compact-byte transport. Implementations can provide this lane to -avoid boxing multi-megabyte wire frames while the legacy method remains -available for backwards compatibility. - -```ts -transmitToWalletUint8Array?: (message: Uint8Array) => Promise -``` +`transmitToWalletUint8Array` is an optional compact-byte lane for large frames. +The required `number[]` method and all wallet protocol contracts remain +unchanged. Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes), [Functions](#functions), [Types](#types), [Enums](#enums), [Variables](#variables) @@ -2333,17 +2230,14 @@ export default class CachedKeyDeriver implements KeyDeriverApi { maxCacheSize?: number; }) derivePublicKey(protocolID: WalletProtocol, keyID: string, counterparty: Counterparty, forSelf: boolean = false): PublicKey - async derivePublicKeyAsync(protocolID: WalletProtocol, keyID: string, counterparty: Counterparty, forSelf: boolean = false): Promise derivePrivateKey(protocolID: WalletProtocol, keyID: string, counterparty: Counterparty): PrivateKey - derivePrivateKeys(derivations: readonly PrivateKeyDerivation[]): PrivateKey[] deriveSymmetricKey(protocolID: WalletProtocol, keyID: string, counterparty: Counterparty): SymmetricKey - async deriveSymmetricKeyAsync(protocolID: WalletProtocol, keyID: string, counterparty: Counterparty): Promise revealCounterpartySecret(counterparty: Counterparty): number[] revealSpecificSecret(counterparty: Counterparty, protocolID: WalletProtocol, keyID: string): number[] } ``` -See also: [Counterparty](./wallet.md#type-counterparty), [KeyDeriverApi](./wallet.md#interface-keyderiverapi), [PrivateKey](./primitives.md#class-privatekey), [PrivateKeyDerivation](./wallet.md#interface-privatekeyderivation), [PublicKey](./primitives.md#class-publickey), [SymmetricKey](./primitives.md#class-symmetrickey), [WalletProtocol](./wallet.md#type-walletprotocol) +See also: [Counterparty](./wallet.md#type-counterparty), [KeyDeriverApi](./wallet.md#interface-keyderiverapi), [PrivateKey](./primitives.md#class-privatekey), [PublicKey](./primitives.md#class-publickey), [SymmetricKey](./primitives.md#class-symmetrickey), [WalletProtocol](./wallet.md#type-walletprotocol) #### Constructor @@ -2541,7 +2435,7 @@ export default class HTTPWalletJSON implements WalletInterface { keyID?: KeyIDStringUnder800Bytes; privileged?: BooleanDefaultFalse; privilegedReason?: DescriptionString5to50Bytes; - counterparty?: PubKeyHex; + counterparty?: PubKeyHex | "self" | "anyone"; forSelf?: BooleanDefaultFalse; }): Promise<{ publicKey: PubKeyHex; @@ -2591,7 +2485,7 @@ export default class HTTPWalletJSON implements WalletInterface { ]; keyID: KeyIDStringUnder800Bytes; privilegedReason?: DescriptionString5to50Bytes; - counterparty?: PubKeyHex; + counterparty?: PubKeyHex | "self" | "anyone"; privileged?: BooleanDefaultFalse; }): Promise<{ ciphertext: Byte[]; @@ -2605,7 +2499,7 @@ export default class HTTPWalletJSON implements WalletInterface { ]; keyID: KeyIDStringUnder800Bytes; privilegedReason?: DescriptionString5to50Bytes; - counterparty?: PubKeyHex; + counterparty?: PubKeyHex | "self" | "anyone"; privileged?: BooleanDefaultFalse; }): Promise<{ plaintext: Byte[]; @@ -2619,7 +2513,7 @@ export default class HTTPWalletJSON implements WalletInterface { ]; keyID: KeyIDStringUnder800Bytes; privilegedReason?: DescriptionString5to50Bytes; - counterparty?: PubKeyHex; + counterparty?: PubKeyHex | "self" | "anyone"; privileged?: BooleanDefaultFalse; }): Promise<{ hmac: Byte[]; @@ -2634,7 +2528,7 @@ export default class HTTPWalletJSON implements WalletInterface { ]; keyID: KeyIDStringUnder800Bytes; privilegedReason?: DescriptionString5to50Bytes; - counterparty?: PubKeyHex; + counterparty?: PubKeyHex | "self" | "anyone"; privileged?: BooleanDefaultFalse; }): Promise<{ valid: true; @@ -2649,7 +2543,7 @@ export default class HTTPWalletJSON implements WalletInterface { ]; keyID: KeyIDStringUnder800Bytes; privilegedReason?: DescriptionString5to50Bytes; - counterparty?: PubKeyHex; + counterparty?: PubKeyHex | "self" | "anyone"; privileged?: BooleanDefaultFalse; }): Promise<{ signature: Byte[]; @@ -2665,7 +2559,7 @@ export default class HTTPWalletJSON implements WalletInterface { ]; keyID: KeyIDStringUnder800Bytes; privilegedReason?: DescriptionString5to50Bytes; - counterparty?: PubKeyHex; + counterparty?: PubKeyHex | "self" | "anyone"; forSelf?: BooleanDefaultFalse; privileged?: BooleanDefaultFalse; }): Promise<{ @@ -2735,9 +2629,9 @@ export default class HTTPWalletWire implements WalletWire { baseUrl: string; httpClient: typeof fetch; originator: string | undefined; - constructor(originator: string | undefined, baseUrl: string = "http://localhost:3301", httpClient?: typeof fetch) + constructor(originator: string | undefined, baseUrl: string = "http://localhost:3301", httpClient = fetch) async transmitToWallet(message: number[]): Promise - async transmitToWalletUint8Array(message: Uint8Array): Promise + async transmitToWalletUint8Array(message: Uint8Array): Promise } ``` @@ -2805,16 +2699,13 @@ export class KeyDeriver implements KeyDeriverApi { constructor(rootKey: PrivateKey | "anyone", private readonly cacheSharedSecret?: ((priv: PrivateKey, pub: Point, point: Point) => void), private readonly retrieveCachedSharedSecret?: ((priv: PrivateKey, pub: Point) => (Point | undefined))) derivePublicKey(protocolID: WalletProtocol, keyID: string, counterparty: Counterparty, forSelf: boolean = false): PublicKey derivePrivateKey(protocolID: WalletProtocol, keyID: string, counterparty: Counterparty): PrivateKey - derivePrivateKeys(derivations: readonly PrivateKeyDerivation[]): PrivateKey[] deriveSymmetricKey(protocolID: WalletProtocol, keyID: string, counterparty: Counterparty): SymmetricKey - async derivePublicKeyAsync(protocolID: WalletProtocol, keyID: string, counterparty: Counterparty, forSelf: boolean = false): Promise - async deriveSymmetricKeyAsync(protocolID: WalletProtocol, keyID: string, counterparty: Counterparty): Promise revealCounterpartySecret(counterparty: Counterparty): number[] revealSpecificSecret(counterparty: Counterparty, protocolID: WalletProtocol, keyID: string): number[] } ``` -See also: [Counterparty](./wallet.md#type-counterparty), [KeyDeriverApi](./wallet.md#interface-keyderiverapi), [Point](./primitives.md#class-point), [PrivateKey](./primitives.md#class-privatekey), [PrivateKeyDerivation](./wallet.md#interface-privatekeyderivation), [PublicKey](./primitives.md#class-publickey), [SymmetricKey](./primitives.md#class-symmetrickey), [WalletProtocol](./wallet.md#type-walletprotocol) +See also: [Counterparty](./wallet.md#type-counterparty), [KeyDeriverApi](./wallet.md#interface-keyderiverapi), [Point](./primitives.md#class-point), [PrivateKey](./primitives.md#class-privatekey), [PublicKey](./primitives.md#class-publickey), [SymmetricKey](./primitives.md#class-symmetrickey), [WalletProtocol](./wallet.md#type-walletprotocol) #### Constructor @@ -2965,7 +2856,7 @@ export class ProtoWallet { async revealCounterpartyKeyLinkage(args: RevealCounterpartyKeyLinkageArgs): Promise async revealSpecificKeyLinkage(args: RevealSpecificKeyLinkageArgs): Promise async encrypt(args: WalletEncryptArgs): Promise - async decrypt(args: WalletDecryptArgs, _originator?: string): Promise + async decrypt(args: WalletDecryptArgs, originator?: string): Promise async createHmac(args: CreateHmacArgs): Promise async verifyHmac(args: VerifyHmacArgs): Promise async createSignature(args: CreateSignatureArgs): Promise @@ -2982,22 +2873,9 @@ Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes), [Functions]( Facilitates wallet operations over cross-document messaging. -A React Native host answers a BRC-100 invocation by injecting the response -into the document that made the call, so a response is delivered by this -window, by the frame bridging for it, or by a host-synthesized event that -carries no source at all. Messages from any other browsing context - a -framed document, an opener, or a sandboxed frame reporting an opaque origin -- are never wallet responses and are ignored before their payload is read. -A relaying host frame is a separate browsing context, so its browser-attested -origin must belong to this document or to the configured wallet origin. -Whatever origin a host stamps on an event it synthesizes in this document is -accepted, because the browser does not attest it and the injection is already -same-origin; configuring an exact domain additionally pins every response to -that origin, while the default wildcard target keeps every host reachable. - ```ts export default class ReactNativeWebView extends InvokableWalletBase { - constructor(domain: string = "*", responseTimeout?: number) + constructor(domain: string = "*") async invoke(call: CallType, args: any): Promise } ``` @@ -3100,7 +2978,7 @@ export default class WalletClient implements WalletInterface { async abortAction(args: { reference: Base64String; }): Promise<{ - aborted: boolean; + aborted: true; }> async listActions(args: ListActionsArgs): Promise async internalizeAction(args: InternalizeActionArgs): Promise<{ @@ -3122,7 +3000,7 @@ export default class WalletClient implements WalletInterface { keyID?: KeyIDStringUnder800Bytes; privileged?: BooleanDefaultFalse; privilegedReason?: DescriptionString5to50Bytes; - counterparty?: PubKeyHex; + counterparty?: PubKeyHex | "self" | "anyone"; forSelf?: BooleanDefaultFalse; }): Promise<{ publicKey: PubKeyHex; @@ -3171,7 +3049,7 @@ export default class WalletClient implements WalletInterface { ]; keyID: KeyIDStringUnder800Bytes; privilegedReason?: DescriptionString5to50Bytes; - counterparty?: PubKeyHex; + counterparty?: PubKeyHex | "self" | "anyone"; privileged?: BooleanDefaultFalse; }): Promise<{ ciphertext: Byte[]; @@ -3184,7 +3062,7 @@ export default class WalletClient implements WalletInterface { ]; keyID: KeyIDStringUnder800Bytes; privilegedReason?: DescriptionString5to50Bytes; - counterparty?: PubKeyHex; + counterparty?: PubKeyHex | "self" | "anyone"; privileged?: BooleanDefaultFalse; }): Promise<{ plaintext: Byte[]; @@ -3197,7 +3075,7 @@ export default class WalletClient implements WalletInterface { ]; keyID: KeyIDStringUnder800Bytes; privilegedReason?: DescriptionString5to50Bytes; - counterparty?: PubKeyHex; + counterparty?: PubKeyHex | "self" | "anyone"; privileged?: BooleanDefaultFalse; }): Promise<{ hmac: Byte[]; @@ -3211,7 +3089,7 @@ export default class WalletClient implements WalletInterface { ]; keyID: KeyIDStringUnder800Bytes; privilegedReason?: DescriptionString5to50Bytes; - counterparty?: PubKeyHex; + counterparty?: PubKeyHex | "self" | "anyone"; privileged?: BooleanDefaultFalse; }): Promise<{ valid: true; @@ -3225,7 +3103,7 @@ export default class WalletClient implements WalletInterface { ]; keyID: KeyIDStringUnder800Bytes; privilegedReason?: DescriptionString5to50Bytes; - counterparty?: PubKeyHex; + counterparty?: PubKeyHex | "self" | "anyone"; privileged?: BooleanDefaultFalse; }): Promise<{ signature: Byte[]; @@ -3240,7 +3118,7 @@ export default class WalletClient implements WalletInterface { ]; keyID: KeyIDStringUnder800Bytes; privilegedReason?: DescriptionString5to50Bytes; - counterparty?: PubKeyHex; + counterparty?: PubKeyHex | "self" | "anyone"; forSelf?: BooleanDefaultFalse; privileged?: BooleanDefaultFalse; }): Promise<{ @@ -3338,7 +3216,7 @@ export default class WalletWireProcessor implements WalletWire { wallet: WalletInterface; constructor(wallet: WalletInterface) async transmitToWallet(message: number[]): Promise - async transmitToWalletUint8Array(message: Uint8Array): Promise + async transmitToWalletUint8Array(message: Uint8Array): Promise } ``` @@ -3383,7 +3261,7 @@ export default class WalletWireTransceiver implements WalletInterface { keyID?: KeyIDStringUnder800Bytes; privileged?: BooleanDefaultFalse; privilegedReason?: DescriptionString5to50Bytes; - counterparty?: PubKeyHex; + counterparty?: PubKeyHex | "self" | "anyone"; forSelf?: BooleanDefaultFalse; }, originator?: OriginatorDomainNameStringUnder250Bytes): Promise<{ publicKey: PubKeyHex; @@ -3433,7 +3311,7 @@ export default class WalletWireTransceiver implements WalletInterface { ]; keyID: KeyIDStringUnder800Bytes; privilegedReason?: DescriptionString5to50Bytes; - counterparty?: PubKeyHex; + counterparty?: PubKeyHex | "self" | "anyone"; privileged?: BooleanDefaultFalse; }, originator?: OriginatorDomainNameStringUnder250Bytes): Promise<{ ciphertext: Byte[]; @@ -3447,7 +3325,7 @@ export default class WalletWireTransceiver implements WalletInterface { ]; keyID: KeyIDStringUnder800Bytes; privilegedReason?: DescriptionString5to50Bytes; - counterparty?: PubKeyHex; + counterparty?: PubKeyHex | "self" | "anyone"; privileged?: BooleanDefaultFalse; }, originator?: OriginatorDomainNameStringUnder250Bytes): Promise<{ plaintext: Byte[]; @@ -3461,7 +3339,7 @@ export default class WalletWireTransceiver implements WalletInterface { ]; keyID: KeyIDStringUnder800Bytes; privilegedReason?: DescriptionString5to50Bytes; - counterparty?: PubKeyHex; + counterparty?: PubKeyHex | "self" | "anyone"; privileged?: BooleanDefaultFalse; }, originator?: OriginatorDomainNameStringUnder250Bytes): Promise<{ hmac: Byte[]; @@ -3476,7 +3354,7 @@ export default class WalletWireTransceiver implements WalletInterface { ]; keyID: KeyIDStringUnder800Bytes; privilegedReason?: DescriptionString5to50Bytes; - counterparty?: PubKeyHex; + counterparty?: PubKeyHex | "self" | "anyone"; privileged?: BooleanDefaultFalse; }, originator?: OriginatorDomainNameStringUnder250Bytes): Promise<{ valid: true; @@ -3491,7 +3369,7 @@ export default class WalletWireTransceiver implements WalletInterface { ]; keyID: KeyIDStringUnder800Bytes; privilegedReason?: DescriptionString5to50Bytes; - counterparty?: PubKeyHex; + counterparty?: PubKeyHex | "self" | "anyone"; privileged?: BooleanDefaultFalse; }, originator?: OriginatorDomainNameStringUnder250Bytes): Promise<{ signature: Byte[]; @@ -3507,7 +3385,7 @@ export default class WalletWireTransceiver implements WalletInterface { ]; keyID: KeyIDStringUnder800Bytes; privilegedReason?: DescriptionString5to50Bytes; - counterparty?: PubKeyHex; + counterparty?: PubKeyHex | "self" | "anyone"; forSelf?: BooleanDefaultFalse; privileged?: BooleanDefaultFalse; }, originator?: OriginatorDomainNameStringUnder250Bytes): Promise<{ @@ -3621,11 +3499,32 @@ export default class WindowCWISubstrate implements WalletInterface { reference: Base64String; }; }> - async signAction(args: SignActionArgs, originator?: OriginatorDomainNameStringUnder250Bytes): Promise + async signAction(args: { + spends: Record; + reference: Base64String; + options?: { + acceptDelayedBroadcast?: BooleanDefaultTrue; + returnTXIDOnly?: BooleanDefaultFalse; + noSend?: BooleanDefaultFalse; + noSendChange?: OutpointString[]; + sendWith: TXIDHexString[]; + }; + }, originator?: OriginatorDomainNameStringUnder250Bytes): Promise<{ + txid?: TXIDHexString; + tx?: BEEF; + noSendChange?: OutpointString[]; + sendWithResults?: Array<{ + txid: TXIDHexString; + status: "unproven" | "sending" | "failed"; + }>; + }> async abortAction(args: { reference: Base64String; }, originator?: OriginatorDomainNameStringUnder250Bytes): Promise<{ - aborted: boolean; + aborted: true; }> async listActions(args: { labels: LabelStringUnder300Bytes[]; @@ -3728,7 +3627,7 @@ export default class WindowCWISubstrate implements WalletInterface { keyID?: KeyIDStringUnder800Bytes; privileged?: BooleanDefaultFalse; privilegedReason?: DescriptionString5to50Bytes; - counterparty?: PubKeyHex; + counterparty?: PubKeyHex | "self" | "anyone"; forSelf?: BooleanDefaultFalse; }, originator?: OriginatorDomainNameStringUnder250Bytes): Promise<{ publicKey: PubKeyHex; @@ -3777,7 +3676,7 @@ export default class WindowCWISubstrate implements WalletInterface { ]; keyID: KeyIDStringUnder800Bytes; privilegedReason?: DescriptionString5to50Bytes; - counterparty?: PubKeyHex; + counterparty?: PubKeyHex | "self" | "anyone"; privileged?: BooleanDefaultFalse; }, originator?: OriginatorDomainNameStringUnder250Bytes): Promise<{ ciphertext: Byte[]; @@ -3790,7 +3689,7 @@ export default class WindowCWISubstrate implements WalletInterface { ]; keyID: KeyIDStringUnder800Bytes; privilegedReason?: DescriptionString5to50Bytes; - counterparty?: PubKeyHex; + counterparty?: PubKeyHex | "self" | "anyone"; privileged?: BooleanDefaultFalse; }, originator?: OriginatorDomainNameStringUnder250Bytes): Promise<{ plaintext: Byte[]; @@ -3803,7 +3702,7 @@ export default class WindowCWISubstrate implements WalletInterface { ]; keyID: KeyIDStringUnder800Bytes; privilegedReason?: DescriptionString5to50Bytes; - counterparty?: PubKeyHex; + counterparty?: PubKeyHex | "self" | "anyone"; privileged?: BooleanDefaultFalse; }, originator?: OriginatorDomainNameStringUnder250Bytes): Promise<{ hmac: Byte[]; @@ -3817,7 +3716,7 @@ export default class WindowCWISubstrate implements WalletInterface { ]; keyID: KeyIDStringUnder800Bytes; privilegedReason?: DescriptionString5to50Bytes; - counterparty?: PubKeyHex; + counterparty?: PubKeyHex | "self" | "anyone"; privileged?: BooleanDefaultFalse; }, originator?: OriginatorDomainNameStringUnder250Bytes): Promise<{ valid: true; @@ -3831,7 +3730,7 @@ export default class WindowCWISubstrate implements WalletInterface { ]; keyID: KeyIDStringUnder800Bytes; privilegedReason?: DescriptionString5to50Bytes; - counterparty?: PubKeyHex; + counterparty?: PubKeyHex | "self" | "anyone"; privileged?: BooleanDefaultFalse; }, originator?: OriginatorDomainNameStringUnder250Bytes): Promise<{ signature: Byte[]; @@ -3846,13 +3745,33 @@ export default class WindowCWISubstrate implements WalletInterface { ]; keyID: KeyIDStringUnder800Bytes; privilegedReason?: DescriptionString5to50Bytes; - counterparty?: PubKeyHex; + counterparty?: PubKeyHex | "self" | "anyone"; forSelf?: BooleanDefaultFalse; privileged?: BooleanDefaultFalse; }, originator?: OriginatorDomainNameStringUnder250Bytes): Promise<{ valid: true; }> - async acquireCertificate(args: AcquireCertificateArgs, originator?: OriginatorDomainNameStringUnder250Bytes): Promise + async acquireCertificate(args: { + type: Base64String; + subject: PubKeyHex; + serialNumber: Base64String; + revocationOutpoint: OutpointString; + signature: HexString; + fields: Record; + certifier: PubKeyHex; + keyringRevealer: PubKeyHex | "certifier"; + keyringForSubject: Record; + acquisitionProtocol: "direct" | "issuance"; + certifierUrl?: string; + }, originator?: OriginatorDomainNameStringUnder250Bytes): Promise<{ + type: Base64String; + subject: PubKeyHex; + serialNumber: Base64String; + certifier: PubKeyHex; + revocationOutpoint: OutpointString; + signature: HexString; + fields: Record; + }> async listCertificates(args: { certifiers: PubKeyHex[]; types: Base64String[]; @@ -3872,7 +3791,23 @@ export default class WindowCWISubstrate implements WalletInterface { fields: Record; }>; }> - async proveCertificate(args: ProveCertificateArgs, originator?: OriginatorDomainNameStringUnder250Bytes): Promise + async proveCertificate(args: { + certificate: { + type: Base64String; + subject: PubKeyHex; + serialNumber: Base64String; + certifier: PubKeyHex; + revocationOutpoint: OutpointString; + signature: HexString; + fields: Record; + }; + fieldsToReveal: CertificateFieldNameUnder50Bytes[]; + verifier: PubKeyHex; + privileged?: BooleanDefaultFalse; + privilegedReason?: DescriptionString5to50Bytes; + }, originator?: OriginatorDomainNameStringUnder250Bytes): Promise<{ + keyringForVerifier: Record; + }> async relinquishCertificate(args: { type: Base64String; serialNumber: Base64String; @@ -3951,7 +3886,7 @@ export default class WindowCWISubstrate implements WalletInterface { } ``` -See also: [AcquireCertificateArgs](./wallet.md#interface-acquirecertificateargs), [BEEF](./wallet.md#type-beef), [Base64String](./wallet.md#type-base64string), [BasketStringUnder300Bytes](./wallet.md#type-basketstringunder300bytes), [BooleanDefaultFalse](./wallet.md#type-booleandefaultfalse), [BooleanDefaultTrue](./wallet.md#type-booleandefaulttrue), [Byte](./wallet.md#type-byte), [CertificateFieldNameUnder50Bytes](./wallet.md#type-certificatefieldnameunder50bytes), [DescriptionString5to50Bytes](./wallet.md#type-descriptionstring5to50bytes), [EntityIconURLStringMax500Bytes](./wallet.md#type-entityiconurlstringmax500bytes), [EntityNameStringMax100Bytes](./wallet.md#type-entitynamestringmax100bytes), [HexString](./wallet.md#type-hexstring), [ISOTimestampString](./wallet.md#type-isotimestampstring), [KeyIDStringUnder800Bytes](./wallet.md#type-keyidstringunder800bytes), [LabelStringUnder300Bytes](./wallet.md#type-labelstringunder300bytes), [OriginatorDomainNameStringUnder250Bytes](./wallet.md#type-originatordomainnamestringunder250bytes), [OutpointString](./wallet.md#type-outpointstring), [OutputTagStringUnder300Bytes](./wallet.md#type-outputtagstringunder300bytes), [PositiveInteger](./wallet.md#type-positiveinteger), [PositiveIntegerDefault10Max10000](./wallet.md#type-positiveintegerdefault10max10000), [PositiveIntegerMax10](./wallet.md#type-positiveintegermax10), [PositiveIntegerOrZero](./wallet.md#type-positiveintegerorzero), [ProtocolString5To400Bytes](./wallet.md#type-protocolstring5to400bytes), [ProveCertificateArgs](./wallet.md#interface-provecertificateargs), [ProveCertificateResult](./wallet.md#interface-provecertificateresult), [PubKeyHex](./wallet.md#type-pubkeyhex), [SatoshiValue](./wallet.md#type-satoshivalue), [SecurityLevel](./wallet.md#type-securitylevel), [SignActionArgs](./wallet.md#interface-signactionargs), [SignActionResult](./wallet.md#interface-signactionresult), [TXIDHexString](./wallet.md#type-txidhexstring), [VersionString7To30Bytes](./wallet.md#type-versionstring7to30bytes), [WalletCertificate](./wallet.md#interface-walletcertificate), [WalletInterface](./wallet.md#interface-walletinterface), [decrypt](./messages.md#variable-decrypt), [encrypt](./messages.md#variable-encrypt) +See also: [BEEF](./wallet.md#type-beef), [Base64String](./wallet.md#type-base64string), [BasketStringUnder300Bytes](./wallet.md#type-basketstringunder300bytes), [BooleanDefaultFalse](./wallet.md#type-booleandefaultfalse), [BooleanDefaultTrue](./wallet.md#type-booleandefaulttrue), [Byte](./wallet.md#type-byte), [CertificateFieldNameUnder50Bytes](./wallet.md#type-certificatefieldnameunder50bytes), [DescriptionString5to50Bytes](./wallet.md#type-descriptionstring5to50bytes), [EntityIconURLStringMax500Bytes](./wallet.md#type-entityiconurlstringmax500bytes), [EntityNameStringMax100Bytes](./wallet.md#type-entitynamestringmax100bytes), [HexString](./wallet.md#type-hexstring), [ISOTimestampString](./wallet.md#type-isotimestampstring), [KeyIDStringUnder800Bytes](./wallet.md#type-keyidstringunder800bytes), [LabelStringUnder300Bytes](./wallet.md#type-labelstringunder300bytes), [OriginatorDomainNameStringUnder250Bytes](./wallet.md#type-originatordomainnamestringunder250bytes), [OutpointString](./wallet.md#type-outpointstring), [OutputTagStringUnder300Bytes](./wallet.md#type-outputtagstringunder300bytes), [PositiveInteger](./wallet.md#type-positiveinteger), [PositiveIntegerDefault10Max10000](./wallet.md#type-positiveintegerdefault10max10000), [PositiveIntegerMax10](./wallet.md#type-positiveintegermax10), [PositiveIntegerOrZero](./wallet.md#type-positiveintegerorzero), [ProtocolString5To400Bytes](./wallet.md#type-protocolstring5to400bytes), [PubKeyHex](./wallet.md#type-pubkeyhex), [SatoshiValue](./wallet.md#type-satoshivalue), [SecurityLevel](./wallet.md#type-securitylevel), [TXIDHexString](./wallet.md#type-txidhexstring), [VersionString7To30Bytes](./wallet.md#type-versionstring7to30bytes), [WalletInterface](./wallet.md#interface-walletinterface), [decrypt](./messages.md#variable-decrypt), [encrypt](./messages.md#variable-encrypt) Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes), [Functions](#functions), [Types](#types), [Enums](#enums), [Variables](#variables) @@ -3960,11 +3895,6 @@ Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes), [Functions]( Facilitates wallet operations over cross-document messaging. -The default wildcard target supports wallets embedded by public web apps, -including callers with opaque origins. Configure an exact origin when the -parent is known. Responses must always come from the current parent window; -exact-origin mode additionally requires the configured origin. - ```ts export default class XDMSubstrate extends InvokableWalletBase { constructor(domain: string = "*") @@ -3981,35 +3911,22 @@ Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes), [Functions]( | | | | | --- | --- | --- | -| [brc100JsonReplacer](#function-brc100jsonreplacer) | [validateBasketInsertion](#function-validatebasketinsertion) | [validateOptionalInteger](#function-validateoptionalinteger) | -| [isHexString](#function-ishexstring) | [validateCreateActionArgs](#function-validatecreateactionargs) | [validateOptionalOutpointString](#function-validateoptionaloutpointstring) | -| [normalizeBRC100ByteArray](#function-normalizebrc100bytearray) | [validateCreateActionInput](#function-validatecreateactioninput) | [validateOriginator](#function-validateoriginator) | -| [normalizeBRC100ByteFields](#function-normalizebrc100bytefields) | [validateCreateActionOptions](#function-validatecreateactionoptions) | [validateOutpointString](#function-validateoutpointstring) | -| [normalizeBRC100WalletByteFields](#function-normalizebrc100walletbytefields) | [validateCreateActionOutput](#function-validatecreateactionoutput) | [validatePositiveIntegerOrZero](#function-validatepositiveintegerorzero) | -| [parseWalletOutpoint](#function-parsewalletoutpoint) | [validateDiscoverByAttributesArgs](#function-validatediscoverbyattributesargs) | [validateProveCertificateArgs](#function-validateprovecertificateargs) | -| [stringifyBRC100](#function-stringifybrc100) | [validateDiscoverByIdentityKeyArgs](#function-validatediscoverbyidentitykeyargs) | [validateRelinquishCertificateArgs](#function-validaterelinquishcertificateargs) | -| [toBRC100PortableByteArray](#function-tobrc100portablebytearray) | [validateInteger](#function-validateinteger) | [validateRelinquishOutputArgs](#function-validaterelinquishoutputargs) | -| [toOriginHeader](#function-tooriginheader) | [validateInternalizeActionArgs](#function-validateinternalizeactionargs) | [validateSatoshis](#function-validatesatoshis) | -| [validateAbortActionArgs](#function-validateabortactionargs) | [validateInternalizeOutput](#function-validateinternalizeoutput) | [validateSignActionArgs](#function-validatesignactionargs) | -| [validateAcquireDirectCertificateArgs](#function-validateacquiredirectcertificateargs) | [validateListActionsArgs](#function-validatelistactionsargs) | [validateSignActionOptions](#function-validatesignactionoptions) | -| [validateAcquireIssuanceCertificateArgs](#function-validateacquireissuancecertificateargs) | [validateListCertificatesArgs](#function-validatelistcertificatesargs) | [validateStringLength](#function-validatestringlength) | -| [validateBase64String](#function-validatebase64string) | [validateListOutputsArgs](#function-validatelistoutputsargs) | [validateWalletPayment](#function-validatewalletpayment) | +| [isHexString](#function-ishexstring) | [validateCreateActionOutput](#function-validatecreateactionoutput) | [validateOriginator](#function-validateoriginator) | +| [parseWalletOutpoint](#function-parsewalletoutpoint) | [validateDiscoverByAttributesArgs](#function-validatediscoverbyattributesargs) | [validateOutpointString](#function-validateoutpointstring) | +| [toOriginHeader](#function-tooriginheader) | [validateDiscoverByIdentityKeyArgs](#function-validatediscoverbyidentitykeyargs) | [validatePositiveIntegerOrZero](#function-validatepositiveintegerorzero) | +| [validateAbortActionArgs](#function-validateabortactionargs) | [validateInteger](#function-validateinteger) | [validateProveCertificateArgs](#function-validateprovecertificateargs) | +| [validateAcquireDirectCertificateArgs](#function-validateacquiredirectcertificateargs) | [validateInternalizeActionArgs](#function-validateinternalizeactionargs) | [validateRelinquishCertificateArgs](#function-validaterelinquishcertificateargs) | +| [validateAcquireIssuanceCertificateArgs](#function-validateacquireissuancecertificateargs) | [validateInternalizeOutput](#function-validateinternalizeoutput) | [validateRelinquishOutputArgs](#function-validaterelinquishoutputargs) | +| [validateBase64String](#function-validatebase64string) | [validateListActionsArgs](#function-validatelistactionsargs) | [validateSatoshis](#function-validatesatoshis) | +| [validateBasketInsertion](#function-validatebasketinsertion) | [validateListCertificatesArgs](#function-validatelistcertificatesargs) | [validateSignActionArgs](#function-validatesignactionargs) | +| [validateCreateActionArgs](#function-validatecreateactionargs) | [validateListOutputsArgs](#function-validatelistoutputsargs) | [validateSignActionOptions](#function-validatesignactionoptions) | +| [validateCreateActionInput](#function-validatecreateactioninput) | [validateOptionalInteger](#function-validateoptionalinteger) | [validateStringLength](#function-validatestringlength) | +| [validateCreateActionOptions](#function-validatecreateactionoptions) | [validateOptionalOutpointString](#function-validateoptionaloutpointstring) | [validateWalletPayment](#function-validatewalletpayment) | Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes), [Functions](#functions), [Types](#types), [Enums](#enums), [Variables](#variables) --- -### Function: brc100JsonReplacer - -JSON replacer that preserves `Uint8Array` values as portable JSON arrays. - -```ts -export function brc100JsonReplacer(this: Record, key: string, value: unknown): unknown -``` - -Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes), [Functions](#functions), [Types](#types), [Enums](#enums), [Variables](#variables) - ---- ### Function: isHexString Check whether a string is a valid hex string (even length and hex characters). @@ -4029,50 +3946,6 @@ Argument Details Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes), [Functions](#functions), [Types](#types), [Enums](#enums), [Variables](#variables) ---- -### Function: normalizeBRC100ByteArray - -Normalizes the runtime representations used for BRC-100 byte arrays. - -Healthy `number[]` and `Uint8Array` values are returned by identity so the -common path does not allocate. The fallback recovers the contiguous -numeric-key object produced by `JSON.stringify(new Uint8Array(...))` in -historical JSON transports. Invalid, sparse, or non-byte input is rejected. - -```ts -export function normalizeBRC100ByteArray(value: unknown): AtomicBEEF | undefined -``` - -See also: [AtomicBEEF](./wallet.md#type-atomicbeef) - -Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes), [Functions](#functions), [Types](#types), [Enums](#enums), [Variables](#variables) - ---- -### Function: normalizeBRC100ByteFields - -Repairs byte arrays only in explicitly selected own fields of one protocol -object. This non-recursive helper is for envelopes that also contain opaque -application data, where field-name-based traversal would be destructive. - -```ts -export function normalizeBRC100ByteFields(value: T, fieldNames: readonly string[]): T -``` - -Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes), [Functions](#functions), [Types](#types), [Enums](#enums), [Variables](#variables) - ---- -### Function: normalizeBRC100WalletByteFields - -Repairs known byte fields in a wallet request, result, or serialized wallet -error. This is intentionally field-aware: unrelated numeric-key objects are -left untouched. Parsed JSON objects are normalized in place. - -```ts -export function normalizeBRC100WalletByteFields(value: T): T -``` - -Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes), [Functions](#functions), [Types](#types), [Enums](#enums), [Variables](#variables) - --- ### Function: parseWalletOutpoint @@ -4085,28 +3958,6 @@ export function parseWalletOutpoint(outpoint: string): { Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes), [Functions](#functions), [Types](#types), [Enums](#enums), [Variables](#variables) ---- -### Function: stringifyBRC100 - -Serialize a BRC-100 payload without allowing typed byte arrays to become objects. - -```ts -export function stringifyBRC100(value: unknown, space?: string | number): string -``` - -Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes), [Functions](#functions), [Types](#types), [Enums](#enums), [Variables](#variables) - ---- -### Function: toBRC100PortableByteArray - -Convert a valid BRC-100 byte array to the portable JSON `number[]` form. - -```ts -export function toBRC100PortableByteArray(value: unknown): number[] | undefined -``` - -Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes), [Functions](#functions), [Types](#types), [Enums](#enums), [Variables](#variables) - --- ### Function: toOriginHeader @@ -4965,12 +4816,6 @@ Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes), [Functions]( --- ### Type: BooleanDefaultFalse -Analyzer note: the named primitive aliases in this file are the public -BRC-100 wire vocabulary. Their names and JSDoc constraints feed generated -API documentation and communicate semantics that primitive types cannot. -S6564 is therefore suppressed on those declarations instead of erasing the -public protocol vocabulary. - ```ts export type BooleanDefaultFalse = boolean ``` @@ -5017,7 +4862,7 @@ Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes), [Functions]( ### Type: Counterparty ```ts -export type Counterparty = PublicKey | PubKeyHex +export type Counterparty = PublicKey | PubKeyHex | "self" | "anyone" ``` See also: [PubKeyHex](./wallet.md#type-pubkeyhex), [PublicKey](./primitives.md#class-publickey) @@ -5111,7 +4956,7 @@ Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes), [Functions]( ### Type: KeyringRevealer ```ts -export type KeyringRevealer = PubKeyHex +export type KeyringRevealer = PubKeyHex | "certifier" ``` See also: [PubKeyHex](./wallet.md#type-pubkeyhex) @@ -5310,7 +5155,7 @@ Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes), [Functions]( ### Type: WalletCounterparty ```ts -export type WalletCounterparty = PubKeyHex +export type WalletCounterparty = PubKeyHex | "self" | "anyone" ``` See also: [PubKeyHex](./wallet.md#type-pubkeyhex) From 82d9f4bb81618ba47542f3c0064b3971f364fe2b Mon Sep 17 00:00:00 2001 From: Deggen Date: Fri, 18 Sep 2026 15:36:19 -0500 Subject: [PATCH 48/58] fix(sdk): reject cancelled lookups instead of answering empty query() and queryDetailed() flatten query$ to its first snapshot. The terminal gate only threw when hostCount was 0, and explicitly skipped the throw for terminalReason 'cancelled', so a caller abort resolved as { type: 'output-list', outputs: [] }. At any host count an aborted attempt was indistinguishable from an authoritative "this service has no outputs" answer, which is exactly the completion-vs-empty confusion queryDetailed exists to prevent. Throw lookupAbortError() before the host-count gate whenever the terminal snapshot reports 'cancelled'. query$ is unchanged and still emits its terminalReason: 'cancelled' snapshot for progressive callers. Co-Authored-By: Claude Fable 5.1 --- packages/sdk/CHANGELOG.md | 4 ++ packages/sdk/docs/reference/overlay-tools.md | 11 ++++ .../sdk/src/overlay-tools/LookupResolver.ts | 25 ++++++-- .../__tests/LookupResolver.dynamic.test.ts | 57 +++++++++++++++++++ 4 files changed, 93 insertions(+), 4 deletions(-) diff --git a/packages/sdk/CHANGELOG.md b/packages/sdk/CHANGELOG.md index 6f84b4ca6..f144a6a27 100644 --- a/packages/sdk/CHANGELOG.md +++ b/packages/sdk/CHANGELOG.md @@ -282,6 +282,10 @@ All notable changes to this project will be documented in this file. The format - LookupResolver host cache no longer lets a tighter-limit discovery satisfy a later larger query, and `query()` still throws the historical no-competent-hosts error when a deadline expires before any host is admitted. +- `LookupResolver.query()` and `queryDetailed()` now reject with an `AbortError` + when the caller's `options.signal` cancels the attempt, at any host count, + instead of flattening a cancelled run into an empty output list. `query$()` + still reports the cancellation as a `terminalReason: 'cancelled'` snapshot. - Batch BEEF mutation bookkeeping and reuse compound Merkle intermediate hashes. The optional asynchronous P2PKH backend now forwards its already validated compressed public key directly into the unlocking script. Existing BEEF diff --git a/packages/sdk/docs/reference/overlay-tools.md b/packages/sdk/docs/reference/overlay-tools.md index 4bd68bade..ff68d88dc 100644 --- a/packages/sdk/docs/reference/overlay-tools.md +++ b/packages/sdk/docs/reference/overlay-tools.md @@ -371,6 +371,10 @@ See also: [UnreachableHostInfo](./overlay-tools.md#interface-unreachablehostinfo #### Property signal Abort this query without cancelling discovery still owned by another query. +`query()` and `queryDetailed()` reject with an `AbortError` once this +signal fires: a cancelled attempt never answered the question, so it is +never reported as an empty output list. `query$()` keeps emitting its +terminal snapshot with `terminalReason: 'cancelled'` instead. ```ts signal?: AbortSignal @@ -965,6 +969,9 @@ Optional `options.graceMs` overrides the per-call grace window (default 80 ms). 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`). +Throws an `AbortError` when `options.signal` aborted the attempt, so a +cancelled lookup is never mistaken for an authoritative empty answer. + ```ts async query(question: LookupQuestion, timeout?: number, options?: LookupQueryOptions): Promise ``` @@ -987,6 +994,10 @@ Performs a lookup and returns both its answer and the host settlement evidence required by security-sensitive consumers to distinguish an authoritative empty result from an availability failure. +Throws an `AbortError` when `options.signal` aborted the attempt, rather +than returning a resolution whose empty answer would have to be +re-qualified against `progress.terminalReason`. + ```ts async queryDetailed(question: LookupQuestion, timeout?: number, options?: LookupQueryOptions): Promise ``` diff --git a/packages/sdk/src/overlay-tools/LookupResolver.ts b/packages/sdk/src/overlay-tools/LookupResolver.ts index 274d5f392..3e3c8b63d 100644 --- a/packages/sdk/src/overlay-tools/LookupResolver.ts +++ b/packages/sdk/src/overlay-tools/LookupResolver.ts @@ -60,7 +60,13 @@ export type LookupFacilitatorAnswer = LookupAnswer | LookupFreeformAnswer * All optional; defaults preserve prior behavior. */ export interface LookupQueryOptions { - /** Abort this query without cancelling discovery still owned by another query. */ + /** + * Abort this query without cancelling discovery still owned by another query. + * `query()` and `queryDetailed()` reject with an `AbortError` once this + * signal fires: a cancelled attempt never answered the question, so it is + * never reported as an empty output list. `query$()` keeps emitting its + * terminal snapshot with `terminalReason: 'cancelled'` instead. + */ signal?: AbortSignal /** * Callback intake budget, independent of legacy aggregation. Defaults to 512 @@ -968,6 +974,9 @@ export default class LookupResolver { * Optional `options.graceMs` overrides the per-call grace window (default 80 ms). * 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`). + * + * Throws an `AbortError` when `options.signal` aborted the attempt, so a + * cancelled lookup is never mistaken for an authoritative empty answer. */ async query( question: LookupQuestion, @@ -981,6 +990,10 @@ export default class LookupResolver { * Performs a lookup and returns both its answer and the host settlement * evidence required by security-sensitive consumers to distinguish an * authoritative empty result from an availability failure. + * + * Throws an `AbortError` when `options.signal` aborted the attempt, rather + * than returning a resolution whose empty answer would have to be + * re-qualified against `progress.terminalReason`. */ async queryDetailed( question: LookupQuestion, @@ -1020,9 +1033,13 @@ export default class LookupResolver { terminalReason: 'settled', ...(options?.correlationId !== undefined ? { correlationId: options.correlationId } : {}) } - // Promise callers cannot see terminalReason. A deadline that admitted no - // host is a miss, not a successful empty answer from a queried host. - if (progress.hostCount === 0 && progress.terminalReason !== 'cancelled') { + // Promise callers cannot see terminalReason. A cancelled attempt never + // answered the question, so it must not flatten into an empty output list + // at any host count. + if (progress.terminalReason === 'cancelled') throw lookupAbortError() + // A deadline that admitted no host is a miss, not a successful empty + // answer from a queried host. + if (progress.hostCount === 0) { throw new Error( `No competent ${this.networkPreset} hosts found by the SLAP trackers for lookup service: ${question.service}` ) diff --git a/packages/sdk/src/overlay-tools/__tests/LookupResolver.dynamic.test.ts b/packages/sdk/src/overlay-tools/__tests/LookupResolver.dynamic.test.ts index c6d78fa33..25e8c3cb5 100644 --- a/packages/sdk/src/overlay-tools/__tests/LookupResolver.dynamic.test.ts +++ b/packages/sdk/src/overlay-tools/__tests/LookupResolver.dynamic.test.ts @@ -238,6 +238,63 @@ describe('LookupResolver dynamic discovery', () => { await expect(iterator.next()).resolves.toEqual({ done: true, value: undefined }) }) + it('rejects query() with an AbortError instead of an empty answer when a queried host is aborted', async () => { + const host = 'https://abort-query.example' + const controller = new AbortController() + const lookup = jest.fn( + async (_url: string, _question: unknown, _timeout: unknown, signal?: AbortSignal) => + await new Promise((_resolve, reject) => { + signal?.addEventListener('abort', () => reject(signal.reason), { once: true }) + }) + ) + const resolver = new LookupResolver({ + facilitator: { lookup } as any, + hostOverrides: { ls_abort_query: [host] } + }) + const pending = resolver.query({ service: 'ls_abort_query', query: {} }, undefined, { + signal: controller.signal + }) + pending.catch(() => { + /* asserted below */ + }) + + await jest.advanceTimersByTimeAsync(1) + expect(lookup).toHaveBeenCalledTimes(1) + controller.abort(new Error('caller stopped lookup')) + await jest.advanceTimersByTimeAsync(1) + + await expect(pending).rejects.toMatchObject({ + name: 'AbortError', + message: 'Lookup cancelled' + }) + }) + + it('rejects queryDetailed() with an AbortError when the caller aborts before a host is admitted', async () => { + const controller = new AbortController() + controller.abort(new Error('caller stopped lookup')) + const lookup = jest.fn() + const resolver = new LookupResolver({ + facilitator: { lookup } as any, + hostOverrides: { ls_abort_early: ['https://abort-early.example'] } + }) + const pending = resolver.queryDetailed( + { service: 'ls_abort_early', query: {} }, + undefined, + { signal: controller.signal } + ) + pending.catch(() => { + /* asserted below */ + }) + + await jest.advanceTimersByTimeAsync(1) + + await expect(pending).rejects.toMatchObject({ + name: 'AbortError', + message: 'Lookup cancelled' + }) + expect(lookup).not.toHaveBeenCalled() + }) + it('emits a deadline terminal snapshot when no host receipt arrives', async () => { const host = 'https://deadline.example' const lookup = jest.fn( From 7f8bb2c3b1cae7c2099b28cdd62a648c572fe838 Mon Sep 17 00:00:00 2001 From: Deggen Date: Fri, 18 Sep 2026 15:39:26 -0500 Subject: [PATCH 49/58] fix(sdk): keep discovery resource limits out of the no-hosts error chargeLookupDiscoveryBytes can exhaust maxTotalBytes while SLAP trackers are still being read, before any host is admitted. That leaves the terminal snapshot with hostCount 0, terminalReason 'resource-limit' and the limit recorded in limitsHit. queryDetailed()'s hostCount gate did not look at terminalReason, so it threw the historical "No competent hosts found by the SLAP trackers" Error and discarded both fields. Security-sensitive Promise callers could not tell "the trackers named no hosts" from "this attempt ran out of its own byte budget". Branch the gate on terminalReason: 'resource-limit' throws LookupResourceLimitError carrying the first limit that was hit, while 'deadline' and a settled empty discovery keep the historical error and message unchanged. Co-Authored-By: Claude Fable 5.1 --- packages/sdk/CHANGELOG.md | 4 ++ packages/sdk/docs/reference/overlay-tools.md | 6 ++- .../sdk/src/overlay-tools/LookupResolver.ts | 14 ++++++- .../__tests/LookupResolver.dynamic.test.ts | 39 ++++++++++++++++++- 4 files changed, 59 insertions(+), 4 deletions(-) diff --git a/packages/sdk/CHANGELOG.md b/packages/sdk/CHANGELOG.md index f144a6a27..fb724536d 100644 --- a/packages/sdk/CHANGELOG.md +++ b/packages/sdk/CHANGELOG.md @@ -286,6 +286,10 @@ All notable changes to this project will be documented in this file. The format when the caller's `options.signal` cancels the attempt, at any host count, instead of flattening a cancelled run into an empty output list. `query$()` still reports the cancellation as a `terminalReason: 'cancelled'` snapshot. +- A lookup that exhausts a client resource budget during SLAP discovery, before + any host is admitted, now throws `LookupResourceLimitError` naming the limit + instead of the historical no-competent-hosts error. That message is reserved + for a deadline or a settled attempt that genuinely found no host. - Batch BEEF mutation bookkeeping and reuse compound Merkle intermediate hashes. The optional asynchronous P2PKH backend now forwards its already validated compressed public key directly into the unlocking script. Existing BEEF diff --git a/packages/sdk/docs/reference/overlay-tools.md b/packages/sdk/docs/reference/overlay-tools.md index ff68d88dc..c1118dddb 100644 --- a/packages/sdk/docs/reference/overlay-tools.md +++ b/packages/sdk/docs/reference/overlay-tools.md @@ -996,7 +996,11 @@ authoritative empty result from an availability failure. Throws an `AbortError` when `options.signal` aborted the attempt, rather than returning a resolution whose empty answer would have to be -re-qualified against `progress.terminalReason`. +re-qualified against `progress.terminalReason`. When a client resource +budget was exhausted during SLAP discovery, before any host could be +admitted, it throws `LookupResourceLimitError` naming that limit; the +historical no-competent-hosts error is reserved for a deadline or a +settled attempt that genuinely found no host. ```ts async queryDetailed(question: LookupQuestion, timeout?: number, options?: LookupQueryOptions): Promise diff --git a/packages/sdk/src/overlay-tools/LookupResolver.ts b/packages/sdk/src/overlay-tools/LookupResolver.ts index 3e3c8b63d..a5ad0bb30 100644 --- a/packages/sdk/src/overlay-tools/LookupResolver.ts +++ b/packages/sdk/src/overlay-tools/LookupResolver.ts @@ -993,7 +993,11 @@ export default class LookupResolver { * * Throws an `AbortError` when `options.signal` aborted the attempt, rather * than returning a resolution whose empty answer would have to be - * re-qualified against `progress.terminalReason`. + * re-qualified against `progress.terminalReason`. When a client resource + * budget was exhausted during SLAP discovery, before any host could be + * admitted, it throws `LookupResourceLimitError` naming that limit; the + * historical no-competent-hosts error is reserved for a deadline or a + * settled attempt that genuinely found no host. */ async queryDetailed( question: LookupQuestion, @@ -1038,8 +1042,14 @@ export default class LookupResolver { // at any host count. if (progress.terminalReason === 'cancelled') throw lookupAbortError() // A deadline that admitted no host is a miss, not a successful empty - // answer from a queried host. + // answer from a queried host. An attempt that exhausted a client resource + // budget during discovery is a third outcome: the trackers were never + // given the chance to name a host, so it keeps its own error and limit + // rather than borrowing the no-competent-hosts message. if (progress.hostCount === 0) { + if (progress.terminalReason === 'resource-limit') { + throw new LookupResourceLimitError(progress.limitsHit?.[0] ?? 'resource-limit') + } throw new Error( `No competent ${this.networkPreset} hosts found by the SLAP trackers for lookup service: ${question.service}` ) diff --git a/packages/sdk/src/overlay-tools/__tests/LookupResolver.dynamic.test.ts b/packages/sdk/src/overlay-tools/__tests/LookupResolver.dynamic.test.ts index 25e8c3cb5..f90b61755 100644 --- a/packages/sdk/src/overlay-tools/__tests/LookupResolver.dynamic.test.ts +++ b/packages/sdk/src/overlay-tools/__tests/LookupResolver.dynamic.test.ts @@ -1,6 +1,7 @@ import LookupResolver, { HTTPSOverlayLookupFacilitator, - LookupAnswerProgress + LookupAnswerProgress, + LookupResourceLimitError } from '../LookupResolver' import { getOverlayHostReputationTracker } from '../HostReputationTracker' import OverlayAdminTokenTemplate from '../OverlayAdminTokenTemplate' @@ -993,6 +994,42 @@ describe('LookupResolver dynamic discovery', () => { await second.return?.() }) + it('throws a resource-limit error when discovery exhausts the byte budget before any host is admitted', async () => { + const tracker = 'https://discovery-limit-tracker.example' + const lookup = jest.fn( + async ( + _url: string, + _question: unknown, + _timeout: unknown, + _signal?: AbortSignal, + options?: { consumeBytes?: (bytes: number) => void } + ) => { + options?.consumeBytes?.(4096) + return { type: 'output-list' as const, outputs: [] } + } + ) + const resolver = new LookupResolver({ + facilitator: { lookup } as any, + slapTrackers: [tracker] + }) + const pending = resolver.queryDetailed( + { service: 'ls_discovery_limit', query: {} }, + undefined, + { limits: { maxTotalBytes: 1024 } } + ) + pending.catch(() => { + /* asserted below */ + }) + + await jest.runAllTimersAsync() + + await expect(pending).rejects.toBeInstanceOf(LookupResourceLimitError) + await expect(pending).rejects.toMatchObject({ + name: 'LookupResourceLimitError', + limit: 'maxTotalBytes' + }) + }) + it('throws from query() when a deadline expires before any host is admitted', async () => { const tracker = 'https://deadline-miss-tracker.example' const lookup = jest.fn( From e87ef2e0c0017364eae4ba90ec0e69aa85de48bb Mon Sep 17 00:00:00 2001 From: Deggen Date: Fri, 18 Sep 2026 15:42:05 -0500 Subject: [PATCH 50/58] fix(sdk): refuse redirects on overlay lookup and SLAP discovery requests performLookupRequest built its RequestInit without a redirect field, so fetch defaulted to 'follow'. normalizeLookupHost and the https: guard in lookup() validate the advertised URL only; neither runs again on the final URL. A SLAP-advertised HTTPS host could therefore answer the lookup POST with a 307/308 and have the serialized stringifyBRC100({ service, query }) body re-sent to http:, loopback or a link-local address. This PR queries every eligible advertised host, so the hop sat on the hot path for untrusted SLAP domains, and the SLAP tracker discovery requests reach the network through this same method. Set redirect: 'error' so the transport fails closed. fetch rejects, and the existing failure path records the advertised host as an ordinary availability failure instead of crashing the query or following the hop. Co-Authored-By: Claude Fable 5.1 --- packages/sdk/CHANGELOG.md | 6 ++ .../sdk/src/overlay-tools/LookupResolver.ts | 8 +++ .../__tests/LookupResolver.additional.test.ts | 55 +++++++++++++++++++ 3 files changed, 69 insertions(+) diff --git a/packages/sdk/CHANGELOG.md b/packages/sdk/CHANGELOG.md index fb724536d..98b28c465 100644 --- a/packages/sdk/CHANGELOG.md +++ b/packages/sdk/CHANGELOG.md @@ -398,6 +398,12 @@ All notable changes to this project will be documented in this file. The format ### Security +- `HTTPSOverlayLookupFacilitator` now issues lookup and SLAP tracker discovery + requests with `redirect: 'error'`. A SLAP-advertised host can no longer + redirect the serialized lookup body to an origin that the advertised-host + scheme and credential checks never saw, such as `http:`, loopback, or + link-local. A redirected response is recorded as an ordinary availability + failure for the advertised host. - Treat cryptographic verification as successful only when it returns an affirmative result: `GlobalKVStore` rejects forged controller-signed overlay values, and `IdentityClient` refuses to publish signature-invalid identity diff --git a/packages/sdk/src/overlay-tools/LookupResolver.ts b/packages/sdk/src/overlay-tools/LookupResolver.ts index a5ad0bb30..ba93f4ca8 100644 --- a/packages/sdk/src/overlay-tools/LookupResolver.ts +++ b/packages/sdk/src/overlay-tools/LookupResolver.ts @@ -563,6 +563,14 @@ export class HTTPSOverlayLookupFacilitator implements OverlayLookupFacilitator { 'X-Aggregation': 'yes' }, body: stringifyBRC100({ service: question.service, query: question.query }), + // normalizeLookupHost and the https: guard above validate the advertised + // URL only. A followed 307/308 would carry the serialized query body to + // an origin neither check ever saw, so an untrusted SLAP host could + // redirect a lookup (or a tracker discovery request, which uses this + // same path) to http:, loopback or link-local. Fail closed instead: the + // rejection is recorded as an ordinary availability failure for the + // advertised host. + redirect: 'error', signal } const response: Response = await this.fetchClient(`${url}/lookup`, fco) diff --git a/packages/sdk/src/overlay-tools/__tests/LookupResolver.additional.test.ts b/packages/sdk/src/overlay-tools/__tests/LookupResolver.additional.test.ts index 358dbba5f..3a782138a 100644 --- a/packages/sdk/src/overlay-tools/__tests/LookupResolver.additional.test.ts +++ b/packages/sdk/src/overlay-tools/__tests/LookupResolver.additional.test.ts @@ -489,6 +489,61 @@ describe('LookupResolver – additional coverage', () => { expect(result).toEqual({ type: 'output-list', outputs: [] }) }) + it('refuses to follow a redirect away from the advertised lookup host', async () => { + const mockFetch = jest + .fn() + .mockResolvedValue(jsonResponse({ type: 'output-list', outputs: [] })) + const facilitator = new HTTPSOverlayLookupFacilitator(mockFetch, false) + await facilitator.lookup('https://advertised.example', { service: 'ls_test', query: {} }) + expect(mockFetch).toHaveBeenCalledWith( + 'https://advertised.example/lookup', + expect.objectContaining({ redirect: 'error' }) + ) + }) + + it('treats a rejected redirect as a host failure rather than a crash', async () => { + // fetch rejects with a TypeError when redirect: 'error' meets a 307/308. + const mockFetch = jest.fn().mockRejectedValue(new TypeError('unexpected redirect')) + const resolver = new LookupResolver({ + facilitator: new HTTPSOverlayLookupFacilitator(mockFetch, false), + hostOverrides: { ls_redirect: ['https://redirecting.example'] } + }) + + const result = await resolver.queryDetailed({ service: 'ls_redirect', query: {} }) + + expect(result.answer).toEqual({ type: 'output-list', outputs: [] }) + expect(result.progress).toMatchObject({ + hostCount: 1, + failedHosts: 1, + successfulHosts: 0, + rejectedHosts: 0, + terminalReason: 'settled' + }) + expect(mockFetch).toHaveBeenCalledTimes(1) + expect(mockFetch).toHaveBeenCalledWith( + 'https://redirecting.example/lookup', + expect.objectContaining({ redirect: 'error' }) + ) + }) + + it('refuses to follow a redirect on SLAP tracker discovery requests', async () => { + const mockFetch = jest + .fn() + .mockResolvedValue(jsonResponse({ type: 'output-list', outputs: [] })) + const resolver = new LookupResolver({ + facilitator: new HTTPSOverlayLookupFacilitator(mockFetch, false), + slapTrackers: ['https://tracker.example'] + }) + + await expect(resolver.query({ service: 'ls_redirect_tracker', query: {} })).rejects.toThrow( + 'No competent mainnet hosts found' + ) + expect(mockFetch).toHaveBeenCalledWith( + 'https://tracker.example/lookup', + expect.objectContaining({ redirect: 'error' }) + ) + }) + it('handles HTTP error responses by throwing', async () => { const mockFetch = jest.fn().mockResolvedValue(jsonResponse({}, 503)) const facilitator = new HTTPSOverlayLookupFacilitator(mockFetch, true) From 091f03e0d83a265f5d153acf48088e174ebea023 Mon Sep 17 00:00:00 2001 From: Deggen Date: Fri, 18 Sep 2026 16:13:01 -0500 Subject: [PATCH 51/58] fix(sdk): re-queue displaced same-txid candidates instead of dropping them TransactionEvidenceCoordinator.run() shifts the next candidate off a job's queue before calling attempt(). If every concurrency slot is already in use (including slots still reserved by non-abortable, already-finished attempts; see the "does not free a non-abortable backend slot..." test), attempt() threw a plain TransactionEvidenceError('limit') and called the caller's `settled` callback. run()'s catch treated that exactly like a genuine verification failure: the candidate was gone (already shift()ed) and never pushed back, so a same-txid alternate candidate that had already been admitted to the job could be silently discarded without ever being tried, even though it was perfectly valid. Fix: attempt() now throws a dedicated ConcurrencyLimitSignal when the operation never started, and does not invoke `settled` (nothing was consumed, so no byte/candidate bookkeeping should be released). run()'s candidate loop catches that signal specifically, unshifts the candidate back onto job.candidates, marks the job not-running, and returns without looping or retrying itself. Re-admission is driven solely by pump() being invoked again when some other active attempt settles, so this cannot spin or busy-wait for a slot. Added a regression test that builds a genuine concurrency race: a non-abortable "ghost" attempt (cancelled while its script verification is still in flight) holds one of two slots, a job with two same-txid candidates (one invalid, one valid) takes the other, and a third waiting job is admitted into the slot freed by the first candidate's failure before the job's own retry can reclaim it. Before the fix this made the valid alternate candidate reject with 'limit'; the test fails for that reason on the old code and passes with the fix. Co-Authored-By: Claude Fable 5.1 --- .../TransactionEvidenceCoordinator.ts | 23 +++- .../TransactionEvidenceCoordinator.test.ts | 119 ++++++++++++++++++ 2 files changed, 140 insertions(+), 2 deletions(-) diff --git a/packages/sdk/src/transaction/TransactionEvidenceCoordinator.ts b/packages/sdk/src/transaction/TransactionEvidenceCoordinator.ts index bdeb252c6..fb7a520ce 100644 --- a/packages/sdk/src/transaction/TransactionEvidenceCoordinator.ts +++ b/packages/sdk/src/transaction/TransactionEvidenceCoordinator.ts @@ -67,6 +67,15 @@ function outcome(error: unknown): TransactionEvidenceError { return evidenceError(error) } +/** + * Internal-only signal: this attempt was never started because every concurrency + * slot is in use (including slots still reserved by non-abortable, already-finished + * work). It is distinct from TransactionEvidenceError('limit') so a candidate that + * was merely displaced can be re-queued instead of treated as a tried-and-failed + * candidate. + */ +class ConcurrencyLimitSignal extends Error {} + /** * Bounded, process-local transaction evidence work sharing. This is independent of * lookup services, certificates and trust ratings. Positive reuse always checks @@ -364,6 +373,14 @@ export class TransactionEvidenceCoordinator { this.finish(job, positive) return } catch (error_) { + if (error_ instanceof ConcurrencyLimitSignal) { + // Never attempted: put it back rather than consuming it as a failure. + // Re-admission is driven only by an active attempt's own settle (via + // pump()); we do not loop or retry here, so this cannot spin. + job.candidates.unshift(candidate) + job.running = false + return + } error = outcome(error_) } } @@ -379,8 +396,10 @@ export class TransactionEvidenceCoordinator { settled?: () => void ): Promise { if (this.activeAttempts >= this.limits.concurrentTransactions) { - settled?.() - throw new TransactionEvidenceError('limit') + // The operation never started, so there is nothing for `settled` to release: + // any candidate bytes stay reserved and, for the candidate loop, the caller + // re-queues the candidate instead of discarding it. + throw new ConcurrencyLimitSignal() } this.activeAttempts++ const controller = new AbortController() diff --git a/packages/sdk/src/transaction/__tests/TransactionEvidenceCoordinator.test.ts b/packages/sdk/src/transaction/__tests/TransactionEvidenceCoordinator.test.ts index 5eed91b31..bfbfe484b 100644 --- a/packages/sdk/src/transaction/__tests/TransactionEvidenceCoordinator.test.ts +++ b/packages/sdk/src/transaction/__tests/TransactionEvidenceCoordinator.test.ts @@ -265,6 +265,38 @@ async function twoAnchorFixture(): Promise<{ return { tracker, tx, evidence: tx.toBEEF(), roots } } +async function standaloneFixture( + keySeed: number +): Promise<{ tx: Transaction; root: string; evidence: number[] }> { + const key = new PrivateKey(keySeed) + const p2pkh = new P2PKH() + const source = new Transaction() + source.addInput({ + sourceTXID: '00'.repeat(32), + sourceOutputIndex: 0, + unlockingScript: Script.fromASM('OP_TRUE') + }) + source.addOutput({ satoshis: 10, lockingScript: p2pkh.lock(key.toAddress()) }) + source.merklePath = new MerklePath(height, [ + [ + { offset: 0, hash: source.id('hex'), txid: true }, + { offset: 1, duplicate: true } + ] + ]) + const root = source.merklePath.computeRoot(source.id('hex')) + + const tx = new Transaction() + tx.addInput({ + sourceTransaction: source, + sourceOutputIndex: 0, + unlockingScriptTemplate: p2pkh.unlock(key) + }) + tx.addOutput({ satoshis: 4, lockingScript: p2pkh.lock(key.toAddress()) }) + tx.addOutput({ satoshis: 4, lockingScript: p2pkh.lock(key.toAddress()) }) + await tx.sign() + return { tx, root, evidence: tx.toBEEF() } +} + function verifyParamsInJavaScript(params: BdkVerifyScriptsParams): boolean { const sigHashCache = { hashOutputsSingle: new Map() } for (const [inputIndex, input] of params.tx.inputs.entries()) { @@ -800,6 +832,93 @@ describe('TransactionEvidenceCoordinator', () => { expect(calls).toBeGreaterThanOrEqual(2) }) + it('re-queues a same-txid alternate candidate displaced by a concurrency-limited attempt instead of losing it', async () => { + const ghost = await standaloneFixture(90_001) + const jobA = await standaloneFixture(90_002) + const jobD = await standaloneFixture(90_003) + + const tracker = new LocalChainTracker() + tracker.roots.add(ghost.root) + tracker.roots.add(jobD.root) + // jobA's original root is deliberately left out of tracker.roots so its + // first candidate fails and the coordinator must fall back to the second. + + const scriptGate = deferred() + const scriptEntered = deferred() + const verifier: BdkVerifierInterface = { + supportsMemoryLimit: true, + verifyScripts: async params => verifyParamsInJavaScript(params), + verifyScriptsBatch: async params => { + if (params.some(one => one.tx.id('hex') === ghost.tx.id('hex'))) { + scriptEntered.resolve() + await scriptGate.promise + } + return params.map(verifyParamsInJavaScript) + } + } + + const subject = coordinator(tracker, { concurrentTransactions: 2 }, verifier) + + // 1. Create a "ghost": a consumer cancels while GHOST's script check (a + // non-abortable backend call, per "does not free a non-abortable backend + // slot..." above) is still in flight. The concurrency slot it holds is + // not released until that call actually settles, well after the job + // itself has been finished and removed from `this.work`. + const ghostAbort = new AbortController() + const ghostPromise = subject.verify( + { beef: ghost.evidence, outputIndex: 0 }, + { signal: ghostAbort.signal } + ) + await scriptEntered.promise + ghostAbort.abort() + await expectCode(ghostPromise, 'cancelled') + expect(subject.getStats()).toMatchObject({ pendingTransactions: 0, activeAttempts: 1 }) + + // 2. Build an alternate (good-root) candidate for job A's txid up front. + const alternate = Transaction.fromBEEF(jobA.evidence) + const altSource = alternate.inputs[0].sourceTransaction + if (altSource === undefined) throw new Error('fixture source is missing') + altSource.merklePath = new MerklePath(height, [ + [ + { offset: 0, hash: altSource.id('hex'), txid: true }, + { offset: 1, hash: '5b'.repeat(32) } + ] + ]) + tracker.roots.add(altSource.merklePath.computeRoot(altSource.id('hex'))) + + // 3. Admit job A with its (invalid-root) first candidate. Together with + // the ghost, this uses both of the coordinator's concurrency slots. + const badReceipt = subject.verify({ beef: jobA.evidence, outputIndex: 0 }) + expect(subject.getStats().activeAttempts).toBe(2) + + // 4. A third, unrelated transaction arrives while the coordinator is at + // capacity, so it must wait for a free slot. + const displacer = subject.verify({ beef: jobD.evidence, outputIndex: 0 }) + + // 5. Job A's alternate (good) candidate is admitted onto the SAME, + // already-running job. + const goodReceipt = subject.verify({ beef: alternate.toBEEF(), outputIndex: 1 }) + + // Job A's first (bad-root) candidate fails and frees a slot; the waiting + // job D is admitted into it before job A's own retry can reclaim it. If + // that freed-and-immediately-reclaimed slot causes job A's own alternate + // candidate to be discarded instead of re-queued, both of job A's + // consumers are lost even though the alternate candidate is valid. + // (Promise.allSettled, rather than three sequential `await expect`s, + // ensures an early rejection here is observed as a failing assertion + // instead of an unhandled rejection while a sibling promise is pending.) + const [badOutcome, goodOutcome, displacerOutcome] = await Promise.allSettled([ + badReceipt, + goodReceipt, + displacer + ]) + expect(badOutcome).toMatchObject({ status: 'fulfilled', value: { outputIndex: 0 } }) + expect(goodOutcome).toMatchObject({ status: 'fulfilled', value: { outputIndex: 1 } }) + expect(displacerOutcome).toMatchObject({ status: 'fulfilled', value: { outputIndex: 0 } }) + + scriptGate.resolve() + }) + it('preflights approved duplicate and conflicting ancestry before warmed script work can be reused', async () => { const { tracker, ancestor, firstEvidence, secondEvidence } = await sharedAncestorFixture() const confirmed = ancestor.inputs[0].sourceTransaction From c9afc9d0cafcedc0d5b24fa02972ed7eb882d8c3 Mon Sep 17 00:00:00 2001 From: Deggen Date: Fri, 18 Sep 2026 16:21:37 -0500 Subject: [PATCH 52/58] fix(wallet-toolbox): implement BHServiceClient.findChainTipHash Every TransactionEvidenceCoordinator attempt calls ChaintracksChainTracker.getVerificationContextToken(), which requires chaintracks.findChainTipHash(). BHServiceClient implemented that method as `throw new Error('Not implemented')` even though it already exposes a working findChainTipHeader() against /api/v1/chain/tip/longest. Any wallet using a BHServiceClient as options.chaintracks, or as a LocalChainTracker participating source, broke on every verification attempt. Fix: implement findChainTipHash() by delegating to the existing findChainTipHeader() and returning its hash, matching the same pattern GoChaintracksServiceClient already uses for the same interface method. No other ChaintracksClientApi contract surface changes. Added a regression test on BHServiceClient confirming findChainTipHash() resolves to findChainTipHeader()'s hash instead of throwing, and a regression test on ChaintracksChainTracker confirming getVerificationContextToken() succeeds end-to-end when backed by a BHServiceClient. Both fail with "Not implemented" on the old code and pass with the fix. Co-Authored-By: Claude Fable 5.1 --- .../src/services/chaintracker/BHServiceClient.ts | 2 +- .../chaintracker/__tests/BHServiceClient.test.ts | 13 +++++++++++-- .../__tests/ChaintracksChainTracker.test.ts | 14 ++++++++++++++ 3 files changed, 26 insertions(+), 3 deletions(-) diff --git a/packages/wallet/wallet-toolbox/src/services/chaintracker/BHServiceClient.ts b/packages/wallet/wallet-toolbox/src/services/chaintracker/BHServiceClient.ts index 2c442f453..467643192 100644 --- a/packages/wallet/wallet-toolbox/src/services/chaintracker/BHServiceClient.ts +++ b/packages/wallet/wallet-toolbox/src/services/chaintracker/BHServiceClient.ts @@ -195,7 +195,7 @@ export class BHServiceClient implements ChaintracksServiceClient { } async findChainTipHash(): Promise { - throw new Error('Not implemented') + return (await this.findChainTipHeader()).hash } async subscribeHeaders(_listener: HeaderListener): Promise { diff --git a/packages/wallet/wallet-toolbox/src/services/chaintracker/__tests/BHServiceClient.test.ts b/packages/wallet/wallet-toolbox/src/services/chaintracker/__tests/BHServiceClient.test.ts index c3d8e6722..d054acb2e 100644 --- a/packages/wallet/wallet-toolbox/src/services/chaintracker/__tests/BHServiceClient.test.ts +++ b/packages/wallet/wallet-toolbox/src/services/chaintracker/__tests/BHServiceClient.test.ts @@ -8,7 +8,7 @@ const queried = 'aa'.repeat(32) const canonical = 'bb'.repeat(32) const reorged = 'cc'.repeat(32) -function header(merkleRoot: string): BlockHeader { +function header(merkleRoot: string, hash: string = 'dd'.repeat(32)): BlockHeader { return { version: 1, previousHash: '00'.repeat(32), @@ -17,7 +17,7 @@ function header(merkleRoot: string): BlockHeader { bits: 1, nonce: 1, height, - hash: 'dd'.repeat(32) + hash } } @@ -89,6 +89,15 @@ describe('BHServiceClient height-root cache', () => { expect(client.cache[height]).toBeUndefined() }) + test('findChainTipHash returns the active chain tip header hash', async () => { + const client = bhsClient() + const tipHash = 'ee'.repeat(32) + jest.spyOn(client, 'findChainTipHeader').mockResolvedValue(header(canonical, tipHash)) + + await expect(client.findChainTipHash()).resolves.toBe(tipHash) + expect(client.findChainTipHeader).toHaveBeenCalledTimes(1) + }) + test('LocalChainTracker fallback re-reads BHServiceClient roots instead of the queried-root cache', async () => { const fallback = bhsClient() jest.spyOn(fallback, 'findHeaderForHeight').mockResolvedValue(header(canonical)) diff --git a/packages/wallet/wallet-toolbox/src/services/chaintracker/__tests/ChaintracksChainTracker.test.ts b/packages/wallet/wallet-toolbox/src/services/chaintracker/__tests/ChaintracksChainTracker.test.ts index 34ef1bbfa..df8598e38 100644 --- a/packages/wallet/wallet-toolbox/src/services/chaintracker/__tests/ChaintracksChainTracker.test.ts +++ b/packages/wallet/wallet-toolbox/src/services/chaintracker/__tests/ChaintracksChainTracker.test.ts @@ -1,5 +1,6 @@ import { ChaintracksChainTracker } from '../index.all' import { ChaintracksServiceClient } from '../chaintracks/ChaintracksServiceClient' +import { BHServiceClient } from '../BHServiceClient' import { sdk } from '../../../index.client' import { BlockHeader } from '../../../sdk/WalletServices.interfaces' @@ -216,6 +217,19 @@ describe('ChaintracksChaintracker tests', () => { expect(provider.subscribeReorgs).not.toHaveBeenCalled() }) + test('obtains a verification context token from a BHServiceClient chaintracks source', async () => { + const provider = new BHServiceClient('main', 'https://headers.example', 'test-key') + expect(provider.supportsReorgEvents).toBe(false) + const tipHash = 'aa'.repeat(32) + jest.spyOn(provider, 'findChainTipHeader').mockResolvedValue({ ...HEADER_877599, hash: tipHash }) + const subscribeReorgs = jest.spyOn(provider, 'subscribeReorgs') + const tracker = new ChaintracksChainTracker('main', provider) + + await expect(tracker.getVerificationContextToken()).resolves.toContain(tipHash) + expect(provider.findChainTipHeader).toHaveBeenCalledTimes(1) + expect(subscribeReorgs).not.toHaveBeenCalled() + }) + test('does not hide a registration failure from a built-in client that promises reorg events', async () => { class PromisingEventsClient extends ChaintracksServiceClient { override readonly supportsReorgEvents = true From 83c067098067973b813738abff9f51c3b689fb73 Mon Sep 17 00:00:00 2001 From: Deggen Date: Fri, 18 Sep 2026 16:25:17 -0500 Subject: [PATCH 53/58] docs: changelog entries for F1/F2 review-finding fixes Document the TransactionEvidenceCoordinator candidate-requeue fix and the BHServiceClient.findChainTipHash implementation in their packages' CHANGELOG.md, matching this repo's convention of recording behavior- affecting fixes there alongside the commit history. Co-Authored-By: Claude Fable 5.1 --- packages/sdk/CHANGELOG.md | 7 +++++++ packages/wallet/wallet-toolbox/CHANGELOG.md | 8 ++++++++ 2 files changed, 15 insertions(+) diff --git a/packages/sdk/CHANGELOG.md b/packages/sdk/CHANGELOG.md index 06fbeea69..e78e6e082 100644 --- a/packages/sdk/CHANGELOG.md +++ b/packages/sdk/CHANGELOG.md @@ -328,6 +328,13 @@ All notable changes to this project will be documented in this file. The format ### Fixed +- Re-queue a `TransactionEvidenceCoordinator` candidate that was displaced by a + concurrency-limited attempt instead of discarding it. A same-txid alternate + candidate already admitted to a job could previously be lost without ever + being tried when every concurrency slot was in use at the moment of its + retry, causing an otherwise-valid candidate to fail with `limit`. No public + API change; internal candidate/byte accounting is unaffected. + - Use asynchronous platform SHA-256 for ProtoWallet signature payloads of at least 64 KiB. Preserve deterministic signatures, direct digests, short input behavior, and portable fallback over a snapshot if native hashing is unavailable diff --git a/packages/wallet/wallet-toolbox/CHANGELOG.md b/packages/wallet/wallet-toolbox/CHANGELOG.md index 3cb2cba20..c6bf93540 100644 --- a/packages/wallet/wallet-toolbox/CHANGELOG.md +++ b/packages/wallet/wallet-toolbox/CHANGELOG.md @@ -6,6 +6,14 @@ attention to changes that materially alter behavior or extend functionality. ## wallet-toolbox (unreleased) +- Implement `BHServiceClient.findChainTipHash()` by delegating to its existing + `findChainTipHeader()` call against `/api/v1/chain/tip/longest`, instead of + throwing `Not implemented`. `ChaintracksChainTracker.getVerificationContextToken()` + calls `findChainTipHash()` on every verification attempt, so any wallet + configured with a `BHServiceClient` as `options.chaintracks`, or as a + `LocalChainTracker` participating source, previously failed on every + attempt. No `ChaintracksClientApi` contract change; no migration required. + - Preserve valid compound proofs with multiple marked transactions. Rotate unresolved proof repairs behind waiting heights across monitor restarts, and retain retries that become temporarily ineligible after the chain tip From c2fa8a7c7cbf16c158ba3404e4dce7fd12702d1c Mon Sep 17 00:00:00 2001 From: Deggen Date: Fri, 18 Sep 2026 18:15:21 -0500 Subject: [PATCH 54/58] docs(sdk): state why a cached evidence recheck cannot be displaced The concurrency-limit signal is handled only in the candidate loop. The cached-positive recheck does not need the same handling: run() is reached solely from pump(), which checks the attempt limit synchronously before starting the job, and no await separates that check from the recheck attempt. Record the invariant next to the call so a later refactor of pump() or run() revisits it. Co-Authored-By: Claude Fable 5.1 --- packages/sdk/src/transaction/TransactionEvidenceCoordinator.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/sdk/src/transaction/TransactionEvidenceCoordinator.ts b/packages/sdk/src/transaction/TransactionEvidenceCoordinator.ts index fb7a520ce..6fec5c47e 100644 --- a/packages/sdk/src/transaction/TransactionEvidenceCoordinator.ts +++ b/packages/sdk/src/transaction/TransactionEvidenceCoordinator.ts @@ -292,6 +292,9 @@ export class TransactionEvidenceCoordinator { const cached = this.positives.get(job.txid) if (cached !== undefined) { try { + // run() is entered only from pump(), which has just confirmed a free + // attempt slot, and nothing awaits before this call. This first attempt + // therefore always starts; only later candidate attempts can be displaced. await this.attempt(job, async signal => await this.recheck(job, cached, signal)) this.check(job, job.controller.signal) this.finish(job, cached) From 2189b54d6f45586ec268f2cdc7d7b78375fb1e9a Mon Sep 17 00:00:00 2001 From: Deggen Date: Fri, 18 Sep 2026 18:15:48 -0500 Subject: [PATCH 55/58] chore(wallet): set platform budgets from measured evidence-branch bundles The ceilings carried on this branch were authored before main grew (#534, #547), so the merged wallet bundles exceeded them and CI stopped at the first over-budget dimension. Measured from the exact packed dependency graph at 83c067098 (JS bundle sizes verified byte-identical between macOS and hosted Linux; Hermes gzip estimated at the +1.3% Linux ratio recorded in #547): client Vite 1,759,717 raw / 416,284 gzip / 324,287 brotli client esbuild 1,372,320 raw / 378,741 gzip / 302,937 brotli mobile Metro 1,811,581 raw / 461,355 gzip / 355,871 brotli mobile Hermes 3,688,871 raw / 1,502,972 gzip / 1,166,821 brotli Only the dimensions that exceeded are raised, with about 0.25% headroom (1% on the Hermes gzip estimate, 0.5% on Hermes brotli run variance). Co-Authored-By: Claude Fable 5.1 --- .../wallet/wallet-toolbox/client/platform-budget.json | 4 ++-- .../wallet/wallet-toolbox/mobile/platform-budget.json | 8 ++++---- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/wallet/wallet-toolbox/client/platform-budget.json b/packages/wallet/wallet-toolbox/client/platform-budget.json index bfed7f28a..9c8f6ca16 100644 --- a/packages/wallet/wallet-toolbox/client/platform-budget.json +++ b/packages/wallet/wallet-toolbox/client/platform-budget.json @@ -2,12 +2,12 @@ "profile": "browser", "maximumBytes": { "vite": { - "raw": 1750000, + "raw": 1765000, "gzip": 430000, "brotli": 330000 }, "esbuild": { - "raw": 1360000, + "raw": 1376000, "gzip": 380000, "brotli": 320000 } diff --git a/packages/wallet/wallet-toolbox/mobile/platform-budget.json b/packages/wallet/wallet-toolbox/mobile/platform-budget.json index 174aa189b..388fe5344 100644 --- a/packages/wallet/wallet-toolbox/mobile/platform-budget.json +++ b/packages/wallet/wallet-toolbox/mobile/platform-budget.json @@ -2,14 +2,14 @@ "profile": "mobile", "maximumBytes": { "metro": { - "raw": 1810000, - "gzip": 455000, + "raw": 1817000, + "gzip": 463000, "brotli": 360000 }, "hermes": { "raw": 3750000, - "gzip": 1500000, - "brotli": 1160000 + "gzip": 1538000, + "brotli": 1173000 } } } From 43fc0b7aac926870a745190b0cd2243b667de789 Mon Sep 17 00:00:00 2001 From: Deggen Date: Fri, 18 Sep 2026 18:16:02 -0500 Subject: [PATCH 56/58] chore(budgets): set ceilings from measured lookup discovery bundles Bounded discovery, the host queue and the streaming response reader grow every bundle that embeds @bsv/sdk. Measured from the exact packed dependency graph at the top of the ordered overlay wave (max of 5eef0fc67 and 907739402; JS bundle sizes verified byte-identical between macOS and hosted Linux; Hermes gzip estimated at the +1.3% Linux ratio from #547): @bsv/sdk UMD 591,705 raw / 168,531 gzip / 140,485 brotli @bsv/simple Vite 752,122 raw / 182,787 gzip / 150,699 brotli wallet client Vite 1,782,018 raw / 421,917 gzip / 328,724 brotli wallet client esbuild 1,389,928 raw / 383,620 gzip / 306,783 brotli wallet mobile Metro 1,834,234 raw / 466,804 gzip / 360,591 brotli wallet mobile Hermes 3,725,120 raw / 1,520,644 gzip / 1,181,384 brotli Only the dimensions that exceeded are raised, with about 0.25% headroom (1% on the Hermes gzip estimate, 0.5% on Hermes brotli run variance). Later branches in the wave move these bundles by under 150 bytes, so they inherit these ceilings unchanged. Co-Authored-By: Claude Fable 5.1 --- packages/helpers/simple/browser-budget.json | 4 ++-- packages/sdk/browser-budget.json | 2 +- .../wallet/wallet-toolbox/client/platform-budget.json | 6 +++--- .../wallet/wallet-toolbox/mobile/platform-budget.json | 10 +++++----- 4 files changed, 11 insertions(+), 11 deletions(-) diff --git a/packages/helpers/simple/browser-budget.json b/packages/helpers/simple/browser-budget.json index 249bc9ee9..3e44031a0 100644 --- a/packages/helpers/simple/browser-budget.json +++ b/packages/helpers/simple/browser-budget.json @@ -25,9 +25,9 @@ ], "maximumBytes": { "vite": { - "raw": 750000, + "raw": 755000, "gzip": 185000, - "brotli": 150000 + "brotli": 151500 }, "esbuild": { "raw": 585000, diff --git a/packages/sdk/browser-budget.json b/packages/sdk/browser-budget.json index f72f855e8..822515491 100644 --- a/packages/sdk/browser-budget.json +++ b/packages/sdk/browser-budget.json @@ -30,7 +30,7 @@ "path": "dist/umd/bundle.js", "global": "bsv", "maximumBytes": { - "raw": 590000, + "raw": 594000, "gzip": 172000, "brotli": 145000 } diff --git a/packages/wallet/wallet-toolbox/client/platform-budget.json b/packages/wallet/wallet-toolbox/client/platform-budget.json index 9c8f6ca16..b8d5b2bed 100644 --- a/packages/wallet/wallet-toolbox/client/platform-budget.json +++ b/packages/wallet/wallet-toolbox/client/platform-budget.json @@ -2,13 +2,13 @@ "profile": "browser", "maximumBytes": { "vite": { - "raw": 1765000, + "raw": 1787000, "gzip": 430000, "brotli": 330000 }, "esbuild": { - "raw": 1376000, - "gzip": 380000, + "raw": 1394000, + "gzip": 385000, "brotli": 320000 } } diff --git a/packages/wallet/wallet-toolbox/mobile/platform-budget.json b/packages/wallet/wallet-toolbox/mobile/platform-budget.json index 388fe5344..4ad3142a4 100644 --- a/packages/wallet/wallet-toolbox/mobile/platform-budget.json +++ b/packages/wallet/wallet-toolbox/mobile/platform-budget.json @@ -2,14 +2,14 @@ "profile": "mobile", "maximumBytes": { "metro": { - "raw": 1817000, - "gzip": 463000, - "brotli": 360000 + "raw": 1839000, + "gzip": 468000, + "brotli": 361500 }, "hermes": { "raw": 3750000, - "gzip": 1538000, - "brotli": 1173000 + "gzip": 1556000, + "brotli": 1188000 } } } From 03e6c74a65c9a6d93557597f9bee824b3c50aa44 Mon Sep 17 00:00:00 2001 From: Deggen Date: Fri, 18 Sep 2026 19:17:32 -0500 Subject: [PATCH 57/58] test(sdk): pin HTTPS lookup facilitator transport bounds Cover the cancellation and resource-limit branches of HTTPSOverlayLookupFacilitator: a pre-aborted caller signal short-circuits before any fetch, an abort racing a non-ok response reports cancellation and drains the body, JSON and octet-stream output counts are rejected above the caller's budget (including a negative varint count), atomic BEEF extraction stops once the extracted bytes outrun the response budget, and an in-flight decode observes a mid-stream cancellation. Co-Authored-By: Claude Fable 5.1 --- .../__tests/LookupResolver.transport.test.ts | 216 ++++++++++++++++++ 1 file changed, 216 insertions(+) create mode 100644 packages/sdk/src/overlay-tools/__tests/LookupResolver.transport.test.ts diff --git a/packages/sdk/src/overlay-tools/__tests/LookupResolver.transport.test.ts b/packages/sdk/src/overlay-tools/__tests/LookupResolver.transport.test.ts new file mode 100644 index 000000000..d31c59394 --- /dev/null +++ b/packages/sdk/src/overlay-tools/__tests/LookupResolver.transport.test.ts @@ -0,0 +1,216 @@ +import { + HTTPSOverlayLookupFacilitator, + LookupResourceLimitError, + type LookupAnswer, + type LookupFacilitatorAnswer +} from '../LookupResolver' +import { Transaction } from '../../transaction/index' +import { LockingScript } from '../../script/index' + +const question = { service: 'ls_transport', query: { id: 1 } } +const host = 'https://transport.example' + +const jsonResponse = (body: unknown): Response => + new Response(JSON.stringify(body), { headers: { 'content-type': 'application/json' } }) + +const octetResponse = (payload: Uint8Array): Response => + new Response(payload, { headers: { 'content-type': 'application/octet-stream' } }) + +/** + * Structurally parseable receipt. Parsing BEEF here makes no chain-validity + * claim; these tests only exercise the transport's own byte and count bounds. + */ +function transaction(scriptBytes: number, satoshis = 1): Transaction { + return new Transaction( + 1, + [], + [{ lockingScript: LockingScript.fromHex('00'.repeat(scriptBytes)), satoshis }], + 0 + ) +} + +/** + * Aggregated octet-stream wire format: varint outpoint count, then per outpoint + * a 32-byte txid, a varint output index and a varint-prefixed context, followed + * by the shared BEEF. `repeats` outpoints all reference the same transaction. + */ +function octetPayload(tx: Transaction, repeats: number): Buffer { + const txid = Buffer.from(tx.id('hex'), 'hex') + const outpoints = Array.from({ length: repeats }, (_unused, index) => + Buffer.concat([txid, Buffer.from([index]), Buffer.from([0x00])]) + ) + return Buffer.concat([Buffer.from([repeats]), ...outpoints, Buffer.from(tx.toBEEF())]) +} + +function outputsOf(answer: LookupFacilitatorAnswer): LookupAnswer['outputs'] { + if (answer.type !== 'output-list') throw new Error('expected an output-list answer') + return answer.outputs +} + +async function caught(work: Promise): Promise { + return await work.then( + () => { + throw new Error('expected the lookup to reject') + }, + (error: unknown) => error + ) +} + +describe('HTTPSOverlayLookupFacilitator bounded transport', () => { + it('refuses to issue a request when the caller signal is already aborted', async () => { + const fetchClient = jest.fn() + const facilitator = new HTTPSOverlayLookupFacilitator( + fetchClient as unknown as typeof fetch, + true + ) + const controller = new AbortController() + controller.abort() + + const error = await caught(facilitator.lookup(host, question, 2000, controller.signal)) + + expect(error).toBeInstanceOf(Error) + expect((error as Error).name).toBe('AbortError') + expect((error as Error).message).toBe('Lookup cancelled') + expect(fetchClient).not.toHaveBeenCalled() + }) + + it('reports cancellation, not an HTTP failure, when the caller aborts as the response arrives', async () => { + const controller = new AbortController() + let issued: Response | undefined + const fetchClient = jest.fn(async () => { + controller.abort() + issued = new Response('service unavailable', { status: 503, statusText: 'Unavailable' }) + return issued + }) + const facilitator = new HTTPSOverlayLookupFacilitator( + fetchClient as unknown as typeof fetch, + true + ) + + const error = await caught(facilitator.lookup(host, question, 2000, controller.signal)) + // Let the in-flight request settle so its body cleanup is observable. + await new Promise(resolve => setTimeout(resolve, 0)) + + expect((error as Error).name).toBe('AbortError') + expect((error as Error).message).toBe('Lookup cancelled') + // A cancelled request must not leave the response body undrained. + expect(issued?.bodyUsed).toBe(true) + }) + + it('rejects a JSON output list longer than the requested output budget', async () => { + const answer = { + type: 'output-list', + outputs: [ + { beef: transaction(1, 1).toBEEF(), outputIndex: 0 }, + { beef: transaction(1, 2).toBEEF(), outputIndex: 0 } + ] + } + const facilitator = new HTTPSOverlayLookupFacilitator( + jest.fn(async () => jsonResponse(answer)) as unknown as typeof fetch, + true + ) + + const error = await caught( + facilitator.lookup(host, question, 2000, undefined, { maxOutputs: 1 }) + ) + expect(error).toBeInstanceOf(LookupResourceLimitError) + expect((error as LookupResourceLimitError).limit).toBe('maxOutputs') + + // The bound is inclusive: exactly maxOutputs is still accepted. + const accepted = await facilitator.lookup(host, question, 2000, undefined, { maxOutputs: 2 }) + expect(outputsOf(accepted)).toHaveLength(2) + }) + + it('rejects an octet-stream outpoint count that is negative or over the output budget', async () => { + const tx = transaction(4) + const overBudget = new HTTPSOverlayLookupFacilitator( + jest.fn(async () => octetResponse(octetPayload(tx, 3))) as unknown as typeof fetch, + true + ) + const tooMany = await caught( + overBudget.lookup(host, question, 2000, undefined, { maxOutputs: 2 }) + ) + expect(tooMany).toBeInstanceOf(LookupResourceLimitError) + expect((tooMany as LookupResourceLimitError).limit).toBe('maxOutputs') + + // 0xff + eight 0xff bytes decodes as -1: a count that must never be trusted. + const negativeCount = Buffer.concat([ + Buffer.from([0xff]), + Buffer.alloc(8, 0xff), + Buffer.from(tx.toBEEF()) + ]) + const negative = new HTTPSOverlayLookupFacilitator( + jest.fn(async () => octetResponse(negativeCount)) as unknown as typeof fetch, + true + ) + const malformed = await caught( + negative.lookup(host, question, 2000, undefined, { maxOutputs: 64 }) + ) + expect(malformed).toBeInstanceOf(LookupResourceLimitError) + expect((malformed as LookupResourceLimitError).limit).toBe('maxOutputs') + }) + + it('stops octet-stream extraction once the extracted bytes exceed the response budget', async () => { + const tx = transaction(400) + const payload = octetPayload(tx, 3) + const beefBytes = tx.toBEEF().length + // Three outpoints on one transaction extract three atomic BEEF copies, so + // the retained total outruns the wire length the reader already accepted. + expect(2 * beefBytes).toBeGreaterThan(payload.length + 1) + const facilitator = new HTTPSOverlayLookupFacilitator( + jest.fn(async () => octetResponse(payload)) as unknown as typeof fetch, + true + ) + + const error = await caught( + facilitator.lookup(host, question, 2000, undefined, { + maxResponseBytes: payload.length + 1, + maxOutputs: 8 + }) + ) + expect(error).toBeInstanceOf(LookupResourceLimitError) + expect((error as LookupResourceLimitError).limit).toBe('maxResponseBytes') + + const accepted = await facilitator.lookup(host, question, 2000, undefined, { + maxResponseBytes: 4 * beefBytes, + maxOutputs: 8 + }) + const outputs = outputsOf(accepted) + expect(outputs).toHaveLength(3) + expect(outputs.map(output => output.txid)).toEqual([tx.id('hex'), tx.id('hex'), tx.id('hex')]) + }) + + it('abandons octet-stream extraction when the caller cancels mid-decode', async () => { + const controller = new AbortController() + const tx = transaction(8) + const payload = octetPayload(tx, 60) + let scheduled = false + const facilitator = new HTTPSOverlayLookupFacilitator( + jest.fn(async () => octetResponse(payload)) as unknown as typeof fetch, + true + ) + + const startedAt = Date.now() + const error = await caught( + facilitator.lookup(host, question, 5000, controller.signal, { + maxResponseBytes: 1_000_000, + maxOutputs: 128, + // Cancel once the transport has begun reporting bytes: extraction + // yields to the event loop between outputs and must observe the abort. + consumeBytes: () => { + if (scheduled) return + scheduled = true + setTimeout(() => controller.abort(), 0) + } + }) + ) + // The abandoned decode is detached from the caller-facing promise; give it + // an event-loop turn so its own cancellation check runs before teardown. + await new Promise(resolve => setTimeout(resolve, 30)) + + expect((error as Error).name).toBe('AbortError') + expect((error as Error).message).toBe('Lookup cancelled') + // Cancellation settles the request rather than waiting out the 5s deadline. + expect(Date.now() - startedAt).toBeLessThan(2000) + }) +}) From f4594c104dd8c5ddd111e2cc8e1383cdeb5daba3 Mon Sep 17 00:00:00 2001 From: Deggen Date: Fri, 18 Sep 2026 19:20:42 -0500 Subject: [PATCH 58/58] test(sdk): pin lookup query resource, cancellation and host-policy bounds Exercise the uncovered fail-closed branches of LookupResolver.query$: aggregation stopping at maxOutputs across hosts, the evidenceLimits shorthand defaulting its byte budget, the bounded candidate scan (scan window, malformed entries, maxHosts), per-source quotas for additional hosts, SLAP discovery refusing bytes that would breach maxTotalBytes, byte reports arriving after cancellation, cancellation dropping an in-flight peer answer and skipping queued hosts, idempotent double cancellation, the concurrent-query ceiling, over-budget answers being dropped without blaming the host, the maxTrackers budget, advertisement-map eviction, and a broader host cache surviving a tighter rediscovery. Co-Authored-By: Claude Fable 5.1 --- .../__tests/LookupResolver.limits.test.ts | 567 ++++++++++++++++++ 1 file changed, 567 insertions(+) create mode 100644 packages/sdk/src/overlay-tools/__tests/LookupResolver.limits.test.ts diff --git a/packages/sdk/src/overlay-tools/__tests/LookupResolver.limits.test.ts b/packages/sdk/src/overlay-tools/__tests/LookupResolver.limits.test.ts new file mode 100644 index 000000000..6ebb95d1c --- /dev/null +++ b/packages/sdk/src/overlay-tools/__tests/LookupResolver.limits.test.ts @@ -0,0 +1,567 @@ +import LookupResolver, { + DEFAULT_LOOKUP_LIMITS, + LookupResourceLimitError, + type LookupAnswer, + type LookupAnswerProgress, + type LookupEvidenceEvent, + type LookupFacilitatorAnswer, + type LookupQuestion, + type LookupRequestOptions, + type UnreachableHostInfo +} from '../LookupResolver' +import { getOverlayHostReputationTracker } from '../HostReputationTracker' +import OverlayAdminTokenTemplate from '../OverlayAdminTokenTemplate' +import { CompletedProtoWallet } from '../../auth/certificates/__tests/CompletedProtoWallet' +import { PrivateKey } from '../../primitives/index' +import { LockingScript } from '../../script/index' +import { Transaction } from '../../transaction/index' + +const service = 'ls_limits' +const question: LookupQuestion = { service, query: { id: 1 } } + +type LookupOutput = LookupAnswer['outputs'][number] + +/** Structurally parseable receipt; no chain-validity claim is made here. */ +function receipt(satoshis: number): LookupOutput { + const tx = new Transaction(1, [], [{ lockingScript: LockingScript.fromHex('88'), satoshis }], 0) + return { beef: tx.toBEEF(), outputIndex: 0 } +} + +/** A SLAP advertisement naming `domain` as a host for `advertised`. */ +async function slapAdvertisement( + scalar: number, + domain: string, + advertised: string +): Promise { + const wallet = new CompletedProtoWallet(new PrivateKey(scalar)) + const template = new OverlayAdminTokenTemplate(wallet) + const lockingScript = await template.lock('SLAP', domain, advertised) + const tx = new Transaction(1, [], [{ lockingScript, satoshis: 1 }], 0) + return { beef: tx.toBEEF(), outputIndex: 0 } +} + +interface Deferred { + promise: Promise + resolve: (value: T) => void +} + +function deferred(): Deferred { + let resolve!: (value: T) => void + const promise = new Promise(resolvePromise => { + resolve = resolvePromise + }) + return { promise, resolve } +} + +async function collect( + progress: AsyncIterable +): Promise { + const emissions: LookupAnswerProgress[] = [] + for await (const emission of progress) emissions.push(emission) + return emissions +} + +async function finalEmission( + progress: AsyncIterable +): Promise { + const emissions = await collect(progress) + const last = emissions.at(-1) + if (last === undefined) throw new Error('expected at least one emission') + return last +} + +async function caught(work: Promise): Promise { + return await work.then( + () => { + throw new Error('expected the query to reject') + }, + (error: unknown) => error + ) +} + +/** One macrotask turn: every pending microtask continuation has run. */ +async function eventLoopTurn(): Promise { + await new Promise(resolve => setTimeout(resolve, 0)) +} + +describe('LookupResolver query resource bounds', () => { + beforeEach(() => { + getOverlayHostReputationTracker().reset() + }) + + afterEach(() => { + getOverlayHostReputationTracker().reset() + }) + + it('stops merging at maxOutputs once a second host contributes a new outpoint', async () => { + const answers: Record = { + 'https://first.example': { type: 'output-list', outputs: [receipt(1)] }, + 'https://second.example': { type: 'output-list', outputs: [receipt(2)] } + } + const resolver = new LookupResolver({ + hostOverrides: { [service]: Object.keys(answers) }, + limits: { maxOutputs: 1 }, + facilitator: { lookup: async host => answers[host] } + }) + + const { answer, progress } = await resolver.queryDetailed(question) + + expect(answer.outputs).toHaveLength(1) + expect(progress.successfulHosts).toBe(2) + expect(progress.limitsHit).toContain('maxOutputs') + expect(progress.terminalReason).toBe('resource-limit') + }) + + it('defaults the evidence byte budget when only an output count is supplied', async () => { + const events: LookupEvidenceEvent[] = [] + const first = receipt(1) + const resolver = new LookupResolver({ + hostOverrides: { [service]: ['https://evidence.example'] }, + facilitator: { + lookup: async () => ({ type: 'output-list', outputs: [first, receipt(2)] }) + } + }) + + const { progress } = await resolver.queryDetailed(question, undefined, { + evidenceLimits: { maxOutputs: 1 }, + onEvidence: event => { + events.push(event) + } + }) + + expect(events.filter(event => event.type === 'output')).toHaveLength(1) + expect(events.at(-1)).toEqual({ type: 'limit' }) + expect(progress.limitsHit).toContain('maxEvidenceOutputs') + // The default byte budget is generous: the single receipt was admitted whole. + expect(progress.evidenceBytes).toBe(first.beef.length) + // Evidence intake is additive; legacy aggregation still merged both outputs. + expect(progress.outputs).toHaveLength(2) + }) + + it('bounds the candidate scan and skips malformed and over-budget host entries', async () => { + const queried: string[] = [] + const resolver = new LookupResolver({ + hostOverrides: { + [service]: [ + 'not a url', + 'https://h1.example', + 'https://h2.example', + 'https://h3.example', + 'https://h4.example', + 'https://h5.example' + ] + }, + limits: { maxHosts: 1 }, + facilitator: { + lookup: async host => { + queried.push(host) + return { type: 'output-list', outputs: [receipt(1)] } + } + } + }) + + const { answer, progress } = await resolver.queryDetailed(question) + + expect(queried).toEqual(['https://h1.example']) + expect(answer.outputs).toHaveLength(1) + expect(progress.discoveredHosts).toBe(1) + // 2 beyond the maxHosts * 4 scan window, 1 unparseable, 2 past maxHosts. + expect(progress.skippedHosts).toBe(5) + expect(progress.limitsHit).toContain('maxHosts') + }) + + it('cancelling from the first limit notification leaves every host unqueried', async () => { + const controller = new AbortController() + const queried: string[] = [] + const events: LookupEvidenceEvent[] = [] + const resolver = new LookupResolver({ + hostOverrides: { [service]: ['https://one.example', 'https://two.example'] }, + additionalHosts: { [service]: ['https://three.example'] }, + limits: { maxHosts: 1 }, + facilitator: { + lookup: async host => { + queried.push(host) + return { type: 'output-list', outputs: [] } + } + } + }) + + const error = await caught( + resolver.query(question, undefined, { + signal: controller.signal, + onEvidence: event => { + events.push(event) + if (event.type === 'limit') controller.abort() + } + }) + ) + + expect((error as Error).name).toBe('AbortError') + expect(queried).toEqual([]) + // Exactly one limit notification, even though cancellation records its own. + expect(events).toEqual([{ type: 'limit' }]) + }) + + it('reserves a per-source quota for additional hosts while discovery is refreshed', async () => { + const queried: string[] = [] + const resolver = new LookupResolver({ + networkPreset: 'mainnet', + slapTrackers: ['https://tracker.example'], + additionalHosts: { [service]: ['https://add1.example', 'https://add2.example'] }, + limits: { maxHosts: 2, maxTrackers: 1 }, + facilitator: { + lookup: async (host, asked) => { + queried.push(host) + if (asked.service === 'ls_slap') return { type: 'output-list', outputs: [] } + return { type: 'output-list', outputs: [receipt(1)] } + } + } + }) + + const { answer, progress } = await resolver.queryDetailed(question) + + expect(queried).toEqual(['https://add1.example', 'https://tracker.example']) + expect(answer.outputs).toHaveLength(1) + expect(progress.discoveredHosts).toBe(1) + expect(progress.skippedHosts).toBe(1) + expect(progress.limitsHit).toContain('maxHosts') + }) + + it('cancelling on the quota limit releases discovery before any tracker or host is contacted', async () => { + const controller = new AbortController() + const queried: string[] = [] + const resolver = new LookupResolver({ + networkPreset: 'mainnet', + slapTrackers: ['https://tracker.example'], + additionalHosts: { [service]: ['https://add1.example', 'https://add2.example'] }, + limits: { maxHosts: 2, maxTrackers: 1 }, + facilitator: { + lookup: async host => { + queried.push(host) + return { type: 'output-list', outputs: [] } + } + } + }) + + const error = await caught( + resolver.query(question, undefined, { + signal: controller.signal, + onEvidence: event => { + if (event.type === 'limit') controller.abort() + } + }) + ) + await eventLoopTurn() + + expect((error as Error).name).toBe('AbortError') + // The additional host was already dispatched and the SLAP refresh was about + // to subscribe; cancellation must reach both before either sends a request. + expect(queried).toEqual([]) + }) + + it('fails closed when SLAP discovery bytes would exceed the aggregate budget', async () => { + const hostCharged = deferred() + const resolver = new LookupResolver({ + networkPreset: 'mainnet', + slapTrackers: ['https://tracker.example'], + additionalHosts: { [service]: ['https://add.example'] }, + limits: { maxHosts: 4, maxTrackers: 1, maxTotalBytes: 100 }, + facilitator: { + lookup: async ( + _host: string, + asked: LookupQuestion, + _timeout?: number, + _signal?: AbortSignal, + options?: LookupRequestOptions + ) => { + if (asked.service === 'ls_slap') { + await hostCharged.promise + await eventLoopTurn() + options?.consumeBytes?.(30) + return { type: 'output-list', outputs: [] } + } + options?.consumeBytes?.(80) + hostCharged.resolve() + return { type: 'output-list', outputs: [] } + } + } + }) + + const { progress } = await resolver.queryDetailed(question) + + expect(progress.hostCount).toBe(1) + expect(progress.successfulHosts).toBe(1) + // 80 host bytes were accepted; the 30 discovery bytes that would have + // breached maxTotalBytes are refused and never credited. + expect(progress.receivedBytes).toBe(80) + expect(progress.limitsHit).toContain('maxTotalBytes') + expect(progress.terminalReason).toBe('resource-limit') + expect(progress.discoveryComplete).toBe(false) + }) + + it('refuses byte reports that arrive after the query was cancelled', async () => { + const controller = new AbortController() + const resolver = new LookupResolver({ + hostOverrides: { [service]: ['https://late.example'] }, + limits: { maxTotalBytes: 1024 }, + facilitator: { + lookup: async ( + _host: string, + _asked: LookupQuestion, + _timeout?: number, + _signal?: AbortSignal, + options?: LookupRequestOptions + ) => { + options?.consumeBytes?.(4) + controller.abort() + options?.consumeBytes?.(4) + return { type: 'output-list', outputs: [] } + } + } + }) + + const final = await finalEmission( + resolver.query$(question, undefined, { signal: controller.signal }) + ) + + expect(final.terminalReason).toBe('cancelled') + expect(final.receivedBytes).toBe(4) + }) + + it('cancelling mid-flight drops the in-flight peer answer and skips queued hosts', async () => { + const controller = new AbortController() + const queried: string[] = [] + const hosts = ['https://q1.example', 'https://q2.example', 'https://q3.example'] + const resolver = new LookupResolver({ + hostOverrides: { [service]: hosts }, + limits: { maxHosts: 3, hostConcurrency: 2 }, + facilitator: { + lookup: async host => { + queried.push(host) + return { type: 'output-list', outputs: [receipt(hosts.indexOf(host) + 1)] } + } + } + }) + + const final = await finalEmission( + resolver.query$(question, undefined, { + signal: controller.signal, + onEvidence: event => { + if (event.type === 'output') controller.abort() + } + }) + ) + + expect(queried).toEqual(['https://q1.example', 'https://q2.example']) + expect(final.terminalReason).toBe('cancelled') + expect(final.hostCount).toBe(2) + // The third host never left the queue, and neither in-flight answer was + // aggregated: a cancelled attempt never answered the question. + expect(final.skippedHosts).toBe(1) + expect(final.successfulHosts).toBe(0) + expect(final.outputs).toEqual([]) + }) + + it('treats a second cancellation of the same query as a no-op', async () => { + const controller = new AbortController() + const events: LookupEvidenceEvent[] = [] + const resolver = new LookupResolver({ + hostOverrides: { [service]: ['https://stalled.example'] }, + facilitator: { + lookup: async () => await new Promise(() => {}) + } + }) + + const iterator = resolver + .query$(question, undefined, { + signal: controller.signal, + softTimeoutMs: 0, + graceMs: 0, + onEvidence: event => { + events.push(event) + } + }) + [Symbol.asyncIterator]() + + const first = await iterator.next() + expect(first.done).toBe(false) + expect(first.value.isFinal).toBe(false) + expect(first.value.hostCount).toBe(1) + + controller.abort() + // Breaking the iterator cancels a second time through the iterator signal. + await iterator.return?.(undefined) + + expect(events).toEqual([{ type: 'limit' }]) + }) + + it('refuses a new query once the concurrent query ceiling is reached', async () => { + const gate = deferred() + const resolver = new LookupResolver({ + hostOverrides: { [service]: ['https://capped.example'] }, + facilitator: { lookup: async () => await gate.promise } + }) + + const iterators = Array.from({ length: 128 }, () => + resolver.query$(question)[Symbol.asyncIterator]() + ) + const pending = iterators.map(async iterator => await iterator.next()) + await eventLoopTurn() + + const error = await caught(resolver.query(question)) + expect(error).toBeInstanceOf(LookupResourceLimitError) + expect((error as LookupResourceLimitError).limit).toBe('activeQueries') + + gate.resolve({ type: 'output-list', outputs: [] }) + await Promise.all(pending) + await Promise.all( + iterators.map(async iterator => { + await iterator.return?.(undefined) + }) + ) + + // Every finished query released its slot. + const released = await resolver.queryDetailed(question) + expect(released.progress.hostCount).toBe(1) + }) + + it('drops an answer whose receipts exceed maxResponseBytes without blaming the host', async () => { + const host = 'https://oversized.example' + const resolver = new LookupResolver({ + hostOverrides: { [service]: [host] }, + limits: { maxResponseBytes: 16 }, + facilitator: { lookup: async () => ({ type: 'output-list', outputs: [receipt(1)] }) } + }) + + const { answer, progress } = await resolver.queryDetailed(question) + + expect(answer.outputs).toEqual([]) + expect(progress.limitsHit).toEqual(['maxResponseBytes']) + expect(progress.terminalReason).toBe('resource-limit') + // A client-side budget rejection is not an availability failure. + expect(progress.failedHosts).toBe(0) + expect(progress.rejectedHosts).toBe(0) + expect(getOverlayHostReputationTracker().snapshot(host)?.totalFailures).toBe(0) + }) + + it('drops an answer with more outputs than maxOutputs instead of truncating it', async () => { + const host = 'https://overcounted.example' + const resolver = new LookupResolver({ + hostOverrides: { [service]: [host] }, + limits: { maxOutputs: 1 }, + facilitator: { + lookup: async () => ({ type: 'output-list', outputs: [receipt(1), receipt(2)] }) + } + }) + + const { answer, progress } = await resolver.queryDetailed(question) + + expect(answer.outputs).toEqual([]) + expect(progress.limitsHit).toEqual(['maxOutputs']) + expect(progress.failedHosts).toBe(0) + expect(getOverlayHostReputationTracker().snapshot(host)?.totalFailures).toBe(0) + }) + + it('queries only the budgeted number of SLAP trackers and names the limit it hit', async () => { + const trackers = ['https://t1.example', 'https://t2.example'] + const queried: string[] = [] + const resolver = new LookupResolver({ + networkPreset: 'mainnet', + slapTrackers: trackers, + limits: { maxTrackers: 1 }, + facilitator: { + lookup: async host => { + queried.push(host) + return { type: 'output-list', outputs: [] } + } + } + }) + + const error = await caught(resolver.query(question)) + + expect(queried).toEqual(['https://t1.example']) + // A budget exhausted during discovery keeps its own error rather than + // borrowing the no-competent-hosts message. + expect(error).toBeInstanceOf(LookupResourceLimitError) + expect((error as LookupResourceLimitError).limit).toBe('maxTrackers') + }) + + it('evicts the oldest SLAP attribution once the advertisement map is full', async () => { + const tracker = 'https://ad-tracker.example' + const advertisements = await Promise.all([ + slapAdvertisement(11, 'https://adv1.example', service), + slapAdvertisement(12, 'https://adv2.example', service), + slapAdvertisement(13, 'https://adv3.example', service) + ]) + const unreachable: UnreachableHostInfo[] = [] + const resolver = new LookupResolver({ + networkPreset: 'mainnet', + slapTrackers: [tracker], + cache: { hostsMaxEntries: 1 }, + limits: { maxHosts: 2, maxTrackers: 1 }, + facilitator: { + lookup: async (_host, asked) => { + if (asked.service === 'ls_slap') { + return { type: 'output-list', outputs: advertisements } + } + throw new Error('connection refused') + } + } + }) + + const { progress } = await resolver.queryDetailed(question, undefined, { + onUnreachableHost: info => { + unreachable.push(info) + } + }) + + expect(progress.failedHosts).toBe(2) + const attribution = new Map(unreachable.map(info => [info.host, info.advertisedBy])) + expect(attribution.size).toBe(2) + expect(attribution.get('https://adv2.example')).toBe(tracker) + // adv1 was evicted when adv3's attribution arrived, so it reports no tracker. + expect(attribution.has('https://adv1.example')).toBe(true) + expect(attribution.get('https://adv1.example')).toBeUndefined() + }) + + it('keeps a still-fresh broader host cache when a tighter query rediscovers', async () => { + const tracker = 'https://cache-tracker.example' + const broad = await slapAdvertisement(21, 'https://cached.example', service) + const tight = await slapAdvertisement(22, 'https://rediscovered.example', service) + let trackerCalls = 0 + const resolver = new LookupResolver({ + networkPreset: 'mainnet', + slapTrackers: [tracker], + limits: { maxTrackers: 1 }, + facilitator: { + lookup: async (_host, asked) => { + if (asked.service === 'ls_slap') { + trackerCalls++ + return { type: 'output-list', outputs: [trackerCalls === 1 ? broad : tight] } + } + // An immediate-backoff failure, so the cached host stops being + // available and the tighter query must refresh discovery. + throw new Error('Failed to fetch') + } + } + }) + + const first = await resolver.queryDetailed(question) + expect(first.progress.failedHosts).toBe(1) + + const second = await resolver.queryDetailed(question, undefined, { + limits: { maxOutputs: 8 } + }) + expect(trackerCalls).toBe(2) + expect(second.progress.failedHosts).toBe(1) + + const cache = ( + resolver as unknown as { + hostsCache: Map + } + ).hostsCache + const entry = cache.get(service) + expect(entry?.hosts).toEqual(['https://cached.example']) + expect(entry?.maxOutputs).toBe(DEFAULT_LOOKUP_LIMITS.maxOutputs) + }) +})