From ff36b55bf860713ab7a9b9a3460818dbf62b83aa Mon Sep 17 00:00:00 2001 From: Brayden Langley Date: Fri, 4 Sep 2026 14:09:46 -0700 Subject: [PATCH 01/11] Prototype verified GlobalKVStore reliability in an optional SDK adapter --- docs/packages/sdk/bsv-sdk.md | 2 +- docs/reference/package-api-migrations.md | 7 +- docs/reference/stack-facts.md | 2 +- governance/package-release-notes.json | 6 +- governance/repository-health/baselines.json | 2 +- packages/sdk/CHANGELOG.md | 4 + packages/sdk/README.md | 9 + .../sdk/docs/globalkv-reliability-draft.md | 268 +++++++++++++ .../docs/globalkv-reliability-validation.md | 97 +++++ packages/sdk/package.json | 12 +- packages/sdk/src/kvstore/GlobalKVStore.ts | 37 +- .../sdk/src/kvstore/KVStoreReadSession.ts | 74 ++++ .../sdk/src/kvstore/ReliableGlobalKVStore.ts | 286 +++++++++++++ packages/sdk/src/kvstore/ReliableKVStore.ts | 373 +++++++++++++++++ .../GlobalKVStore.fault-injection.test.ts | 71 ++++ .../__tests/GlobalKVStore.reliable.test.ts | 377 ++++++++++++++++++ .../__tests/KVStoreReadSession.test.ts | 57 +++ .../kvstore/__tests/fixtures/reliableKV.ts | 58 +++ packages/sdk/src/kvstore/reliable.ts | 10 + packages/sdk/src/kvstore/withKVWriteLock.ts | 26 ++ .../sdk/src/overlay-tools/LookupResolver.ts | 17 +- .../ReliableHTTPSLookupFacilitator.ts | 63 +++ .../overlay-tools/ReliableHostReputation.ts | 138 +++++++ .../sdk/src/overlay-tools/ReliableLookup.ts | 126 ++++++ .../overlay-tools/ReliableLookupResolver.ts | 109 +++++ .../overlay-tools/ReliableTopicBroadcaster.ts | 118 ++++++ ...LookupResolver.poison-reproduction.test.ts | 95 +++++ .../__tests/ReliableHostReputation.test.ts | 100 +++++ .../__tests/ReliableLookup.discovery.test.ts | 87 ++++ .../__tests/ReliableTopicBroadcaster.test.ts | 83 ++++ 30 files changed, 2682 insertions(+), 32 deletions(-) create mode 100644 packages/sdk/docs/globalkv-reliability-draft.md create mode 100644 packages/sdk/docs/globalkv-reliability-validation.md create mode 100644 packages/sdk/src/kvstore/KVStoreReadSession.ts create mode 100644 packages/sdk/src/kvstore/ReliableGlobalKVStore.ts create mode 100644 packages/sdk/src/kvstore/ReliableKVStore.ts create mode 100644 packages/sdk/src/kvstore/__tests/GlobalKVStore.fault-injection.test.ts create mode 100644 packages/sdk/src/kvstore/__tests/GlobalKVStore.reliable.test.ts create mode 100644 packages/sdk/src/kvstore/__tests/KVStoreReadSession.test.ts create mode 100644 packages/sdk/src/kvstore/__tests/fixtures/reliableKV.ts create mode 100644 packages/sdk/src/kvstore/reliable.ts create mode 100644 packages/sdk/src/kvstore/withKVWriteLock.ts create mode 100644 packages/sdk/src/overlay-tools/ReliableHTTPSLookupFacilitator.ts create mode 100644 packages/sdk/src/overlay-tools/ReliableHostReputation.ts create mode 100644 packages/sdk/src/overlay-tools/ReliableLookup.ts create mode 100644 packages/sdk/src/overlay-tools/ReliableLookupResolver.ts create mode 100644 packages/sdk/src/overlay-tools/ReliableTopicBroadcaster.ts create mode 100644 packages/sdk/src/overlay-tools/__tests/LookupResolver.poison-reproduction.test.ts create mode 100644 packages/sdk/src/overlay-tools/__tests/ReliableHostReputation.test.ts create mode 100644 packages/sdk/src/overlay-tools/__tests/ReliableLookup.discovery.test.ts create mode 100644 packages/sdk/src/overlay-tools/__tests/ReliableTopicBroadcaster.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 cb311e595..03c5a6b84 100644 --- a/docs/reference/package-api-migrations.md +++ b/docs/reference/package-api-migrations.md @@ -48,7 +48,7 @@ and clean-consumer tests remain the executable type authority. | `@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/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. The proposed reliable path requires explicit chain-tracker and authority configuration; legacy APIs and browser state remain unchanged. This draft is unapproved and must not be published or deployed; see packages/sdk/docs/globalkv-reliability-draft.md. | | `@bsv/simple` | `0.4.1` | `0.5.2` | 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. 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. | @@ -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 an opt-in, draft GlobalKVStore reliability API with bounded parallel discovery, advisory scoped reputation, verified observations and explicit incomplete/conflict outcomes. +- 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. The proposed reliable path requires explicit chain-tracker and authority configuration; legacy APIs and browser state remain unchanged. This draft is unapproved and must not be published or deployed; see packages/sdk/docs/globalkv-reliability-draft.md. | Public subpath | Runtime target(s) | Declaration target(s) | | ---------------------------------- | ---------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------- | @@ -436,6 +436,7 @@ CLI entry points: `{"lch":"./dist/cli.js"}`. | `./remittance/*.ts` | `./dist/esm/src/remittance/*.js`
`./dist/cjs/src/remittance/*.js` | `./dist/types/src/remittance/*.d.ts`
`./dist/cjs/src/remittance/*.d.ts` | | `./remittance/*` | `./dist/esm/src/remittance/*.js`
`./dist/cjs/src/remittance/*.js` | `./dist/types/src/remittance/*.d.ts`
`./dist/cjs/src/remittance/*.d.ts` | | `./umd` | `./dist/umd/bundle.js` | `./dist/types/mod.d.ts` | +| `./kvstore/reliable` | `./dist/esm/src/kvstore/reliable.js`
`./dist/cjs/src/kvstore/reliable.js` | `./dist/types/src/kvstore/reliable.d.ts`
`./dist/cjs/src/kvstore/reliable.d.ts` | ## @bsv/simple diff --git a/docs/reference/stack-facts.md b/docs/reference/stack-facts.md index 849d4354f..459e64e8e 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 92b6ab257..486622e05 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 an opt-in, draft GlobalKVStore reliability API with bounded parallel discovery, advisory scoped reputation, verified observations and explicit incomplete/conflict outcomes.", + "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. The proposed reliable path requires explicit chain-tracker and authority configuration; legacy APIs and browser state remain unchanged. This draft is unapproved and must not be published or deployed; see packages/sdk/docs/globalkv-reliability-draft.md." }, { "name": "@bsv/simple", diff --git a/governance/repository-health/baselines.json b/governance/repository-health/baselines.json index 7b141dff3..d8ec9f27a 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/CHANGELOG.md b/packages/sdk/CHANGELOG.md index d6b61d775..6bd668f34 100644 --- a/packages/sdk/CHANGELOG.md +++ b/packages/sdk/CHANGELOG.md @@ -214,6 +214,10 @@ All notable changes to this project will be documented in this file. The format ## [Unreleased] +- Draft 2.5.0: add opt-in verified GlobalKVStore observations, advisory scoped + reputation, bounded concurrent lookup and explicit incomplete/conflict states. + Unapproved; see `docs/globalkv-reliability-draft.md` before any migration. + ### Added - Add shared BRC-100 byte-boundary helpers that preserve valid `number[]` and diff --git a/packages/sdk/README.md b/packages/sdk/README.md index ee0e7d30e..8fba32f40 100644 --- a/packages/sdk/README.md +++ b/packages/sdk/README.md @@ -185,3 +185,12 @@ Incorporated material remains under the separate terms identified in [LICENSES/](./LICENSES/). Keep all three payloads with source and binary distributions. Thank you for being a part of the BSV Blockchain Libraries Project. Let's build the future of BSV Blockchain together! + +## Draft GlobalKVStore reliability API + +The optional `@bsv/sdk/kvstore/reliable` entry point exposes a `getResult` API distinguishes verified observations, partial answers, +authoritative absence, conflicts and temporary failure. It requires an explicit +network-correct chain tracker and completeness policy. See the +[unapproved design and migration proposal](docs/globalkv-reliability-draft.md) +for local evidence and material limits. Legacy behavior remains unchanged. +This proposal is not ready for publication or deployment. diff --git a/packages/sdk/docs/globalkv-reliability-draft.md b/packages/sdk/docs/globalkv-reliability-draft.md new file mode 100644 index 000000000..303163632 --- /dev/null +++ b/packages/sdk/docs/globalkv-reliability-draft.md @@ -0,0 +1,268 @@ +# GlobalKVStore reliability proposal — unapproved, not ready for deployment + +This is a local investigation and opt-in implementation proposal, based on +`98734b07cf` (2026-09-04). Nothing here authorizes merging, publishing, deployment, +changing discovery, or removing existing consumer overrides. The legacy path is +retained for compatibility and as an executable reproduction of the defect. + +## Proven failure sequence + +`LookupResolver.poison-reproduction.test.ts` uses synthetic storage and two +in-process hosts. A future `backoffUntil` excludes a working host entirely. An +empty peer then produces an empty answer. Resetting only the synthetic tracker +immediately restores the data without changing the query or host behavior. +Both assertions pass against the unchanged legacy implementation. A third test +uses only normal `recordFailure`/`flush` calls with a temporarily advanced browser +clock; correcting the clock and reloading turns the generated penalty into more +than 300 days of exclusion. + +The root cause spans several shared boundaries: + +1. `HostReputationTracker` loads v3, falling back to v2/v1. It coerces numbers + instead of validating their bounds. TTL only removes sufficiently old, + positive update times. Future timestamps survive. The nominal generated + cooldown cap is 60 seconds, but imported cooldowns have no cap. Counters do + not decay. The 256-entry, 30-day pruning does not repair these timestamps. +2. Its singleton keys entries by host only. Network, lookup service, SHIP and + SLAP use no independent reputation scope. All failures except semantic HTTP + errors share exponential backoff; some fetch/DNS strings skip its grace. +3. `prepareHostsForQuery` absolutely excludes backoff entries. No successful + request can rehabilitate a host that is never contacted. All-backoff recovery + refreshes discovery, but does not override the same persisted exclusion. +4. Discovery returns the first nonempty candidate set; later tracker results do + not enlarge the returned/cache snapshot. An empty discovery result is cached + for five minutes. Fresh GlobalKVStore instances can therefore appear to help. +5. Host queries themselves already run concurrently. The regression is not a + serial host loop. Discovery (up to five seconds), possible discovery retry, + and host lookup (normally two seconds) have separate budgets, however. +6. `queryDetailed` has completion counters, but GlobalKVStore uses `query` and + discards them. All failed hosts can become `outputs: []`; undecodable token + outputs are silently skipped. `get` then returns `undefined` or `[]`. +7. The legacy resolver accepts optional txid hints and structurally valid BEEF + bytes before cryptographic validation. GlobalKVStore checks token signatures + but does not establish transaction inclusion/currentness or reconcile tips. +8. `set` and `remove` ignore returned `BroadcastFailure`. The double-spend helper + also ignores a returned error while broadcasting the competitor. Immediate + re-query can race indexing. Locks live on each store instance and key only. +9. Current SDK `X-Topics` is comma-separated, matching current Overlay Express's + documented OpenAPI encoding; older servers required JSON arrays. This draft + changes neither wire encoding nor consumers' production compatibility patches. + +Historical operator dossiers record retired advertisements, service 404s, +HyperTypist's fresh-instance retry, TrueLink's indexing-aware recovery, and +Metanet Docs's pinned backend and submission adapter. These are corroborating +historical evidence, not a new live inventory. No live data was fetched or +mutated for this investigation. + +## Local timelines + +| Case | Legacy path | Proposed path | +| -------------------------------------------------------------- | ---------------------------------------------- | -------------------------------------------------- | +| Persisted future cooldown, only recovered host | Immediate backoff error; zero requests | Host is eligible for a probe immediately | +| Empty peer plus cooled recovered host | Empty peer wins by exclusion | Both contacted; verified data retained | +| Fast empty at 10 ms, valid data at 800 ms | May omit data through discovery/reputation | Data at 800 ms; completeness remains partial | +| Healthy peer plus stalled peers | Parallel host work, separate discovery budgets | One 2,000 ms host window, verified partial data | +| Retired advertisement at 0 ms, healthy advertisement at 400 ms | First tracker candidate snapshot | Union at 400 ms; dead host settles at 2,400 ms | +| All hosts stall | Can return an empty output list | Explicit unavailable after 2,000 ms with overrides | +| Cold discovery stalls | Up to 5,000 ms before host work | Discovery budget 1,500 ms within total 5,000 ms | +| Write accepted before index | Returns success immediately | Poll exact outpoint; unconfirmed at 5,000 ms | + +Fake-timer figures are deterministic scheduling evidence, not production latency +measurements. The HTTP fault-injection suite uses real independent loopback +servers and real JSON, BEEF, signature and locally anchored Merkle verification. +It tests disabled, delayed, corrupt, stale and empty peers. Healthy path target +is 1–2 seconds; default cold discovery plus host settlement is at most about +3.5 seconds within the five-second deadline under bounded cooperative work. + +## State machines and invariants + +Legacy: unknown → success or failure → exponential backoff → exclusion until +wall-clock expiry. Success resets failure counters, but exclusion prevents a +probe. Persistence can recreate exclusion on every reload. Cross-tab writers +replace the entire stale map without locking; last writer wins. + +Proposed: unknown → probed → verified response or classified failure → advisory +penalty → decaying/half-open probe → immediate reset on verified response. +Every selected host is probed on the next lookup even during cooldown. There is +no autonomous background probe when the application is idle. This favors recovery +and bounded parallel work over suppressing every probe during an outage. + +- Separate v4 keys include network, service and normalized host URL. +- Timeouts carry weight 1; transport/rejection 2; malformed 8; invalid 16. +- Penalty is capped at 64, halves per minute, and yields at most 30 s cooldown. +- Cooldown affects ordering only, never the decision to contact a selected host. +- State expires in 24 hours; malformed/nonfinite/future timestamps fail open. +- v1–v3 are ignored, not copied or deleted. Rolling back still sees legacy state. +- At most 256 entries and 1 MiB read input. No arbitrary server error text, + query, user identity, value, or BEEF is persisted or logged. +- Browser read/modify/write is serialized using Web Locks. Without atomic + storage/locks, the new path uses memory only. Custom adapters must provide + equivalent serialization; the tests model two independent tabs sharing it. +- At most 32 candidate hosts and 32 trackers per operation. Truncation marks + discovery incomplete. This is a resource bound, not a guarantee that an + arbitrary 33rd healthy host will be contacted; scalable candidate scheduling + needs review before a general guarantee can be made. + +Discovery gathers all bounded tracker responses anew. The draft does not add a +new persistent discovery cache. Advertisement parsing is routing input, not proof +of authority or a guarantee of freshness. Duplicates normalize away; malformed +URLs cannot be contacted. Existing emergency host overrides remain available. + +The total monotonic timer includes discovery, request parsing and validation. +Each host has its own smaller budget. AbortSignal is passed to facilitators; +standard fetch is cancelled. A non-cooperative custom facilitator cannot hold +the consumer promise open, but its own underlying work may continue. No early +first-answer cancellation is used because the current wire protocol supplies no +safe early-completion certificate. Cancellation at deadline/completion is tested. + +## Correctness and structured API + +`GlobalKVStore` imported from `@bsv/sdk/kvstore/reliable` exposes `getResult`, which requires an explicit, network-correct `ChainTracker`. +Each candidate validates BEEF parsing, actual txid versus any hint, output index, +PushDrop fields, protocol/controller/key/tags selectors, derived controller lock, +field signature and SPV verification before health credit or deduplication. +The optional HTTP facilitator bounds streamed responses to 4 MiB before parsing. +Bounds are 256 outputs and 4 MiB BEEF per host. Aggregate outputs are deduplicated +by the actual transaction hash and index. Within each protocol/controller/key, +proven spending successors supersede ancestors. Incomparable tips return conflict; +array order, latency and response cardinality never choose a winner. + +| Outcome | Meaning | +| ------------------------ | --------------------------------------------------------------------------------------------- | +| `data` | Verified observed entries, with `complete` or `partial` completeness and `observed` freshness | +| `absent` | Every configured authority and contacted host completed successfully and returned empty | +| `incomplete` | Some empty responses exist, but they do not establish absence | +| `unavailable` | No usable data and availability/discovery failed | +| `malformed` / `rejected` | No usable data and validation/rejection evidence exists | +| `conflict` | Valid incomparable tips; no winner selected | +| `stale` | Caller-owned last-known-good state retained after a refresh failure | + +Completeness requires an explicitly configured authority set, successful complete +discovery, no failed hosts and agreement on current membership. This deliberately +conservative rule does not infer independent votes from arbitrary discovered URLs. +A missing authority, failed peer or stale member list prevents authoritative +absence. The legacy-shaped `get` adapter throws `KVStoreUnavailableError` for +partial/failed outcomes when reliability is enabled. Consumers wanting partial +observations must use the structured API and display their status. + +### Fundamental protocol gap + +With one unknown accurate host and arbitrary stale/malicious peers, no client can +prove global currentness from inclusion proofs alone. Two worlds are identical +to the client: a token is current, or it was spent and the successor is withheld. +Empty responses carry no authenticated completeness/index watermark. Node identity +is not an independent authority quorum. Thus the unconditional requested guarantee +cannot honestly be delivered by a latency/reputation patch. + +The draft exposes this limit rather than labeling observed data globally current. +Before promotion, define a versioned server capability for signed complete query +snapshots, authority membership/failure assumptions, index watermarks/read tokens, +and spend/absence evidence. Pagination requires snapshot-bound cursors and global +membership semantics. A server assertion alone still needs a stated trust policy. +No overlay wire-format change is proposed without that design review. + +## Writes and consumer migration + +Opt-in writes default to wallet `noSend` until overlay submission, reject returned broadcast errors, bound waiting, and poll for the +same outpoint after indexing. An ambiguous submission reports `unconfirmed` with +the transaction identity; it must be reconciled rather than recreated. Shared +same-origin Web Locks serialize operations by network/service/protocol/controller/ +key; in-process fallback coordinates store instances. Existing instance locks +remain for legacy callers. The effective per-call protocol is used when unlocking. + +The optional `ReliableTopicBroadcaster` now rediscovers SHIP hosts on every +submission, bounds discovery and parallel submissions under one five-second +deadline, propagates fetch cancellation and checks acknowledgment indices. +Returned errors are failures. Legacy broadcaster behavior is unchanged. +Competing replacement transactions now wait for independent indexing confirmation +before the retry helper resumes. Replacement output zero is the GlobalKVStore +contract; removal transactions still require a separate absence confirmation. +Remaining write blockers are material: cross-device uniqueness requires atomic +server admission/reservation or an explicit protocol conflict policy. A browser +lock cannot provide that. The draft blocks subsequent same-key writes after an ambiguous/rejected result, +shares up to 256 pending records across instances, and exposes +`reconcilePendingWrite` to resubmit the same retained signed transaction and +confirm without creating another transaction. BEEF is held in private memory, +never attached to an error object or written to reputation storage. A durable +cross-tab pending-write journal and tested restart reconciliation are required before claiming idempotence across reloads. Reliable +history is intentionally rejected by legacy-shaped `get` until ancestor values +receive the same verification policy. No current value is substituted for history. + +`KVStoreReadState` retains last-known-good entries in caller-owned memory on +transient failure, exposes stale status, and clears on explicit account/query +change or authoritative absence. It never hides a conflict or replaces a known successor with a proven stale +ancestor. Partial list refreshes retain previously observed missing records as +stale rather than silently dropping them. Consumers should +render refreshing/degraded state, offer Retry and trigger bounded re-fetch on +foreground/reconnect. UI copy should say “Temporarily unavailable” rather than +exposing host cooldowns. The optional `KVStoreReadSession` adds coalesced refresh, automatic two-second retry, +cancellation and observer notifications. Local Metanet Docs and Gloss adapters +use it directly; a local Metanet Docs status component has a tested Retry button. +These previews are not wired into production factories or published consumers. + +Proposed sequence after human design approval: + +1. Review authority/currentness, bounded candidate scheduling, parser/CPU resource + limits, history and durable write semantics; finish the uncovered cases. +2. Complete server capability and atomic write-admission design in overlay-topics + and the overlay engine, including portable protocol vectors. +3. Finish SDK browser/mobile, property/mutation, packed consumer and CI evidence. +4. Publish an approved SDK minor and coordinated server capability releases only + through protected workflows, in a separate explicitly authorized task. +5. Integrate structured reads in Metanet Docs and an independent consumer; retain + production pins and compatibility adapters until their backend capabilities + are independently validated. Roll out read-only observation first. +6. Verify synthetic canaries, tail latency, stale-state visibility and pending-write + recovery before any write-path upgrade. Human approval gates every deployment. +7. Roll back via prior package/config; v4 is additive and legacy records untouched. + +## Review guide + +Start with the two legacy reproduction tests. Review `ReliableHostReputation`, +then `ReliableLookupResolver.queryReliable`/`ReliableLookup`, then the cryptographic and reconciliation +boundary in `ReliableKVStore`. Review the opt-in adapter in `ReliableGlobalKVStore` last. The optional subpath keeps the legacy UMD +payload within its unchanged size budget. +Run deterministic tests and real loopback fault injection before package checks. +Read the limitations above before evaluating latency or completeness claims. +This proposal must remain a draft: it is not a complete implementation of all +requested protocol, restart, consumer UX or deployment guarantees. + +## Local use and explicit non-goals of the prototype + +```ts +import { GlobalKVStore, KVStoreReadSession } from '@bsv/sdk/kvstore/reliable' + +const store = new GlobalKVStore({ + wallet, + reliability: { chainTracker: trustedTracker, authoritativeHosts: approvedAuthorities } +}) +const session = new KVStoreReadSession( + signal => store.getResult(query, {}, signal), + snapshot => render(snapshot) +) +await session.refresh() // Retry uses the same method; failures auto-retry while mounted. +// On unmount or account/query change: +session.stop() +``` + +The placeholders above must be supplied by the application; no permissive tracker +or authority is silently invented. Main SDK imports retain legacy behavior. The +new subpath has ESM, CJS and TypeScript export contracts. No production app pin or +compatibility patch has been removed. + +Open design decisions: authority membership and fault model; signed index +watermarks/currentness evidence; discovery authentication and candidate limits; +snapshot pagination; worker-isolated parsing/validation for strict adversarial CPU +bounds; cross-device atomic key admission; durable pending-write recovery; verified +history; half-open probe load under many tabs; and production UI adoption. These +are blockers to the unconditional end-state guarantee, not hidden successes. + +## Validation and consumer preview scope + +The local validation record is in `globalkv-reliability-validation.md` beside this +proposal. Both consumer previews are retained on local task branches. They depend +on the unpublished local SDK tarball and are intentionally not proposed as +consumer dependency upgrades. Only the shared SDK change receives a draft PR. +There is no overlay-topics PR: currentness/absence and cross-device reservation +semantics require agreement before a defensible server implementation can be +reviewed. No live advertisements were inspected or altered during this task. diff --git a/packages/sdk/docs/globalkv-reliability-validation.md b/packages/sdk/docs/globalkv-reliability-validation.md new file mode 100644 index 000000000..56e2f64e1 --- /dev/null +++ b/packages/sdk/docs/globalkv-reliability-validation.md @@ -0,0 +1,97 @@ +# Local validation — unapproved GlobalKVStore draft + +Date: 2026-09-04. Source base: `98734b07cf` on ts-stack main. All failure +injection used synthetic data and isolated local processes. No live wallet, +overlay, Kubernetes, DNS, discovery advertisement, secret or reputation state +was touched. + +## Results + +| Check | Result | +| ----------------------------------------------- | ---------------------------------------------------------------------------------------------- | +| Frozen pnpm install, lifecycle scripts disabled | Passed | +| Workspace build | Passed; local package/application artifacts only | +| Repository health | Passed: 42 projects, 34 public packages, zero contract findings/control errors | +| Root lint and format | Passed | +| Workspace typecheck | Passed after building the workspace; fresh checkout first needed Verifast outputs | +| SDK full coverage suite | 163 suites, 5,983 tests, 1 snapshot passed | +| SDK coverage | Statements 94.30%, branches 86.41%, functions 95.09%, lines 95.20% | +| Existing SDK property profile | 3 suites, 6 tests passed | +| SDK packed artifact contract | Passed ESM/CJS imports, conditional/wildcard exports, source maps, publint and type resolution | +| SDK exact-tarball browser contract | Passed Vite, esbuild and UMD | +| Main UMD payload | 554,942 raw bytes; unchanged 555,000-byte budget | +| New root-cause/adapter tests | 59 tests across 7 suites, included in full SDK results | +| Real loopback HTTP fault injection | 5 cases passed: disabled, delayed, corrupt, stale and empty peers | +| Metanet Docs with local SDK artifact | 49 test files, 185 tests and frontend build passed | +| Metanet Docs lint | Zero errors; 10 existing React refresh warnings | +| Gloss with local SDK artifact | TypeScript build and 4 tests passed | +| Security audit | Failed on unchanged dependency graph; details below | + +Commands include `pnpm build`, `pnpm health:check`, `pnpm lint`, +`pnpm format:check`, `pnpm typecheck`, `pnpm --filter @bsv/sdk test:coverage +--runInBand`, `pnpm --filter @bsv/sdk test:property`, +`pnpm --filter @bsv/sdk pack:check`, `pnpm --filter @bsv/sdk test:browser`, +and `pnpm audit:security`. Tests never changed production state. + +The new suites preserve three passing legacy reproductions and test the optional +adapter with real signatures, BEEF parsing and fixture-specific trusted Merkle +roots. Fake clocks establish the 800 ms fast-empty/data case, 2,000 ms degraded +host window and 2,400 ms delayed-discovery/retired-host case. HTTP tests use a +1,000 ms total budget and 300 ms per-host budget and assert success within the +total budget. These are controlled local measurements, not production SLO data. + +## Audit and review limitations + +`pnpm audit:security` reports 10 vulnerabilities: 8 high and 2 moderate, with 2 +already governed ignored findings. High findings include the existing `fast-uri` +version beneath root AJV tooling and `toml` beneath docs-site's +`remark-mdx-frontmatter`. This branch has no dependency or lockfile change. +No advisory was suppressed and no quality threshold or bundle budget was raised. +The only baseline metadata change updates the SDK package version to its proposed +minor version; security baselines and accepted findings are unchanged. + +A public CI merge gate must not be represented as passing: the audit gate is +known to be blocked on this dependency graph, and the draft is explicitly not +ready for deployment. This task does not authorize publication or a separate +maintenance dependency migration. + +The full suite's coverage threshold passed, but that is not a claim that all +requested behavior is implemented. Remaining work includes durable cross-tab and +restart pending-write recovery, cross-device uniqueness, a currentness/absence +protocol, verified history, production consumer wiring, real multi-tab browser +scheduling tests, independent portable conformance for any new protocol, and +focused mutation/adversarial resource testing. Read the companion design before +considering promotion. + +## Consumer artifact verification + +Consumer manifests, locks, production factories, host pins and compatibility +patches are unchanged. Local preview commits are: + +- Metanet Docs: `a63e76a`, branch `codex/globalkv-reliability-integration`. +- Gloss: `01c6b7c`, branch `codex/globalkv-reliability-integration`. + +The optional module was verified by directly importing +`@bsv/sdk/kvstore/reliable` from each consumer's dependency tree before testing. +Metanet Docs' normal no-save npm install hit an existing Tiptap peer conflict; +its first baseline test pass therefore did not use the candidate. That result +was discarded as candidate evidence. The local SDK tarball was then extracted +into its disposable `node_modules/@bsv/sdk`, imports were verified, and the full +suite/build rerun successfully. Gloss used the same locally packed artifact. + +These preview commits remain local. Consumer PRs are deferred until an approved, +reproducible package input exists. The React preview tests failure-versus-absence +copy and a functioning Retry button; both consumers use the shared read session +for retained data and recovery. They do not switch production traffic. + +## Human approval required for later work + +Review the SDK draft and agree on the authority/fault model first. Complete the +protocol and remaining durability tests, then obtain explicit approval for each +package publication, server deployment and consumer migration. Preserve existing +pins and compatibility adapters during capability validation. A rollback uses +prior packages/configuration; v4 reputation does not delete legacy records. + +Nothing was merged, published, deployed or changed in production. Network-ops +itself remains unchanged. This report is evidence for a draft proposal, not a +release approval or a claim that the entire requested end state is achieved. diff --git a/packages/sdk/package.json b/packages/sdk/package.json index 089328862..fb15c63f5 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" @@ -736,6 +736,16 @@ "./umd": { "types": "./dist/types/mod.d.ts", "import": "./dist/umd/bundle.js" + }, + "./kvstore/reliable": { + "import": { + "types": "./dist/types/src/kvstore/reliable.d.ts", + "default": "./dist/esm/src/kvstore/reliable.js" + }, + "require": { + "types": "./dist/cjs/src/kvstore/reliable.d.ts", + "default": "./dist/cjs/src/kvstore/reliable.js" + } } }, "scripts": { diff --git a/packages/sdk/src/kvstore/GlobalKVStore.ts b/packages/sdk/src/kvstore/GlobalKVStore.ts index 6e0281db9..86ecbb803 100644 --- a/packages/sdk/src/kvstore/GlobalKVStore.ts +++ b/packages/sdk/src/kvstore/GlobalKVStore.ts @@ -63,10 +63,10 @@ export class GlobalKVStore { /** * Configuration for the KVStore instance containing all runtime options. - * @private + * @protected * @readonly */ - private readonly config: KVStoreConfig + protected readonly config: KVStoreConfig /** * Historian instance used to extract history from transaction outputs. @@ -82,9 +82,9 @@ export class GlobalKVStore { /** * Topic broadcaster used to broadcast transactions to the overlay. - * @private + * @protected */ - private readonly topicBroadcaster: TopicBroadcaster + protected readonly topicBroadcaster: TopicBroadcaster /** * A map to store locks for each key to ensure atomic updates. @@ -231,7 +231,11 @@ export class GlobalKVStore { // Wrap entire operation in double-spend retry, including overlay query const outpoint = await withDoubleSpendRetry(async () => { // Re-query overlay on each attempt to get fresh token state - const existingEntries = await this.queryOverlay({ key, controller }, { includeToken: true }) + const existingEntries = await this.queryOverlay( + { key, controller }, + { includeToken: true }, + protocolID + ) const existingToken = existingEntries.length > 0 ? existingEntries[0].token : undefined if (existingToken == null) { @@ -299,7 +303,7 @@ export class GlobalKVStore { } const tx = Transaction.fromAtomicBEEF(signableTransaction.tx) - const unlocker = pushdrop.unlock(this.config.protocolID as WalletProtocol, key, 'anyone') + const unlocker = pushdrop.unlock(protocolID as WalletProtocol, key, 'anyone') const unlockingScript = await unlocker.sign(tx, 0) const { tx: finalTx } = await this.wallet.signAction( @@ -368,7 +372,11 @@ export class GlobalKVStore { // Remove token with double-spend retry const txid = await withDoubleSpendRetry(async () => { // Re-query overlay on each attempt to get fresh token state - const existingEntries = await this.queryOverlay({ key, controller }, { includeToken: true }) + const existingEntries = await this.queryOverlay( + { key, controller }, + { includeToken: true }, + protocolID + ) if (existingEntries.length === 0 || existingEntries[0].token == null) { throw new Error('The item did not exist, no item was deleted.') @@ -499,9 +507,9 @@ export class GlobalKVStore { * Helper function to fetch and cache user identity key * * @returns {Promise} The identity key of the current user - * @private + * @protected */ - private async getIdentityKey(): Promise { + protected async getIdentityKey(): Promise { this.cachedIdentityKey ??= ( await this.wallet.getPublicKey({ identityKey: true }, this.config.originator) ).publicKey @@ -514,11 +522,12 @@ export class GlobalKVStore { * @param {KVStoreQuery} query - Query parameters sent to overlay * @param {KVStoreGetOptions} options - Configuration options for the query * @returns {Promise} Array of matching KV entries - * @private + * @protected */ - private async queryOverlay( + protected async queryOverlay( query: KVStoreQuery, - options: KVStoreGetOptions = {} + options: KVStoreGetOptions = {}, + _writeProtocol?: WalletProtocol ): Promise { const answer = await this.lookupResolver.query({ service: options.serviceName ?? (this.config.serviceName as string), @@ -618,9 +627,9 @@ export class GlobalKVStore { * @param {Transaction} transaction - The transaction to broadcast. * @returns {Promise} The broadcast result. * @throws {Error} If the broadcast fails or the network is unreachable. - * @private + * @protected */ - private async submitToOverlay( + protected async submitToOverlay( transaction: Transaction ): Promise { return await this.topicBroadcaster.broadcast(transaction) diff --git a/packages/sdk/src/kvstore/KVStoreReadSession.ts b/packages/sdk/src/kvstore/KVStoreReadSession.ts new file mode 100644 index 000000000..f5092de87 --- /dev/null +++ b/packages/sdk/src/kvstore/KVStoreReadSession.ts @@ -0,0 +1,74 @@ +import { KVStoreReadState, type KVStoreReadResult } from './ReliableKVStore.js' + +export interface KVStoreReadSnapshot { + refreshing: boolean + result?: KVStoreReadResult +} +/** Shared lifecycle for UI refresh/retry. Create a new session on identity/query change. */ +export class KVStoreReadSession { + private readonly state = new KVStoreReadState() + private snapshot: KVStoreReadSnapshot = { refreshing: false } + private timer: ReturnType | undefined + private pending: Promise | undefined + private stopped = false + private controller: AbortController | undefined + constructor( + private readonly read: (signal: AbortSignal) => Promise, + private readonly onChange: (snapshot: KVStoreReadSnapshot) => void, + private readonly retryDelayMs = 2000 + ) { + if (!Number.isFinite(retryDelayMs) || retryDelayMs < 100) + throw new RangeError('Retry delay must be at least 100 ms') + } + private emit(): void { + try { + this.onChange({ ...this.snapshot }) + } catch { + /* UI observers cannot interrupt recovery. */ + } + } + async refresh(): Promise { + if (this.stopped) return + if (this.pending !== undefined) return await this.pending + clearTimeout(this.timer) + this.snapshot = { ...this.snapshot, refreshing: true } + this.emit() + this.controller = new AbortController() + const controller = this.controller + this.pending = Promise.resolve() + .then(async () => { + let result: KVStoreReadResult + try { + result = await this.read(controller.signal) + } catch { + result = { + kind: 'unavailable', + retryable: true, + evidence: { completedHosts: 0, failedHosts: 0, discoveryComplete: false, durationMs: 0 } + } + } + if (this.stopped) return + result = this.state.apply(result) + this.snapshot = { refreshing: false, result } + this.emit() + if ( + result.kind !== 'absent' && + !(result.kind === 'data' && result.completeness === 'complete') + ) { + this.timer = setTimeout(() => { + void this.refresh() + }, this.retryDelayMs) + } + }) + .finally(() => { + this.pending = undefined + }) + return await this.pending + } + stop(): void { + this.stopped = true + clearTimeout(this.timer) + this.controller?.abort() + this.state.clear() + } +} diff --git a/packages/sdk/src/kvstore/ReliableGlobalKVStore.ts b/packages/sdk/src/kvstore/ReliableGlobalKVStore.ts new file mode 100644 index 000000000..56d7deca6 --- /dev/null +++ b/packages/sdk/src/kvstore/ReliableGlobalKVStore.ts @@ -0,0 +1,286 @@ +import { PushDrop } from '../script/index.js' +import * as Utils from '../primitives/utils.js' +import { ReliableTopicBroadcaster } from '../overlay-tools/ReliableTopicBroadcaster.js' +import GlobalKVStore from './GlobalKVStore.js' +import ReliableLookupResolver from '../overlay-tools/ReliableLookupResolver.js' +import { withinDeadline } from '../overlay-tools/ReliableLookup.js' +import { + validateKVAnswer, + reconcileKVResults, + KVStoreUnavailableError, + KVStoreWriteError, + confirmKVWrite, + type KVStoreReliabilityConfig, + type KVStoreReadResult +} from './ReliableKVStore.js' +import { withKVWriteLock } from './withKVWriteLock.js' +import type { + KVStoreConfig, + KVStoreQuery, + KVStoreGetOptions, + KVStoreEntry, + KVStoreSetOptions, + KVStoreRemoveOptions +} from './types.js' +import type { CreateActionOutput, WalletProtocol } from '../wallet/Wallet.interfaces.js' +import type Transaction from '../transaction/Transaction.js' + +export interface ReliableKVStoreConfig extends Omit { + lookupResolver?: ReliableLookupResolver + reliability: KVStoreReliabilityConfig +} +interface PendingWrite { + txid: string + removing: boolean + transaction?: Transaction +} +const pendingWrites = new Map() +const failedTransactions = new WeakMap() + +/** Opt-in adapter available only at @bsv/sdk/kvstore/reliable. */ +export default class ReliableGlobalKVStore extends GlobalKVStore { + private readonly reliableResolver: ReliableLookupResolver + private readonly policy: KVStoreReliabilityConfig + + constructor(config: ReliableKVStoreConfig) { + if ( + typeof config.reliability?.chainTracker?.isValidRootForHeight !== 'function' || + typeof config.reliability.chainTracker.currentHeight !== 'function' + ) + throw new TypeError('Reliable reads require an explicit chain tracker') + const resolver = + config.lookupResolver ?? + new ReliableLookupResolver({ + networkPreset: config.networkPreset, + hostOverrides: config.hostOverrides, + slapTrackers: config.slapTrackers + }) + super({ + ...config, + overlayBroadcast: config.overlayBroadcast ?? true, + lookupResolver: resolver + }) + this.reliableResolver = resolver + this.policy = config.reliability + // The same checked path covers submission and the SDK's competing-tx recovery. + const broadcaster = new ReliableTopicBroadcaster( + this.config.topics ?? ['tm_kvstore'], + resolver, + this.config.networkPreset === 'local' + ) + const broadcast = broadcaster.broadcast.bind(broadcaster) + this.topicBroadcaster.broadcast = async transaction => { + let result + try { + result = await withinDeadline(async () => await broadcast(transaction), 5000) + } catch { + throw this.submissionError('unconfirmed', transaction) + } + if (result.status !== 'success') throw this.submissionError('rejected', transaction) + await this.confirmAdmittedOutput(transaction) + return result + } + } + + private submissionError( + outcome: 'rejected' | 'unconfirmed', + transaction: Transaction + ): KVStoreWriteError { + const error = new KVStoreWriteError(outcome, transaction.id('hex')) + failedTransactions.set(error, transaction) + return error + } + + /** GlobalKVStore places its token at output zero, including competing replacements. */ + private async confirmAdmittedOutput(transaction: Transaction): Promise { + let query: KVStoreQuery + try { + const decoded = PushDrop.decode(transaction.outputs[0].lockingScript) + if (decoded.fields.length !== 5 && decoded.fields.length !== 6) return + query = { + protocolID: JSON.parse(Utils.toUTF8(decoded.fields[0])), + key: Utils.toUTF8(decoded.fields[1]), + controller: Utils.toHex(decoded.fields[3]) + } + } catch { + return + } // A removal may have no replacement token. + const outpoint = `${transaction.id('hex')}.0` + const confirmed = await confirmKVWrite( + async signal => await this.getResult(query, {}, signal), + outpoint + ) + if (!confirmed) throw this.submissionError('unconfirmed', transaction) + } + + async getResult( + query: KVStoreQuery, + options: KVStoreGetOptions = {}, + signal?: AbortSignal + ): Promise { + if (options.history === true) + throw new Error('Verified history requires a separate history validation policy') + if (!query.key && !query.controller && !query.protocolID && !query.tags?.length) + throw new Error('A KVStore query selector is required') + const resolution = await this.reliableResolver.queryReliable( + { service: options.serviceName ?? this.config.serviceName ?? 'ls_kvstore', query }, + { + signal, + deadlineMs: this.policy.deadlineMs, + hostTimeoutMs: this.policy.hostTimeoutMs, + validate: async (answer, signal) => + await validateKVAnswer(answer, query, this.policy.chainTracker, signal) + } + ) + return reconcileKVResults(resolution, this.policy.authoritativeHosts) + } + + private async readEntries( + query: KVStoreQuery, + options: KVStoreGetOptions + ): Promise { + const result = await this.getResult(query, options) + if (result.kind === 'absent') return [] + if (result.kind !== 'data' || result.completeness !== 'complete') + throw new KVStoreUnavailableError(result.kind) + return result.entries.map(entry => { + if (options.includeToken === true) return entry + const { token: _token, ...value } = entry + return value + }) + } + + override async get( + query: KVStoreQuery, + options: KVStoreGetOptions = {} + ): Promise { + const entries = await this.readEntries(query, options) + return query.key !== undefined && query.controller !== undefined ? entries[0] : entries + } + + protected override async queryOverlay( + query: KVStoreQuery, + options: KVStoreGetOptions = {}, + writeProtocol?: WalletProtocol + ): Promise { + return await this.readEntries( + { ...query, protocolID: writeProtocol ?? query.protocolID ?? this.config.protocolID }, + options + ) + } + + // Keep the returned-error check even if an application replaces the broadcaster. + protected override async submitToOverlay(transaction: Transaction) { + const result = await super.submitToOverlay(transaction) + if (result.status !== 'success') throw this.submissionError('rejected', transaction) + return result + } + + private async write( + key: string, + protocol: WalletProtocol | undefined, + operation: (controller: string) => Promise, + removing = false + ): Promise { + const controller = await this.getIdentityKey() + const scope = JSON.stringify([ + this.config.networkPreset, + this.config.serviceName, + protocol ?? this.config.protocolID, + controller, + key + ]) + return await withKVWriteLock(scope, async () => { + const pending = pendingWrites.get(scope) + if (pending !== undefined) throw new KVStoreWriteError('unconfirmed', pending.txid) + if (pendingWrites.size >= 256) throw new KVStoreUnavailableError('unavailable') + try { + return await operation(controller) + } catch (error) { + if (error instanceof KVStoreWriteError) + pendingWrites.set(scope, { + txid: error.txid, + removing, + transaction: failedTransactions.get(error) + }) + throw error + } + }) + } + + /** Reconcile a pending write without creating another transaction. Memory survives store instances, not reloads. */ + async reconcilePendingWrite(key: string, protocolID = this.config.protocolID): Promise { + const controller = await this.getIdentityKey() + const scope = JSON.stringify([ + this.config.networkPreset, + this.config.serviceName, + protocolID, + controller, + key + ]) + return await withKVWriteLock(scope, async () => { + const pending = pendingWrites.get(scope) + if (pending === undefined) return true + if (pending.transaction !== undefined) { + try { + await this.topicBroadcaster.broadcast(pending.transaction) + } catch { + return false + } + } + const confirmed = await confirmKVWrite( + async signal => await this.getResult({ key, controller, protocolID }, {}, signal), + pending.removing ? undefined : `${pending.txid}.0` + ) + if (confirmed) pendingWrites.delete(scope) + return confirmed + }) + } + + override async set(key: string, value: string, options: KVStoreSetOptions = {}): Promise { + return await this.write(key, options.protocolID, async controller => { + const outpoint = await super.set(key, value, options) + const confirmed = await confirmKVWrite( + async signal => + await this.getResult( + { + key, + controller, + protocolID: options.protocolID ?? this.config.protocolID + }, + {}, + signal + ), + outpoint + ) + if (!confirmed) throw new KVStoreWriteError('unconfirmed', outpoint.split('.')[0]) + return outpoint + }) + } + + override async remove( + key: string, + outputs?: CreateActionOutput[], + options: KVStoreRemoveOptions = {} + ): Promise { + return await this.write( + key, + options.protocolID, + async controller => { + const txid = await super.remove(key, outputs, options) + const confirmed = await confirmKVWrite( + async () => + await this.getResult({ + key, + controller, + protocolID: options.protocolID ?? this.config.protocolID + }), + undefined + ) + if (!confirmed) throw new KVStoreWriteError('unconfirmed', txid) + return txid + }, + true + ) + } +} diff --git a/packages/sdk/src/kvstore/ReliableKVStore.ts b/packages/sdk/src/kvstore/ReliableKVStore.ts new file mode 100644 index 000000000..896e3bd40 --- /dev/null +++ b/packages/sdk/src/kvstore/ReliableKVStore.ts @@ -0,0 +1,373 @@ +import Transaction from '../transaction/Transaction.js' +import type ChainTracker from '../transaction/ChainTracker.js' +import { PushDrop } from '../script/index.js' +import { ProtoWallet } from '../wallet/ProtoWallet.js' +import { Beef } from '../transaction/Beef.js' +import * as Utils from '../primitives/utils.js' +import { + LookupValidationError, + LookupValidationUnavailableError, + withinDeadline +} from '../overlay-tools/ReliableLookup.js' +import type { ReliableLookupResult } from '../overlay-tools/ReliableLookup.js' +import type { LookupAnswer } from '../overlay-tools/LookupResolver.js' +import type { KVStoreEntry, KVStoreQuery } from './types.js' + +export interface KVStoreReliabilityConfig { + /** Trusted, network-correct header validation. Never use a permissive tracker in production. */ + chainTracker: ChainTracker + /** Explicit trust policy for completeness. Discovery is not an authority election. */ + authoritativeHosts?: string[] + deadlineMs?: number + hostTimeoutMs?: number +} +export interface ValidatedKVOutput { + entry: KVStoreEntry + transaction: Transaction + outpoint: string +} +export interface KVStoreReadEvidence { + completedHosts: number + failedHosts: number + discoveryComplete: boolean + durationMs: number +} +export type KVStoreReadResult = + | { + kind: 'data' + entries: KVStoreEntry[] + completeness: 'complete' | 'partial' + freshness: 'observed' + evidence: KVStoreReadEvidence + } + | { kind: 'absent'; authority: 'configured-hosts'; evidence: KVStoreReadEvidence } + | { + kind: 'unavailable' | 'incomplete' | 'malformed' | 'rejected' + retryable: true + evidence: KVStoreReadEvidence + } + | { kind: 'conflict'; retryable: true; evidence: KVStoreReadEvidence } + | { kind: 'stale'; entries: KVStoreEntry[]; retryable: true; evidence: KVStoreReadEvidence } + +export class KVStoreUnavailableError extends Error { + readonly retryable = true + constructor(readonly outcome: KVStoreReadResult['kind']) { + super('Data is temporarily unavailable. Please retry.') + this.name = 'KVStoreUnavailableError' + } +} +export class KVStoreWriteError extends Error { + readonly retryable = true + constructor( + readonly outcome: 'rejected' | 'unconfirmed', + readonly txid: string + ) { + super( + outcome === 'rejected' + ? 'The write was not accepted.' + : 'Write confirmation is pending. Reconcile before creating another transaction.' + ) + this.name = 'KVStoreWriteError' + } +} + +function matches(entry: KVStoreEntry, query: KVStoreQuery): boolean { + if (query.key !== undefined && query.key !== entry.key) return false + if (query.controller !== undefined && query.controller !== entry.controller) return false + if ( + query.protocolID !== undefined && + JSON.stringify(query.protocolID) !== JSON.stringify(entry.protocolID) + ) + return false + if (query.tags !== undefined) { + const test = (tag: string): boolean => entry.tags?.includes(tag) === true + if (!(query.tagQueryMode === 'any' ? query.tags.some(test) : query.tags.every(test))) + return false + } + return true +} + +/** Validation happens before health credit, aggregation or deduplication. */ +export async function validateKVAnswer( + answer: LookupAnswer, + query: KVStoreQuery, + tracker: ChainTracker, + signal: AbortSignal +): Promise { + if (answer.outputs.length > 256) throw new LookupValidationError('malformed') + const checkedTracker: ChainTracker = { + currentHeight: async () => { + try { + return await tracker.currentHeight() + } catch { + throw new LookupValidationUnavailableError() + } + }, + isValidRootForHeight: async (root, height) => { + try { + return await tracker.isValidRootForHeight(root, height) + } catch { + throw new LookupValidationUnavailableError() + } + } + } + const values: ValidatedKVOutput[] = [] + let totalBytes = 0 + for (const result of answer.outputs) { + if (signal.aborted) throw new Error('Lookup aborted') + if ( + !Array.isArray(result.beef) || + result.beef.length === 0 || + !result.beef.every(x => Number.isInteger(x) && x >= 0 && x <= 255) + ) + throw new LookupValidationError('malformed') + totalBytes += result.beef.length + if ( + totalBytes > 4 * 1024 * 1024 || + !Number.isInteger(result.outputIndex) || + result.outputIndex < 0 + ) + throw new LookupValidationError('malformed') + try { + const tx = Transaction.fromBEEF(result.beef) + const txid = tx.id('hex') + if (result.txid !== undefined && result.txid.toLowerCase() !== txid) + throw new Error('Mismatched transaction') + const output = tx.outputs[result.outputIndex] + if (output === undefined) throw new Error('Missing output') + const decoded = PushDrop.decode(output.lockingScript) + if (decoded.fields.length !== 5 && decoded.fields.length !== 6) + throw new Error('Invalid fields') + const signature = decoded.fields.pop() as number[] + const entry: KVStoreEntry = { + protocolID: JSON.parse(Utils.toUTF8(decoded.fields[0])), + key: Utils.toUTF8(decoded.fields[1]), + value: Utils.toUTF8(decoded.fields[2]), + controller: Utils.toHex(decoded.fields[3]) + } + if ( + !Array.isArray(entry.protocolID) || + entry.protocolID.length !== 2 || + ![0, 1, 2].includes(entry.protocolID[0]) || + typeof entry.protocolID[1] !== 'string' || + entry.key.length === 0 + ) + throw new Error('Invalid identity') + if (decoded.fields.length === 5) { + const tags: unknown = JSON.parse(Utils.toUTF8(decoded.fields[4])) + if (!Array.isArray(tags) || !tags.every(x => typeof x === 'string')) + throw new Error('Invalid tags') + entry.tags = tags + } + if (!matches(entry, query)) throw new Error('Off-query output') + const anyone = new ProtoWallet('anyone') + const args = { + protocolID: entry.protocolID, + keyID: entry.key, + counterparty: entry.controller + } + const { valid } = await anyone.verifySignature({ + ...args, + data: decoded.fields.flat(), + signature + }) + if (!valid) throw new Error('Invalid signature') + const { publicKey } = await anyone.getPublicKey(args) + if (decoded.lockingPublicKey.toString() !== publicKey) + throw new Error('Invalid controller lock') + if (!(await tx.verify(checkedTracker))) throw new Error('Invalid transaction proof') + entry.token = { + txid, + outputIndex: result.outputIndex, + beef: Beef.fromBinary(result.beef), + satoshis: output.satoshis ?? 0 + } + values.push({ entry, transaction: tx, outpoint: `${txid}.${result.outputIndex}` }) + } catch (error) { + if (error instanceof LookupValidationUnavailableError) throw error + throw new LookupValidationError('invalid') + } + } + return values +} + +function spends(transaction: Transaction, outpoint: string): boolean { + const pending = [transaction] + const seen = new Set() + for (let i = 0; i < pending.length && i < 4096; i++) { + const tx = pending[i] + const id = tx.id('hex') + if (seen.has(id)) continue + seen.add(id) + for (const input of tx.inputs) { + const source = input.sourceTXID ?? input.sourceTransaction?.id('hex') + if (`${source}.${input.sourceOutputIndex}` === outpoint) return true + if (input.sourceTransaction !== undefined && input.sourceTransaction.id('hex') === source) + pending.push(input.sourceTransaction) + } + } + return false +} + +/** Select only maximal states whose relationship is proven by transaction inputs. */ +export function reconcileKVResults( + result: ReliableLookupResult, + authorities: string[] = [] +): KVStoreReadResult { + const answers = result.hosts.filter(h => h.kind === 'answer') + const evidence: KVStoreReadEvidence = { + completedHosts: result.hosts.length, + failedHosts: result.hosts.length - answers.length, + discoveryComplete: result.discoveryComplete, + durationMs: result.durationMs + } + const unique = new Map() + for (const host of answers) for (const value of host.values) unique.set(value.outpoint, value) + const groups = new Map() + for (const value of unique.values()) { + const identity = JSON.stringify([ + value.entry.protocolID, + value.entry.controller, + value.entry.key + ]) + const group = groups.get(identity) ?? [] + group.push(value) + groups.set(identity, group) + } + const current: ValidatedKVOutput[] = [] + for (const group of groups.values()) { + const tips = group.filter( + value => !group.some(other => other !== value && spends(other.transaction, value.outpoint)) + ) + if (tips.length !== 1) return { kind: 'conflict', retryable: true, evidence } + current.push(tips[0]) + } + const normalize = (host: string): string => host.replace(/\/$/, '') + const authoritative = + authorities.length > 0 && + authorities.every(host => answers.some(answer => normalize(answer.host) === normalize(host))) + // Completeness requires a configured trust set, successful discovery/settlement, + // and identical reconciled membership. Counts or latency never elect authority. + const complete = + authoritative && + result.discoveryComplete && + evidence.failedHosts === 0 && + answers.every(host => { + const represented = new Set(host.values.map(value => value.outpoint)) + return ( + current.every(value => represented.has(value.outpoint)) && + host.values.every(value => unique.has(value.outpoint)) + ) + }) + if (current.length > 0) + return { + kind: 'data', + entries: current.map(v => v.entry), + completeness: complete ? 'complete' : 'partial', + freshness: 'observed', + evidence + } + if (complete && answers.every(host => host.values.length === 0)) + return { kind: 'absent', authority: 'configured-hosts', evidence } + const kind = + answers.length > 0 + ? 'incomplete' + : result.hosts.some(h => h.kind === 'invalid' || h.kind === 'malformed') + ? 'malformed' + : result.hosts.some(h => h.kind === 'rejected') + ? 'rejected' + : 'unavailable' + return { kind, retryable: true, evidence } +} + +/** UI state contains only caller-owned memory; it must be discarded on account/query change. */ +export class KVStoreReadState { + private lastGood: KVStoreEntry[] | undefined + apply(result: KVStoreReadResult): KVStoreReadResult { + if (result.kind === 'data') { + for (const next of result.entries) { + const prior = this.lastGood?.find( + entry => + entry.key === next.key && + entry.controller === next.controller && + JSON.stringify(entry.protocolID) === JSON.stringify(next.protocolID) + ) + if ( + prior?.token === undefined || + next.token === undefined || + prior.token.txid === next.token.txid + ) + continue + try { + const previousTx = Transaction.fromBEEF(prior.token.beef.toBinary(), prior.token.txid) + const nextTx = Transaction.fromBEEF(next.token.beef.toBinary(), next.token.txid) + if (spends(previousTx, `${next.token.txid}.${next.token.outputIndex}`)) { + return { + kind: 'stale', + entries: this.lastGood as KVStoreEntry[], + retryable: true, + evidence: result.evidence + } + } + if (!spends(nextTx, `${prior.token.txid}.${prior.token.outputIndex}`)) + return { kind: 'conflict', retryable: true, evidence: result.evidence } + } catch { + return { kind: 'conflict', retryable: true, evidence: result.evidence } + } + } + if (result.completeness === 'partial' && this.lastGood !== undefined) { + const identity = (entry: KVStoreEntry): string => + JSON.stringify([entry.protocolID, entry.controller, entry.key]) + const combined = new Map(this.lastGood.map(entry => [identity(entry), entry])) + for (const entry of result.entries) combined.set(identity(entry), entry) + this.lastGood = [...combined.values()] + return { kind: 'stale', entries: this.lastGood, retryable: true, evidence: result.evidence } + } + this.lastGood = result.entries + } else if (result.kind === 'absent') this.lastGood = undefined + else if (result.kind !== 'conflict' && this.lastGood !== undefined) + return { kind: 'stale', entries: this.lastGood, retryable: true, evidence: result.evidence } + return result + } + clear(): void { + this.lastGood = undefined + } +} + +/** Poll confirmation only; never construct another logical write on an ambiguous result. */ +export async function confirmKVWrite( + read: (signal: AbortSignal) => Promise, + outpoint: string | undefined, + deadlineMs = 5000 +): Promise { + try { + return await withinDeadline(async signal => { + while (!signal.aborted) { + const result = await read(signal) + if (signal.aborted) return false + if (outpoint === undefined && result.kind === 'absent') return true + if ( + result.kind === 'data' && + result.completeness === 'complete' && + result.entries.some( + entry => `${entry.token?.txid}.${entry.token?.outputIndex}` === outpoint + ) + ) + return true + if (result.kind === 'conflict') return false + await new Promise(resolve => { + const finish = (): void => { + clearTimeout(timer) + signal.removeEventListener('abort', finish) + resolve() + } + const timer = setTimeout(finish, 200) + signal.addEventListener('abort', finish, { once: true }) + }) + } + return false + }, deadlineMs) + } catch { + return false + } +} diff --git a/packages/sdk/src/kvstore/__tests/GlobalKVStore.fault-injection.test.ts b/packages/sdk/src/kvstore/__tests/GlobalKVStore.fault-injection.test.ts new file mode 100644 index 000000000..0fb778efe --- /dev/null +++ b/packages/sdk/src/kvstore/__tests/GlobalKVStore.fault-injection.test.ts @@ -0,0 +1,71 @@ +import { createServer, type Server } from 'node:http' +import GlobalKVStore from '../ReliableGlobalKVStore' +import LookupResolver from '../../overlay-tools/ReliableLookupResolver' +import { fixture, chainTracker } from './fixtures/reliableKV' + +// Real loopback HTTP, real JSON/BEEF parsing, real signatures and local header roots. +// No wallet or live overlay calls. +describe('isolated HTTP fault injection', () => { + const servers: Server[] = [] + const timers: ReturnType[] = [] + afterEach(async () => { + timers.splice(0).forEach(clearTimeout) + await Promise.all( + servers.splice(0).map(async server => { + server.closeAllConnections() + await new Promise(resolve => server.close(() => resolve())) + }) + ) + }) + async function host(body: unknown, delay = 0, status = 200): Promise { + const server = createServer((_req, res) => { + const respond = () => { + if (!res.destroyed) { + res.writeHead(status, { 'Content-Type': 'application/json' }) + res.end(typeof body === 'string' ? body : JSON.stringify(body)) + } + } + if (delay) timers.push(setTimeout(respond, delay)) + else respond() + }) + servers.push(server) + await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)) + const address = server.address() + if (address === null || typeof address === 'string') throw new Error('Missing listener') + return `http://127.0.0.1:${address.port}` + } + it.each(['disabled', 'delayed', 'corrupt', 'stale', 'empty'])( + '%s peer cannot hide a valid reachable state', + async fault => { + const old = await fixture('old') + const current = await fixture('current', old.tx) + const answer = { type: 'output-list', outputs: [current.output] } + const good = await host(answer) + const body = + fault === 'corrupt' + ? '{bad json' + : { type: 'output-list', outputs: fault === 'stale' ? [old.output] : [] } + const bad = await host( + body, + fault === 'delayed' ? 10000 : 0, + fault === 'disabled' ? 503 : 200 + ) + const resolver = new LookupResolver({ + networkPreset: 'local', + hostOverrides: { ls_kvstore: [bad, good] } + }) + const store = new GlobalKVStore({ + lookupResolver: resolver, + reliability: { + chainTracker, + authoritativeHosts: [good, bad], + deadlineMs: 1000, + hostTimeoutMs: 300 + } + }) + const result = await store.getResult(current.query) + expect(result).toMatchObject({ kind: 'data', entries: [{ value: 'current' }] }) + expect(result.evidence.durationMs).toBeLessThan(1000) + } + ) +}) diff --git a/packages/sdk/src/kvstore/__tests/GlobalKVStore.reliable.test.ts b/packages/sdk/src/kvstore/__tests/GlobalKVStore.reliable.test.ts new file mode 100644 index 000000000..1ebda221a --- /dev/null +++ b/packages/sdk/src/kvstore/__tests/GlobalKVStore.reliable.test.ts @@ -0,0 +1,377 @@ +import GlobalKVStore from '../ReliableGlobalKVStore' +import LookupResolver from '../../overlay-tools/ReliableLookupResolver' +import HTTPSOverlayLookupFacilitator from '../../overlay-tools/ReliableHTTPSLookupFacilitator' +import { ReliableHostReputation } from '../../overlay-tools/ReliableHostReputation' +import { KVStoreReadState, confirmKVWrite, KVStoreWriteError } from '../ReliableKVStore' +import { fixture, chainTracker } from './fixtures/reliableKV' + +const good = 'https://good.example' +const bad = 'https://bad.example' +const empty = { type: 'output-list' as const, outputs: [] } +const sleep = async (ms: number) => await new Promise(resolve => setTimeout(resolve, ms)) + +describe('verified KV reliability', () => { + let f: Awaited> + beforeAll(async () => { + f = await fixture() + }) + afterEach(() => jest.useRealTimers()) + function setup(lookup: jest.Mock, hosts = [good, bad], authoritativeHosts = hosts) { + const resolver = new LookupResolver({ + facilitator: { lookup }, + hostOverrides: { ls_kvstore: hosts } + }) + return new GlobalKVStore({ + lookupResolver: resolver, + reliability: { chainTracker, authoritativeHosts } + }) + } + async function advance(promise: Promise, ms = 5000): Promise { + await jest.advanceTimersByTimeAsync(ms) + return await promise + } + it('one healthy host returns verified data and exact txid', async () => { + const store = setup( + jest.fn(async () => ({ ...empty, outputs: [f.output] })), + [good] + ) + const result = await store.getResult(f.query) + expect(result).toMatchObject({ + kind: 'data', + completeness: 'complete', + entries: [{ value: 'synthetic value', token: { txid: f.tx.id('hex') } }] + }) + }) + it.each([20000, Infinity])( + 'healthy host survives another with delay %s under one deadline', + async delay => { + jest.useFakeTimers() + const lookup = jest.fn(async (host: string) => { + if (host === bad) { + if (delay === Infinity) await new Promise(() => {}) + else await sleep(delay) + } + return { ...empty, outputs: [f.output] } + }) + const result = await advance(setup(lookup).getResult(f.query), 2100) + expect(result).toMatchObject({ + kind: 'data', + completeness: 'partial', + evidence: { failedHosts: 1, durationMs: 2000 } + }) + } + ) + it('fastest empty cannot beat delayed valid data', async () => { + jest.useFakeTimers() + const lookup = jest.fn(async (host: string) => { + await sleep(host === good ? 800 : 10) + return host === good ? { ...empty, outputs: [f.output] } : empty + }) + const result = await advance(setup(lookup).getResult(f.query)) + expect(result).toMatchObject({ kind: 'data', completeness: 'partial' }) + }) + it('unanimous configured authoritative empties distinguish absence', async () => { + expect(await setup(jest.fn(async () => empty)).getResult(f.query)).toMatchObject({ + kind: 'absent' + }) + expect( + await setup( + jest.fn(async () => empty), + [good], + [] + ).getResult(f.query) + ).toMatchObject({ kind: 'incomplete' }) + }) + it('all unavailable is explicit and bounded', async () => { + jest.useFakeTimers() + expect( + await advance(setup(jest.fn(async () => await new Promise(() => {}))).getResult(f.query)) + ).toMatchObject({ kind: 'unavailable', retryable: true, evidence: { durationMs: 2000 } }) + }) + it.each([404, 429, 500])('HTTP %s remains a failure rather than absence', async status => { + const facilitator = new HTTPSOverlayLookupFacilitator( + jest.fn(async () => new Response('', { status })) + ) + const resolver = new LookupResolver({ facilitator, hostOverrides: { ls_kvstore: [good] } }) + const store = new GlobalKVStore({ + lookupResolver: resolver, + reliability: { chainTracker, authoritativeHosts: [good] } + }) + expect((await store.getResult(f.query)).kind).toBe(status < 500 ? 'rejected' : 'unavailable') + }) + it.each([ + { type: 'output-list', outputs: [{ beef: [1, 2, 3], outputIndex: 0 }] }, + { type: 'output-list', outputs: 'invalid' }, + { type: 'freeform', result: null } + ])('malformed peer cannot poison valid peer: %j', async answer => { + const store = setup( + jest.fn(async host => (host === good ? { ...empty, outputs: [f.output] } : answer)) + ) + expect(await store.getResult(f.query)).toMatchObject({ kind: 'data', completeness: 'partial' }) + }) + it('all invalid proofs never become empty', async () => { + const altered = { ...f.output, txid: '00'.repeat(32) } + expect( + await setup(jest.fn(async () => ({ ...empty, outputs: [altered] }))).getResult(f.query) + ).toMatchObject({ kind: 'malformed' }) + }) + it('wrong query selector never validates', async () => { + expect( + await setup(jest.fn(async () => ({ ...empty, outputs: [f.output] }))).getResult({ + ...f.query, + key: 'another key' + }) + ).toMatchObject({ kind: 'malformed' }) + }) + it('cryptographically valid successor replaces stale predecessor', async () => { + const successor = await fixture('updated', f.tx) + const store = setup( + jest.fn(async host => ({ ...empty, outputs: [host === good ? successor.output : f.output] })) + ) + expect(await store.getResult(f.query)).toMatchObject({ + kind: 'data', + entries: [{ value: 'updated' }] + }) + }) + it('two valid incomparable states are a conflict', async () => { + const competitor = await fixture('competing') + const store = setup( + jest.fn(async host => ({ ...empty, outputs: [host === good ? competitor.output : f.output] })) + ) + expect(await store.getResult(f.query)).toMatchObject({ kind: 'conflict' }) + }) + it('deduplicates verified output identity', async () => { + expect( + await setup(jest.fn(async () => ({ ...empty, outputs: [f.output, f.output] }))).getResult( + f.query + ) + ).toMatchObject({ kind: 'data', entries: [expect.any(Object)] }) + }) + it('aborts HTTP requests when the operation deadline expires', async () => { + jest.useFakeTimers() + const signals: AbortSignal[] = [] + const fetch = jest.fn(async (_url, init) => { + signals.push(init.signal) + return await new Promise(() => {}) + }) + const resolver = new LookupResolver({ + facilitator: new HTTPSOverlayLookupFacilitator(fetch), + hostOverrides: { ls_kvstore: [good] } + }) + await advance( + resolver.queryReliable( + { service: 'ls_kvstore', query: {} }, + { validate: async () => [], deadlineMs: 100, hostTimeoutMs: 2000 } + ) + ) + expect(signals).toHaveLength(1) + expect(signals[0].aborted).toBe(true) + }) + it('recovered host recorded in v4 cooldown is probed and rehabilitated', async () => { + const values = new Map() + const storage = { + get: key => values.get(key), + set: (key, value) => { + values.set(key, value) + }, + lock: async (_name, action) => await action() + } + const reputation = new ReliableHostReputation(storage) + await reputation.record('mainnet', 'ls_kvstore', good, 'invalid') + const resolver = new LookupResolver({ + facilitator: { lookup: jest.fn(async () => ({ ...empty, outputs: [f.output] })) }, + reliableReputationStorage: storage, + hostOverrides: { ls_kvstore: [good] } + }) + const store = new GlobalKVStore({ + lookupResolver: resolver, + reliability: { chainTracker, authoritativeHosts: [good] } + }) + expect((await store.getResult(f.query)).kind).toBe('data') + await Promise.resolve() + expect( + JSON.parse(values.get('bsvsdk_overlay_host_reputation_v4')!).entries[ + JSON.stringify(['mainnet', 'ls_kvstore', good]) + ].penalty + ).toBe(0) + }) + it('retains last-known-good during failures and clears it on account change', async () => { + const state = new KVStoreReadState() + const data = await setup( + jest.fn(async () => ({ ...empty, outputs: [f.output] })), + [good] + ).getResult(f.query) + state.apply(data) + const failure = { + kind: 'unavailable' as const, + retryable: true as const, + evidence: data.evidence + } + expect(state.apply(failure)).toMatchObject({ + kind: 'stale', + entries: [{ value: 'synthetic value' }] + }) + state.clear() + expect(state.apply(failure).kind).toBe('unavailable') + }) + it('indexing delay confirms the same write without constructing retries', async () => { + jest.useFakeTimers() + const data = await setup( + jest.fn(async () => ({ ...empty, outputs: [f.output] })), + [good] + ).getResult(f.query) + const read = jest + .fn() + .mockResolvedValueOnce({ kind: 'incomplete', evidence: data.evidence }) + .mockResolvedValue(data) + expect(await advance(confirmKVWrite(read, `${f.tx.id('hex')}.0`))).toBe(true) + expect(read).toHaveBeenCalledTimes(2) + }) + it('returned broadcast errors fail reliable writes', async () => { + const store = setup(jest.fn(), [good]) + ;(store as any).topicBroadcaster.broadcast = jest.fn(async () => ({ + status: 'error', + code: 'ERR_REJECTED', + description: 'synthetic' + })) + await expect((store as any).submitToOverlay(f.tx)).rejects.toBeInstanceOf(KVStoreWriteError) + }) +}) + +describe('read validation boundaries', () => { + it('rejects an untrusted Merkle root rather than accepting a structurally valid BEEF', async () => { + const f = await fixture('untrusted') + const resolver = new LookupResolver({ + facilitator: { lookup: async () => ({ type: 'output-list', outputs: [f.output] }) }, + hostOverrides: { ls_kvstore: [good] } + }) + const store = new GlobalKVStore({ + lookupResolver: resolver, + reliability: { + chainTracker: { currentHeight: async () => 100, isValidRootForHeight: async () => false }, + authoritativeHosts: [good] + } + }) + expect(await store.getResult(f.query)).toMatchObject({ kind: 'malformed' }) + }) + it('does not replace a last-known successor with its stale predecessor', async () => { + const old = await fixture('earlier') + const latest = await fixture('later', old.tx) + let output = latest.output + const resolver = new LookupResolver({ + facilitator: { lookup: async () => ({ type: 'output-list', outputs: [output] }) }, + hostOverrides: { ls_kvstore: [good] } + }) + const store = new GlobalKVStore({ + lookupResolver: resolver, + reliability: { chainTracker, authoritativeHosts: [good] } + }) + const state = new KVStoreReadState() + state.apply(await store.getResult(latest.query)) + output = old.output + expect(state.apply(await store.getResult(old.query))).toMatchObject({ + kind: 'stale', + entries: [{ value: 'later' }] + }) + }) + it('rejects an oversized HTTP body before parsing', async () => { + const facilitator = new HTTPSOverlayLookupFacilitator( + jest.fn(async () => new Response('x', { headers: { 'content-length': '5000000' } })) + ) + await expect(facilitator.lookup(good, { service: 'ls_kvstore', query: {} })).rejects.toThrow( + 'malformed' + ) + }) +}) + +describe('pending write idempotence within a runtime', () => { + it('blocks another transaction until an ambiguous write is reconciled', async () => { + const { author } = await import('./fixtures/reliableKV') + const f = await fixture('pending write') + const resolver = new LookupResolver({ + facilitator: { lookup: async () => ({ type: 'output-list', outputs: [f.output] }) }, + hostOverrides: { ls_kvstore: [good] } + }) + const store = new GlobalKVStore({ + wallet: author as any, + lookupResolver: resolver, + protocolID: f.query.protocolID, + reliability: { chainTracker, authoritativeHosts: [good] } + }) + const first = jest.fn(async () => { + throw (store as any).submissionError('unconfirmed', f.tx) + }) + const next = jest.fn(async () => 'another transaction') + await expect( + (store as any).write(f.query.key, f.query.protocolID, first) + ).rejects.toBeInstanceOf(KVStoreWriteError) + await expect( + (store as any).write(f.query.key, f.query.protocolID, next) + ).rejects.toBeInstanceOf(KVStoreWriteError) + expect(next).not.toHaveBeenCalled() + const resend = jest.fn(async () => ({ status: 'success', txid: f.tx.id('hex') })) + ;(store as any).topicBroadcaster.broadcast = resend + expect(await store.reconcilePendingWrite(f.query.key, f.query.protocolID)).toBe(true) + expect(resend).toHaveBeenCalledTimes(1) + expect(resend).toHaveBeenCalledWith(f.tx) + expect(await (store as any).write(f.query.key, f.query.protocolID, next)).toBe( + 'another transaction' + ) + }) +}) + +it('proof infrastructure failure is unavailable rather than blaming a host or reporting absence', async () => { + const f = await fixture('proof service unavailable') + const resolver = new LookupResolver({ + facilitator: { lookup: async () => ({ type: 'output-list', outputs: [f.output] }) }, + hostOverrides: { ls_kvstore: [good] } + }) + const store = new GlobalKVStore({ + lookupResolver: resolver, + reliability: { + chainTracker: { + currentHeight: async () => 100, + isValidRootForHeight: async () => { + throw new Error('offline') + } + }, + authoritativeHosts: [good] + } + }) + expect(await store.getResult(f.query)).toMatchObject({ kind: 'unavailable' }) +}) + +it('requires a runtime-validated chain tracker instead of silently using a default network', () => { + expect(() => new GlobalKVStore({ reliability: {} } as any)).toThrow('explicit chain tracker') +}) + +it('waits for indexing before acknowledging a competing replacement to the retry helper', async () => { + const { ReliableTopicBroadcaster } = await import('../../overlay-tools/ReliableTopicBroadcaster') + const f = await fixture('competing replacement') + const broadcast = jest + .spyOn(ReliableTopicBroadcaster.prototype, 'broadcast') + .mockResolvedValue({ status: 'success', txid: f.tx.id('hex'), message: 'synthetic' }) + jest.useFakeTimers() + const lookup = jest + .fn() + .mockResolvedValueOnce({ type: 'output-list', outputs: [] }) + .mockResolvedValue({ type: 'output-list', outputs: [f.output] }) + const resolver = new LookupResolver({ + facilitator: { lookup }, + hostOverrides: { ls_kvstore: [good] } + }) + const store = new GlobalKVStore({ + lookupResolver: resolver, + reliability: { chainTracker, authoritativeHosts: [good] } + }) + try { + const pending = (store as any).topicBroadcaster.broadcast(f.tx) + await jest.advanceTimersByTimeAsync(500) + expect(await pending).toMatchObject({ status: 'success' }) + expect(broadcast).toHaveBeenCalledTimes(1) + expect(lookup).toHaveBeenCalledTimes(2) + } finally { + broadcast.mockRestore() + jest.useRealTimers() + } +}) diff --git a/packages/sdk/src/kvstore/__tests/KVStoreReadSession.test.ts b/packages/sdk/src/kvstore/__tests/KVStoreReadSession.test.ts new file mode 100644 index 000000000..652581721 --- /dev/null +++ b/packages/sdk/src/kvstore/__tests/KVStoreReadSession.test.ts @@ -0,0 +1,57 @@ +import { KVStoreReadSession } from '../KVStoreReadSession' +import type { KVStoreReadResult } from '../ReliableKVStore' +const evidence = { completedHosts: 1, failedHosts: 0, discoveryComplete: true, durationMs: 5 } +const data: KVStoreReadResult = { + kind: 'data', + entries: [{ key: 'fixture', value: 'synthetic', controller: 'fixture', protocolID: [1, 'test'] }], + completeness: 'complete', + freshness: 'observed', + evidence +} +const unavailable: KVStoreReadResult = { kind: 'unavailable', retryable: true, evidence } +describe('shared UI lifecycle', () => { + afterEach(() => jest.useRealTimers()) + it('preserves data on failure, exposes retry, and recovers automatically', async () => { + jest.useFakeTimers() + const read = jest + .fn() + .mockResolvedValueOnce(data) + .mockResolvedValueOnce(unavailable) + .mockResolvedValue(data) + const change = jest.fn() + const session = new KVStoreReadSession(read, change) + await session.refresh() + await session.refresh() + expect(change).toHaveBeenLastCalledWith( + expect.objectContaining({ + refreshing: false, + result: expect.objectContaining({ kind: 'stale' }) + }) + ) + await jest.advanceTimersByTimeAsync(2000) + expect(read).toHaveBeenCalledTimes(3) + expect(change).toHaveBeenLastCalledWith({ refreshing: false, result: data }) + session.stop() + }) + it('retry coalesces and stop aborts pending work without an empty UI update', async () => { + let signal: AbortSignal | undefined + let finish: (result: KVStoreReadResult) => void = () => {} + const read = jest.fn(async s => { + signal = s + return await new Promise(resolve => { + finish = resolve + }) + }) + const change = jest.fn() + const session = new KVStoreReadSession(read, change) + const a = session.refresh() + const b = session.refresh() + await Promise.resolve() + session.stop() + expect(signal?.aborted).toBe(true) + finish(unavailable) + await Promise.all([a, b]) + expect(read).toHaveBeenCalledTimes(1) + expect(change).toHaveBeenCalledTimes(1) + }) +}) diff --git a/packages/sdk/src/kvstore/__tests/fixtures/reliableKV.ts b/packages/sdk/src/kvstore/__tests/fixtures/reliableKV.ts new file mode 100644 index 000000000..0c47b2058 --- /dev/null +++ b/packages/sdk/src/kvstore/__tests/fixtures/reliableKV.ts @@ -0,0 +1,58 @@ +import Transaction from '../../../transaction/Transaction' +import MerklePath from '../../../transaction/MerklePath' +import PrivateKey from '../../../primitives/PrivateKey' +import { ProtoWallet } from '../../../wallet/ProtoWallet' +import { UnlockingScript, PushDrop } from '../../../script/index' +import * as Utils from '../../../primitives/utils' + +// Synthetic private key and locally trusted roots; never contacts a chain service. +export const author = new ProtoWallet(new PrivateKey(42)) +export const protocol: [0 | 1 | 2, string] = [1, 'reliability fixture'] +export const roots = new Set() +export const chainTracker = { + currentHeight: async () => 100, + isValidRootForHeight: async (root: string, height: number) => height === 100 && roots.has(root) +} +export async function fixture(value = 'synthetic value', previous?: Transaction) { + const controller = (await author.getPublicKey({ identityKey: true })).publicKey + const lockingScript = await new PushDrop(author).lock( + [ + Utils.toArray(JSON.stringify(protocol), 'utf8'), + Utils.toArray('fixture key', 'utf8'), + Utils.toArray(value, 'utf8'), + Utils.toArray(controller, 'hex') + ], + protocol, + 'fixture key', + 'anyone', + true + ) + const tx = new Transaction( + 1, + previous === undefined + ? [] + : [ + { + sourceTXID: previous.id('hex'), + sourceOutputIndex: 0, + unlockingScript: UnlockingScript.fromHex(''), + sequence: 0xffffffff + } + ], + [{ lockingScript, satoshis: 1 }], + 0 + ) + const id = tx.id('hex') + tx.merklePath = new MerklePath(100, [ + [ + { offset: 0, hash: '01'.repeat(32) }, + { offset: 1, hash: id, txid: true } + ] + ]) + roots.add(tx.merklePath.computeRoot(id)) + return { + tx, + query: { key: 'fixture key', controller, protocolID: protocol }, + output: { beef: tx.toBEEF(), outputIndex: 0 } + } +} diff --git a/packages/sdk/src/kvstore/reliable.ts b/packages/sdk/src/kvstore/reliable.ts new file mode 100644 index 000000000..f153ac337 --- /dev/null +++ b/packages/sdk/src/kvstore/reliable.ts @@ -0,0 +1,10 @@ +/** Optional reliability adapter. No publication or deployment approval is implied. */ +export { default as GlobalKVStore } from './ReliableGlobalKVStore.js' +export type { ReliableKVStoreConfig } from './ReliableGlobalKVStore.js' +export { default as LookupResolver } from '../overlay-tools/ReliableLookupResolver.js' +export type { ReliableLookupResolverConfig } from '../overlay-tools/ReliableLookupResolver.js' +export * from './ReliableKVStore.js' +export * from '../overlay-tools/ReliableLookup.js' +export * from '../overlay-tools/ReliableHostReputation.js' +export { default as HTTPSOverlayLookupFacilitator } from '../overlay-tools/ReliableHTTPSLookupFacilitator.js' +export * from './KVStoreReadSession.js' diff --git a/packages/sdk/src/kvstore/withKVWriteLock.ts b/packages/sdk/src/kvstore/withKVWriteLock.ts new file mode 100644 index 000000000..2dde68af3 --- /dev/null +++ b/packages/sdk/src/kvstore/withKVWriteLock.ts @@ -0,0 +1,26 @@ +import * as Utils from '../primitives/utils.js' +import { sha256 } from '../primitives/Hash.js' + +const pending = new Map>() +/** Same-origin Web Lock plus shared in-process fallback; no user data in lock names. */ +export async function withKVWriteLock(scope: string, operation: () => Promise): Promise { + const name = `bsvsdk-kv:${Utils.toHex(sha256(Utils.toArray(scope, 'utf8')))}` + const run = async (): Promise => { + const previous = pending.get(name) ?? Promise.resolve() + let release: () => void = () => {} + const gate = new Promise(resolve => { + release = resolve + }) + const current = previous.then(async () => await gate) + pending.set(name, current) + await previous + try { + return await operation() + } finally { + release() + if (pending.get(name) === current) pending.delete(name) + } + } + const locks = globalThis.navigator?.locks + return locks === undefined ? await run() : await locks.request(name, run) +} diff --git a/packages/sdk/src/overlay-tools/LookupResolver.ts b/packages/sdk/src/overlay-tools/LookupResolver.ts index 66f44f1bc..418b776a2 100644 --- a/packages/sdk/src/overlay-tools/LookupResolver.ts +++ b/packages/sdk/src/overlay-tools/LookupResolver.ts @@ -346,7 +346,8 @@ export interface OverlayLookupFacilitator { lookup: ( url: string, question: LookupQuestion, - timeout?: number + timeout?: number, + signal?: AbortSignal ) => Promise } @@ -397,7 +398,7 @@ export class HTTPSOverlayLookupFacilitator implements OverlayLookupFacilitator { } } - private async performLookupRequest( + protected async performLookupRequest( url: string, question: LookupQuestion, signal: AbortSignal | undefined @@ -736,11 +737,11 @@ interface LookupHostFailureContext { * Represents a Lookup Resolver. */ export default class LookupResolver { - private readonly facilitator: OverlayLookupFacilitator - private readonly slapTrackers: string[] - private readonly hostOverrides: Record - private readonly additionalHosts: Record - private readonly networkPreset: LookupNetworkPreset + protected readonly facilitator: OverlayLookupFacilitator + protected readonly slapTrackers: string[] + protected readonly hostOverrides: Record + protected readonly additionalHosts: Record + protected readonly networkPreset: LookupNetworkPreset private readonly hostReputation: HostReputationTracker private readonly telemetry: Telemetry @@ -1249,7 +1250,7 @@ export default class LookupResolver { /** * Extracts competent host domains from a SLAP tracker response. */ - private extractHostsFromAnswer(answer: LookupAnswer, service: string): string[] { + protected extractHostsFromAnswer(answer: LookupAnswer, service: string): string[] { const hosts: string[] = [] if (answer.type !== 'output-list') return hosts for (const output of answer.outputs) { diff --git a/packages/sdk/src/overlay-tools/ReliableHTTPSLookupFacilitator.ts b/packages/sdk/src/overlay-tools/ReliableHTTPSLookupFacilitator.ts new file mode 100644 index 000000000..d7cd623be --- /dev/null +++ b/packages/sdk/src/overlay-tools/ReliableHTTPSLookupFacilitator.ts @@ -0,0 +1,63 @@ +import { + HTTPSOverlayLookupFacilitator, + type LookupQuestion, + type LookupFacilitatorAnswer +} from './LookupResolver.js' +import { withinDeadline, LookupValidationError } from './ReliableLookup.js' + +const MAX_RESPONSE_BYTES = 4 * 1024 * 1024 + +/** Bound untrusted wire bytes before JSON or BEEF parsing in the optional adapter. */ +export default class ReliableHTTPSLookupFacilitator extends HTTPSOverlayLookupFacilitator { + constructor(httpClient: typeof fetch = globalThis.fetch.bind(globalThis), allowHTTP = false) { + super(async (input, init) => { + const response = await httpClient(input, init) + if (!response.ok) return response + if (Number(response.headers.get('content-length')) > MAX_RESPONSE_BYTES) { + await response.body?.cancel() + throw new LookupValidationError('malformed') + } + const reader = response.body?.getReader() + if (reader === undefined) throw new LookupValidationError('malformed') + const chunks: Uint8Array[] = [] + let length = 0 + const abort = (): void => { + void reader.cancel().catch(() => {}) + } + init?.signal?.addEventListener('abort', abort, { once: true }) + try { + while (true) { + if (init?.signal?.aborted === true) throw new Error('Lookup aborted') + const { value, done } = await reader.read() + if (done) break + length += value.byteLength + if (length > MAX_RESPONSE_BYTES) throw new LookupValidationError('malformed') + chunks.push(value) + } + const bytes = new Uint8Array(length) + let offset = 0 + for (const chunk of chunks) { + bytes.set(chunk, offset) + offset += chunk.byteLength + } + return new Response(bytes, { status: response.status, headers: response.headers }) + } finally { + init?.signal?.removeEventListener('abort', abort) + await reader.cancel().catch(() => {}) + } + }, allowHTTP) + } + override async lookup( + url: string, + question: LookupQuestion, + timeout = 2000, + signal?: AbortSignal + ): Promise { + if (!url.startsWith('https:') && !this.allowHTTP) throw new Error('HTTPS required') + return await withinDeadline( + async child => await this.performLookupRequest(url, question, child), + timeout, + signal + ) + } +} diff --git a/packages/sdk/src/overlay-tools/ReliableHostReputation.ts b/packages/sdk/src/overlay-tools/ReliableHostReputation.ts new file mode 100644 index 000000000..e77467f3f --- /dev/null +++ b/packages/sdk/src/overlay-tools/ReliableHostReputation.ts @@ -0,0 +1,138 @@ +/** Advisory health state. No entry is ever permission to exclude a host. */ +export type HostFailureReason = 'timeout' | 'transport' | 'rejected' | 'malformed' | 'invalid' +export interface ReliableReputationEntry { + updatedAt: number + cooldownUntil: number + penalty: number + reason?: HostFailureReason +} +export interface ReliableReputationStorage { + get: (key: string) => string | null | undefined + set: (key: string, value: string) => void + /** Must serialize read/modify/write across every writer (e.g. Web Locks). */ + lock: (name: string, action: () => Promise) => Promise +} +const KEY = 'bsvsdk_overlay_host_reputation_v4' +const TTL = 86400000 +const MAX_ENTRIES = 256 +const MAX_COOLDOWN = 30000 +const reasons: HostFailureReason[] = ['timeout', 'transport', 'rejected', 'malformed', 'invalid'] + +function browserStorage(): ReliableReputationStorage | undefined { + try { + const storage = globalThis.localStorage + const locks = globalThis.navigator?.locks + if (storage == null || locks == null) return undefined + return { + get: key => storage.getItem(key), + set: (key, value) => storage.setItem(key, value), + lock: async (name, action) => await locks.request(name, action) + } + } catch { + return undefined + } +} + +/** v1-v3 are intentionally not imported: their host-only keys cannot be safely scoped. */ +export class ReliableHostReputation { + private entries: Record = {} + constructor(private readonly storage = browserStorage()) {} + + private scope(network: string, service: string, host: string): string { + return JSON.stringify([network, service, host]) + } + + private sanitize(input: unknown, now: number): Record { + if (input === null || typeof input !== 'object' || Array.isArray(input)) return {} + const entries: Record = {} + for (const [key, value] of Object.entries(input)) { + if (key.length > 2048 || value === null || typeof value !== 'object') continue + const e = value as ReliableReputationEntry + if (![e.updatedAt, e.cooldownUntil, e.penalty].every(Number.isFinite)) continue + // Clock rollback, implausible future state and TTL expiry all fail open. + if (e.updatedAt > now || now - e.updatedAt > TTL || e.updatedAt < 0) continue + if ( + e.penalty < 0 || + e.penalty > 64 || + e.cooldownUntil < 0 || + e.cooldownUntil > e.updatedAt + MAX_COOLDOWN + ) + continue + if (e.reason !== undefined && !reasons.includes(e.reason)) continue + let scope: unknown + try { + scope = JSON.parse(key) + } catch { + continue + } + if (!Array.isArray(scope) || scope.length !== 3 || !scope.every(x => typeof x === 'string')) + continue + entries[key] = { + updatedAt: e.updatedAt, + cooldownUntil: e.cooldownUntil, + penalty: e.penalty, + reason: e.reason + } + } + return Object.fromEntries( + Object.entries(entries) + .sort((a, b) => b[1].updatedAt - a[1].updatedAt) + .slice(0, MAX_ENTRIES) + ) + } + + private read(now: number): Record { + try { + const raw = this.storage?.get(KEY) + if (raw == null || raw.length > 1024 * 1024) return {} + const envelope = JSON.parse(raw) + return envelope?.version === 4 ? this.sanitize(envelope.entries, now) : {} + } catch { + return {} + } + } + + rank(network: string, service: string, hosts: string[]): string[] { + const now = Date.now() + this.entries = this.storage === undefined ? this.sanitize(this.entries, now) : this.read(now) + const score = (host: string): number => { + const e = this.entries[this.scope(network, service, host)] + return e === undefined + ? 0 + : e.penalty * 2 ** (-(now - e.updatedAt) / 60000) + (e.cooldownUntil > now ? 64 : 0) + } + return [...new Set(hosts)].sort((a, b) => score(a) - score(b)) + } + + async record( + network: string, + service: string, + host: string, + reason?: HostFailureReason + ): Promise { + const update = async (): Promise => { + const now = Date.now() + const entries = this.storage === undefined ? this.sanitize(this.entries, now) : this.read(now) + const key = this.scope(network, service, host) + const previous = entries[key] + const weights = { timeout: 1, transport: 2, rejected: 2, malformed: 8, invalid: 16 } + const decayed = + previous === undefined ? 0 : previous.penalty * 2 ** (-(now - previous.updatedAt) / 60000) + const penalty = reason === undefined ? 0 : Math.min(64, decayed + weights[reason]) + entries[key] = { + updatedAt: now, + penalty, + cooldownUntil: reason === undefined ? 0 : now + Math.min(MAX_COOLDOWN, 250 * penalty), + reason + } + this.entries = this.sanitize(entries, now) + this.storage?.set(KEY, JSON.stringify({ version: 4, entries: this.entries })) + } + try { + if (this.storage === undefined) await update() + else await this.storage.lock(KEY, update) + } catch { + /* Advisory persistence must never break lookup. */ + } + } +} diff --git a/packages/sdk/src/overlay-tools/ReliableLookup.ts b/packages/sdk/src/overlay-tools/ReliableLookup.ts new file mode 100644 index 000000000..905723cb5 --- /dev/null +++ b/packages/sdk/src/overlay-tools/ReliableLookup.ts @@ -0,0 +1,126 @@ +import type { LookupAnswer, LookupQuestion, OverlayLookupFacilitator } from './LookupResolver.js' +import { ReliableHostReputation, type HostFailureReason } from './ReliableHostReputation.js' + +export type ReliableHostOutcome = + { host: string; kind: 'answer'; values: T[] } | { host: string; kind: HostFailureReason } +export interface ReliableLookupResult { + hosts: ReliableHostOutcome[] + discoveryComplete: boolean + durationMs: number +} +export interface ReliableLookupOptions { + /** Includes discovery, host requests and validation; default 5000 ms. */ + deadlineMs?: number + /** Per host, including validation; default 2000 ms. */ + hostTimeoutMs?: number + signal?: AbortSignal + /** Must reject malformed, off-query or invalid data before returning any values. */ + validate: (answer: LookupAnswer, signal: AbortSignal) => Promise +} +/** Validation infrastructure failed independently of the responding host. */ +export class LookupValidationUnavailableError extends Error { + constructor() { + super('Proof validation temporarily unavailable') + this.name = 'LookupValidationUnavailableError' + } +} +export class LookupValidationError extends Error { + constructor(readonly reason: 'malformed' | 'invalid') { + super(`Lookup response ${reason}`) + this.name = 'LookupValidationError' + } +} +export const monotonicNow = (): number => globalThis.performance?.now() ?? Date.now() +export function boundedMs(value: number | undefined, fallback: number): number { + if (value === undefined) return fallback + if (!Number.isFinite(value) || value < 1 || value > 30000) + throw new RangeError('Lookup deadline must be between 1 and 30000 ms') + return value +} + +/** Races even a non-cooperative facilitator; cooperative work also receives abort. */ +export async function withinDeadline( + work: (signal: AbortSignal) => Promise, + ms: number, + parent?: AbortSignal +): Promise { + const controller = new AbortController() + let timer: ReturnType | undefined + let abort: () => void = () => {} + const deadline = new Promise((_resolve, reject) => { + abort = () => { + controller.abort() + reject(new Error('Lookup deadline exceeded')) + } + timer = setTimeout(abort, Math.max(0, ms)) + if (parent?.aborted === true) abort() + else parent?.addEventListener('abort', abort, { once: true }) + }) + try { + const pending = Promise.resolve().then(async () => { + if (controller.signal.aborted) throw new Error('Lookup cancelled') + return await work(controller.signal) + }) + return await Promise.race([pending, deadline]) + } finally { + clearTimeout(timer) + parent?.removeEventListener('abort', abort) + controller.abort() + } +} + +export function normalizeHosts(hosts: string[], allowHTTP: boolean): string[] { + const normalized = new Set() + for (const host of hosts) { + try { + const url = new URL(host) + if (url.protocol !== 'https:' && !(allowHTTP && url.protocol === 'http:')) continue + if (url.username !== '' || url.password !== '' || url.search !== '' || url.hash !== '') + continue + normalized.add(url.href.replace(/\/$/, '')) + } catch { + /* Invalid discovery URL. */ + } + } + return [...normalized] +} + +export async function requestReliableHost( + facilitator: OverlayLookupFacilitator, + reputation: ReliableHostReputation, + network: string, + host: string, + question: LookupQuestion, + options: ReliableLookupOptions, + remainingMs: number, + parent: AbortSignal +): Promise> { + const budget = Math.min(boundedMs(options.hostTimeoutMs, 2000), remainingMs) + try { + const values = await withinDeadline( + async signal => { + const answer = await facilitator.lookup(host, question, budget, signal) + if (answer?.type !== 'output-list' || !Array.isArray(answer.outputs)) + throw new LookupValidationError('malformed') + return await options.validate(answer, signal) + }, + budget, + parent + ) + if (!parent.aborted) void reputation.record(network, question.service, host) + return { host, kind: 'answer', values } + } catch (error) { + let reason: HostFailureReason = 'transport' + if (error instanceof LookupValidationError) reason = error.reason + else if (error instanceof Error && /deadline|timed out|abort/i.test(error.message)) + reason = 'timeout' + else if (error instanceof SyntaxError) reason = 'malformed' + else if (typeof error === 'object' && error !== null && 'status' in error) { + reason = Number(error.status) < 500 ? 'rejected' : 'transport' + } + // Cancellation belongs to the operation, not to the host. + if (!parent.aborted && !(error instanceof LookupValidationUnavailableError)) + void reputation.record(network, question.service, host, reason) + return { host, kind: reason } + } +} diff --git a/packages/sdk/src/overlay-tools/ReliableLookupResolver.ts b/packages/sdk/src/overlay-tools/ReliableLookupResolver.ts new file mode 100644 index 000000000..ca7ee28d1 --- /dev/null +++ b/packages/sdk/src/overlay-tools/ReliableLookupResolver.ts @@ -0,0 +1,109 @@ +import ReliableHTTPSLookupFacilitator from './ReliableHTTPSLookupFacilitator.js' +import LookupResolver, { type LookupResolverConfig, type LookupQuestion } from './LookupResolver.js' +import { ReliableHostReputation, type ReliableReputationStorage } from './ReliableHostReputation.js' +import { + withinDeadline, + monotonicNow, + boundedMs, + normalizeHosts, + requestReliableHost, + type ReliableLookupOptions, + type ReliableLookupResult +} from './ReliableLookup.js' + +export interface ReliableLookupResolverConfig extends LookupResolverConfig { + reliableReputationStorage?: ReliableReputationStorage +} +/** Optional resolver adapter; deliberately excluded from the legacy UMD bundle. */ +export default class ReliableLookupResolver extends LookupResolver { + private readonly reliableReputation: ReliableHostReputation + constructor(config: ReliableLookupResolverConfig = {}) { + super({ + ...config, + facilitator: + config.facilitator ?? + new ReliableHTTPSLookupFacilitator(undefined, config.networkPreset === 'local') + }) + this.reliableReputation = new ReliableHostReputation(config.reliableReputationStorage) + } + async queryReliable( + question: LookupQuestion, + options: ReliableLookupOptions + ): Promise> { + const startedAt = monotonicNow() + const deadlineMs = boundedMs(options.deadlineMs, 5000) + boundedMs(options.hostTimeoutMs, 2000) + const remaining = (): number => Math.max(0, deadlineMs - (monotonicNow() - startedAt)) + const settled: ReliableLookupResult['hosts'] = [] + let discoveryComplete = true + try { + await withinDeadline( + async signal => { + let candidates: string[] = [] + if (this.hostOverrides[question.service] !== undefined) { + candidates = this.hostOverrides[question.service].slice() + } else if (this.networkPreset === 'local') { + candidates = ['http://localhost:8080'] + } else if (question.service === 'ls_slap') { + candidates = this.slapTrackers.slice() + } else { + // Discover the union. No first-tracker cache and no reputation exclusion. + const trackers = normalizeHosts(this.slapTrackers, false) + const answers = await Promise.all( + trackers.slice(0, 32).map(async host => { + try { + const answer = await withinDeadline( + async child => + await this.facilitator.lookup( + host, + { service: 'ls_slap', query: { service: question.service } }, + Math.min(1500, remaining()), + child + ), + Math.min(1500, remaining()), + signal + ) + if (answer?.type !== 'output-list' || !Array.isArray(answer.outputs)) + throw new Error('Invalid discovery response') + return this.extractHostsFromAnswer(answer, question.service) + } catch { + discoveryComplete = false + return [] + } + }) + ) + if (trackers.length === 0 || trackers.length > 32) discoveryComplete = false + candidates = answers.flat() + } + candidates.push(...(this.additionalHosts[question.service] ?? [])) + const hosts = normalizeHosts(candidates, this.networkPreset === 'local') + if (hosts.length > 32 || hosts.length === 0) discoveryComplete = false + // Every selected host is probed, including cooled hosts. Reputation is ordering only. + await Promise.all( + this.reliableReputation + .rank(this.networkPreset, question.service, hosts) + .slice(0, 32) + .map(async host => { + const outcome = await requestReliableHost( + this.facilitator, + this.reliableReputation, + this.networkPreset, + host, + question, + options, + remaining(), + signal + ) + settled.push(outcome) + }) + ) + }, + deadlineMs, + options.signal + ) + } catch { + discoveryComplete = false + } + return { hosts: settled.slice(), discoveryComplete, durationMs: monotonicNow() - startedAt } + } +} diff --git a/packages/sdk/src/overlay-tools/ReliableTopicBroadcaster.ts b/packages/sdk/src/overlay-tools/ReliableTopicBroadcaster.ts new file mode 100644 index 000000000..6aa4d8d05 --- /dev/null +++ b/packages/sdk/src/overlay-tools/ReliableTopicBroadcaster.ts @@ -0,0 +1,118 @@ +import type Transaction from '../transaction/Transaction.js' +import TransactionParser from '../transaction/Transaction.js' +import type { BroadcastResponse, BroadcastFailure } from '../transaction/Broadcaster.js' +import type ReliableLookupResolver from './ReliableLookupResolver.js' +import { HTTPSOverlayBroadcastFacilitator } from './SHIPBroadcaster.js' +import OverlayAdminTokenTemplate from './OverlayAdminTokenTemplate.js' +import { + withinDeadline, + normalizeHosts, + monotonicNow, + LookupValidationError +} from './ReliableLookup.js' + +/** Bounded SHIP discovery/submission without the legacy five-minute candidate cache. */ +export class ReliableTopicBroadcaster { + constructor( + private readonly topics: string[], + private readonly resolver: ReliableLookupResolver, + private readonly allowHTTP = false, + private readonly httpClient: typeof fetch = globalThis.fetch.bind(globalThis) + ) {} + + async broadcast(transaction: Transaction): Promise { + const txid = transaction.id('hex') + const start = monotonicNow() + const remaining = (): number => Math.max(0, 5000 - (monotonicNow() - start)) + try { + return await withinDeadline(async signal => { + const discovery = await this.resolver.queryReliable( + { service: 'ls_ship', query: { topics: this.topics } }, + { + deadlineMs: 2500, + hostTimeoutMs: 1000, + signal, + validate: async answer => { + if (answer.outputs.length > 256) throw new LookupValidationError('malformed') + const candidates: string[] = [] + for (const output of answer.outputs) { + try { + const tx = TransactionParser.fromBEEF(output.beef) + const ad = OverlayAdminTokenTemplate.decode( + tx.outputs[output.outputIndex].lockingScript + ) + if (ad.protocol === 'SHIP' && this.topics.includes(ad.topicOrService)) + candidates.push(ad.domain) + } catch { + throw new LookupValidationError('malformed') + } + } + return candidates + } + } + ) + const hosts = normalizeHosts( + discovery.hosts.flatMap(host => (host.kind === 'answer' ? host.values : [])), + this.allowHTTP + ).slice(0, 32) + const beef = transaction.toBEEF() + const outcomes = await Promise.all( + hosts.map(async host => { + try { + return await withinDeadline( + async child => { + const facilitator = new HTTPSOverlayBroadcastFacilitator( + async (input, init) => await this.httpClient(input, { ...init, signal: child }), + this.allowHTTP + ) + const response = await facilitator.send(host, { beef, topics: this.topics }) + return this.topics.every(topic => { + const ack = response?.[topic] + if (ack === undefined) return false + const admitted = ack.outputsToAdmit ?? [] + const retained = ack.coinsToRetain ?? [] + const removed = ack.coinsRemoved ?? [] + return ( + [admitted, retained, removed].every( + indices => + Array.isArray(indices) && + indices.every(index => Number.isInteger(index) && index >= 0) + ) && + admitted.every(index => index < transaction.outputs.length) && + [...retained, ...removed].every(index => index < transaction.inputs.length) && + admitted.length + retained.length + removed.length > 0 + ) + }) + }, + Math.min(2000, remaining()), + signal + ) + } catch { + return false + } + }) + ) + return outcomes.some(Boolean) + ? { + status: 'success', + txid, + message: + 'Submission acknowledged; independent indexing confirmation is still required.' + } + : { + status: 'error', + txid, + code: 'ERR_RELIABLE_SUBMISSION', + description: 'No valid submission acknowledgment.' + } + }, 5000) + } catch { + return { + status: 'error', + txid, + code: 'ERR_RELIABLE_SUBMISSION_TIMEOUT', + description: 'Submission deadline exceeded; reconcile before retrying.' + } + } + } +} diff --git a/packages/sdk/src/overlay-tools/__tests/LookupResolver.poison-reproduction.test.ts b/packages/sdk/src/overlay-tools/__tests/LookupResolver.poison-reproduction.test.ts new file mode 100644 index 000000000..34d8f4f92 --- /dev/null +++ b/packages/sdk/src/overlay-tools/__tests/LookupResolver.poison-reproduction.test.ts @@ -0,0 +1,95 @@ +import LookupResolver from '../LookupResolver' +import { HostReputationTracker } from '../HostReputationTracker' +import { Transaction } from '../../transaction/index' +import { LockingScript } from '../../script/index' + +// Synthetic local reproduction retained as evidence for the legacy API. +describe('legacy poisoned reputation reproduction', () => { + const host = 'https://recovered.example' + const empty = 'https://empty.example' + const output = { + beef: new Transaction( + 1, + [], + [{ lockingScript: LockingScript.fromHex('51'), satoshis: 1 }], + 0 + ).toBEEF(), + outputIndex: 0 + } + const tracker = (): HostReputationTracker => + new HostReputationTracker({ + get: () => + JSON.stringify({ + [host]: { host, backoffUntil: Date.now() + 365 * 86400000, lastUpdatedAt: Date.now() } + }), + set: () => {} + }) + it('never contacts a reachable host with a future persisted cooldown', async () => { + const lookup = jest.fn(async () => ({ type: 'output-list' as const, outputs: [output] })) + const resolver = new LookupResolver({ + facilitator: { lookup }, + hostOverrides: { ls_kvstore: [host] }, + reputationStorage: { + get: () => JSON.stringify({ [host]: tracker().snapshot(host) }), + set: () => {} + } + }) + await expect(resolver.query({ service: 'ls_kvstore', query: {} })).rejects.toThrow( + 'backing off' + ) + expect(lookup).not.toHaveBeenCalled() + }) + it('returns empty while the reachable data host is excluded; reset immediately restores data', async () => { + const lookup = jest.fn(async (url: string) => ({ + type: 'output-list' as const, + outputs: url === host ? [output] : [] + })) + const reputation = tracker() + const resolver = new LookupResolver({ + facilitator: { lookup }, + hostOverrides: { ls_kvstore: [empty, host] }, + reputationStorage: { + get: () => JSON.stringify({ [host]: reputation.snapshot(host) }), + set: () => {} + } + }) + expect((await resolver.query({ service: 'ls_kvstore', query: {} })).outputs).toHaveLength(0) + expect(lookup.mock.calls.map(call => call[0])).toEqual([empty]) + ;(resolver as any).hostReputation.reset() + expect((await resolver.query({ service: 'ls_kvstore', query: {} })).outputs).toHaveLength(1) + }) +}) + +it('a normal persisted penalty becomes year-long exclusion after the browser clock is corrected', async () => { + jest.useFakeTimers() + const data = new Map() + const storage = { + get: (key: string) => data.get(key), + set: (key: string, value: string) => { + data.set(key, value) + } + } + const host = 'https://clock-recovered.example' + try { + jest.setSystemTime(new Date('2027-09-04T00:00:00Z')) + const oldTab = new HostReputationTracker(storage) + for (let i = 0; i < 8; i++) oldTab.recordFailure(host, new Error('Failed to fetch')) + oldTab.flush() + jest.setSystemTime(new Date('2026-09-04T00:00:00Z')) + const lookup = jest.fn(async () => ({ type: 'output-list' as const, outputs: [] })) + const reloaded = new LookupResolver({ + facilitator: { lookup }, + reputationStorage: storage, + hostOverrides: { ls_kvstore: [host] } + }) + await expect(reloaded.query({ service: 'ls_kvstore', query: {} })).rejects.toThrow( + 'backing off' + ) + expect(lookup).not.toHaveBeenCalled() + expect( + new HostReputationTracker(storage).snapshot(host)!.backoffUntil - Date.now() + ).toBeGreaterThan(300 * 86400000) + } finally { + jest.useRealTimers() + } +}) diff --git a/packages/sdk/src/overlay-tools/__tests/ReliableHostReputation.test.ts b/packages/sdk/src/overlay-tools/__tests/ReliableHostReputation.test.ts new file mode 100644 index 000000000..a6c825a9b --- /dev/null +++ b/packages/sdk/src/overlay-tools/__tests/ReliableHostReputation.test.ts @@ -0,0 +1,100 @@ +import { ReliableHostReputation } from '../ReliableHostReputation' +const KEY = 'bsvsdk_overlay_host_reputation_v4' +const h1 = 'https://one.example' +const h2 = 'https://two.example' +function storage() { + const data = new Map() + let pending = Promise.resolve() + return { + data, + get: (key: string) => data.get(key), + set: (key: string, value: string) => { + data.set(key, value) + }, + lock: async (_name: string, action: () => Promise): Promise => { + const result = pending.then(action) + pending = result.then( + () => {}, + () => {} + ) + return await result + } + } +} +describe('versioned advisory reputation', () => { + beforeEach(() => { + jest.useFakeTimers() + jest.setSystemTime(1000000) + }) + afterEach(() => jest.useRealTimers()) + it.each(['{', 'null', '[]', '{"version":3,"entries":{}}', '{"version":4,"entries":null}'])( + 'fails open for corrupt/legacy schema %s', + raw => { + const s = storage() + s.data.set(KEY, raw) + expect(new ReliableHostReputation(s).rank('mainnet', 'ls_kvstore', [h1, h2])).toEqual([ + h1, + h2 + ]) + } + ) + it('does not import poisoned v1-v3 or mutate legacy browser records', async () => { + const s = storage() + for (const version of [1, 2, 3]) + s.data.set(`bsvsdk_overlay_host_reputation_v${version}`, 'poison') + const tracker = new ReliableHostReputation(s) + expect(tracker.rank('mainnet', 'ls_kvstore', [h1])).toEqual([h1]) + await tracker.record('mainnet', 'ls_kvstore', h1) + expect(s.data.get('bsvsdk_overlay_host_reputation_v3')).toBe('poison') + }) + it('isolates network and service', async () => { + const tracker = new ReliableHostReputation(storage()) + await tracker.record('mainnet', 'ls_kvstore', h1, 'invalid') + expect(tracker.rank('mainnet', 'ls_kvstore', [h1, h2])).toEqual([h2, h1]) + expect(tracker.rank('testnet', 'ls_kvstore', [h1, h2])).toEqual([h1, h2]) + expect(tracker.rank('mainnet', 'ls_ship', [h1, h2])).toEqual([h1, h2]) + }) + it.each([1, 1000000000])('expires penalties after clock moves to %s', async now => { + const tracker = new ReliableHostReputation(storage()) + await tracker.record('mainnet', 'ls_kvstore', h1, 'invalid') + jest.setSystemTime(now) + expect(tracker.rank('mainnet', 'ls_kvstore', [h1, h2])).toEqual([h1, h2]) + }) + it('serializes concurrent tabs without dropping another host update', async () => { + const s = storage() + const a = new ReliableHostReputation(s) + const b = new ReliableHostReputation(s) + await Promise.all([ + a.record('mainnet', 'ls_kvstore', h1, 'timeout'), + b.record('mainnet', 'ls_kvstore', h2, 'invalid') + ]) + const entries = JSON.parse(s.data.get(KEY)!).entries + expect(Object.keys(entries)).toHaveLength(2) + expect(entries[JSON.stringify(['mainnet', 'ls_kvstore', h1])].penalty).toBeLessThan( + entries[JSON.stringify(['mainnet', 'ls_kvstore', h2])].penalty + ) + }) + it('bounded penalties decay and successful probes reset immediately', async () => { + const s = storage() + const tracker = new ReliableHostReputation(s) + for (let i = 0; i < 100; i++) await tracker.record('mainnet', 'ls_kvstore', h1, 'invalid') + let entry = Object.values(JSON.parse(s.data.get(KEY)!).entries)[0] as any + expect(entry.penalty).toBe(64) + expect(entry.cooldownUntil - entry.updatedAt).toBeLessThanOrEqual(30000) + jest.advanceTimersByTime(60000) + await tracker.record('mainnet', 'ls_kvstore', h1, 'timeout') + entry = Object.values(JSON.parse(s.data.get(KEY)!).entries)[0] as any + expect(entry.penalty).toBe(33) + await tracker.record('mainnet', 'ls_kvstore', h1) + expect(tracker.rank('mainnet', 'ls_kvstore', [h1, h2])).toEqual([h1, h2]) + }) + it('bounds serialized state size', async () => { + const s = storage() + const tracker = new ReliableHostReputation(s) + for (let i = 0; i < 300; i++) { + jest.advanceTimersByTime(1) + await tracker.record('mainnet', 'ls_kvstore', `https://${i}.example`, 'timeout') + } + expect(Object.keys(JSON.parse(s.data.get(KEY)!).entries)).toHaveLength(256) + }) +}) diff --git a/packages/sdk/src/overlay-tools/__tests/ReliableLookup.discovery.test.ts b/packages/sdk/src/overlay-tools/__tests/ReliableLookup.discovery.test.ts new file mode 100644 index 000000000..e4b08bb3a --- /dev/null +++ b/packages/sdk/src/overlay-tools/__tests/ReliableLookup.discovery.test.ts @@ -0,0 +1,87 @@ +import LookupResolver from '../ReliableLookupResolver' +import OverlayAdminTokenTemplate from '../OverlayAdminTokenTemplate' +import Transaction from '../../transaction/Transaction' +import { author } from '../../kvstore/__tests/fixtures/reliableKV' +import { withKVWriteLock } from '../../kvstore/withKVWriteLock' +const good = 'https://good.example' +const retired = 'https://retired.example' +const empty = { type: 'output-list' as const, outputs: [] } +describe('reliable discovery and local write serialization', () => { + afterEach(() => jest.useRealTimers()) + async function advertisement(host: string) { + const lockingScript = await new OverlayAdminTokenTemplate(author as any).lock( + 'SLAP', + host, + 'ls_kvstore' + ) + return { + beef: new Transaction(1, [], [{ lockingScript, satoshis: 1 }], 0).toBEEF(), + outputIndex: 0 + } + } + it('collects the later tracker candidate despite a retired first advertisement', async () => { + const stale = await advertisement(retired) + const fresh = await advertisement(good) + jest.useFakeTimers() + const lookup = jest.fn(async host => { + if (host === 'https://first.example') return { ...empty, outputs: [stale] } + if (host === 'https://second.example') { + await new Promise(resolve => setTimeout(resolve, 400)) + return { ...empty, outputs: [fresh] } + } + if (host === retired) return await new Promise(() => {}) + return empty + }) + const resolver = new LookupResolver({ + facilitator: { lookup }, + slapTrackers: ['https://first.example', 'https://second.example'] + }) + const pending = resolver.queryReliable( + { service: 'ls_kvstore', query: {} }, + { validate: async () => ['validated fixture'] } + ) + await jest.advanceTimersByTimeAsync(5000) + const result = await pending + expect(result.hosts).toContainEqual({ + host: good, + kind: 'answer', + values: ['validated fixture'] + }) + expect(result.durationMs).toBe(2400) + }) + it('does not cache failed discovery across attempts', async () => { + const fresh = await advertisement(good) + let recovered = false + const lookup = jest.fn(async host => + host === good ? empty : recovered ? { ...empty, outputs: [fresh] } : empty + ) + const resolver = new LookupResolver({ + facilitator: { lookup }, + slapTrackers: ['https://tracker.example'] + }) + const question = { service: 'ls_kvstore', query: {} } + expect( + (await resolver.queryReliable(question, { validate: async () => [] })).hosts + ).toHaveLength(0) + recovered = true + expect( + (await resolver.queryReliable(question, { validate: async () => [] })).hosts + ).toHaveLength(1) + }) + it('serializes competing operations across store instances and releases after failure', async () => { + jest.useFakeTimers() + const timeline: string[] = [] + const a = withKVWriteLock('synthetic scope', async () => { + timeline.push('a:start') + await new Promise(resolve => setTimeout(resolve, 50)) + timeline.push('a:end') + throw new Error('synthetic') + }).catch(() => {}) + const b = withKVWriteLock('synthetic scope', async () => { + timeline.push('b:start') + }) + await jest.advanceTimersByTimeAsync(60) + await Promise.all([a, b]) + expect(timeline).toEqual(['a:start', 'a:end', 'b:start']) + }) +}) diff --git a/packages/sdk/src/overlay-tools/__tests/ReliableTopicBroadcaster.test.ts b/packages/sdk/src/overlay-tools/__tests/ReliableTopicBroadcaster.test.ts new file mode 100644 index 000000000..634bd7843 --- /dev/null +++ b/packages/sdk/src/overlay-tools/__tests/ReliableTopicBroadcaster.test.ts @@ -0,0 +1,83 @@ +import { ReliableTopicBroadcaster } from '../ReliableTopicBroadcaster' +import OverlayAdminTokenTemplate from '../OverlayAdminTokenTemplate' +import Transaction from '../../transaction/Transaction' +import { fixture, author } from '../../kvstore/__tests/fixtures/reliableKV' + +const good = 'https://good.example' +const bad = 'https://bad.example' +const ack = { tm_kvstore: { outputsToAdmit: [0], coinsToRetain: [], coinsRemoved: [] } } +async function resolverFor(hosts: string[]) { + const ads = await Promise.all( + hosts.map(async host => { + const script = await new OverlayAdminTokenTemplate(author as any).lock( + 'SHIP', + host, + 'tm_kvstore' + ) + return { + beef: new Transaction(1, [], [{ lockingScript: script, satoshis: 1 }], 0).toBEEF(), + outputIndex: 0 + } + }) + ) + return { + queryReliable: jest.fn(async (_question, options) => ({ + hosts: [ + { + host: 'https://tracker.example', + kind: 'answer', + values: await options.validate({ type: 'output-list', outputs: ads }) + } + ], + discoveryComplete: true, + durationMs: 0 + })) + } +} +describe('bounded submission adapter', () => { + afterEach(() => jest.useRealTimers()) + it('submits concurrently, aborts the dead host and preserves canonical topics encoding', async () => { + const f = await fixture('submission') + const resolver = await resolverFor([bad, good]) + jest.useFakeTimers() + const signals: AbortSignal[] = [] + const fetch = jest.fn(async (url, init) => { + signals.push(init.signal) + expect(init.headers['X-Topics']).toBe('tm_kvstore') + if (url.startsWith(bad)) return await new Promise(() => {}) + return new Response(JSON.stringify(ack)) + }) + const broadcaster = new ReliableTopicBroadcaster(['tm_kvstore'], resolver as any, false, fetch) + const pending = broadcaster.broadcast(f.tx) + await jest.advanceTimersByTimeAsync(2100) + expect(await pending).toMatchObject({ status: 'success', txid: f.tx.id('hex') }) + expect(fetch).toHaveBeenCalledTimes(2) + expect(signals.every(signal => signal.aborted)).toBe(true) + }) + it.each([{}, { status: 'error' }, { tm_kvstore: { outputsToAdmit: [500] } }])( + 'rejects malformed or explicit error acknowledgment %j', + async response => { + const f = await fixture('rejected') + const resolver = await resolverFor([good]) + const broadcaster = new ReliableTopicBroadcaster( + ['tm_kvstore'], + resolver as any, + false, + jest.fn(async () => new Response(JSON.stringify(response))) + ) + expect(await broadcaster.broadcast(f.tx)).toMatchObject({ status: 'error' }) + } + ) + it('rediscovers after failure instead of persisting a stale submission host', async () => { + const f = await fixture('recovery') + const resolver = await resolverFor([good]) + const fetch = jest + .fn() + .mockResolvedValueOnce(new Response('', { status: 500 })) + .mockResolvedValueOnce(new Response(JSON.stringify(ack))) + const broadcaster = new ReliableTopicBroadcaster(['tm_kvstore'], resolver as any, false, fetch) + expect((await broadcaster.broadcast(f.tx)).status).toBe('error') + expect((await broadcaster.broadcast(f.tx)).status).toBe('success') + expect(resolver.queryReliable).toHaveBeenCalledTimes(2) + }) +}) From 5f33c2116c7776c35ceabcede7b76919e8c709d5 Mon Sep 17 00:00:00 2001 From: Brayden Langley Date: Fri, 4 Sep 2026 14:33:40 -0700 Subject: [PATCH 02/11] Apply reliability core to every standard overlay lookup resolver --- docs/packages/sdk/bsv-sdk.md | 2 +- docs/reference/package-api-migrations.md | 6 +- docs/reference/stack-facts.md | 2 +- governance/package-release-notes.json | 6 +- governance/repository-health/baselines.json | 2 +- packages/sdk/CHANGELOG.md | 7 +- packages/sdk/README.md | 24 +- .../sdk/docs/globalkv-reliability-draft.md | 44 +- .../docs/globalkv-reliability-validation.md | 55 +- packages/sdk/docs/overlay-lookup-migration.md | 116 +++ packages/sdk/package.json | 2 +- .../LookupResolver.diagnostics.test.ts | 12 +- .../__tests/GlobalKVStore.reliable.test.ts | 4 +- .../sdk/src/overlay-tools/LookupResolver.ts | 708 +++++++----------- .../ReliableHTTPSLookupFacilitator.ts | 65 +- .../overlay-tools/ReliableHostReputation.ts | 12 +- .../sdk/src/overlay-tools/ReliableLookup.ts | 36 +- .../overlay-tools/ReliableLookupResolver.ts | 112 +-- .../__tests/LookupResolver.additional.test.ts | 261 +++---- ...LookupResolver.poison-reproduction.test.ts | 30 +- .../__tests/LookupResolver.resiliency.test.ts | 110 +-- .../LookupResolver.shared-reliability.test.ts | 282 +++++++ .../__tests/LookupResolver.test.ts | 149 ++-- .../src/overlay-tools/boundLookupResponse.ts | 43 ++ packages/sdk/src/overlay-tools/index.ts | 11 + 25 files changed, 1168 insertions(+), 933 deletions(-) create mode 100644 packages/sdk/docs/overlay-lookup-migration.md create mode 100644 packages/sdk/src/overlay-tools/__tests/LookupResolver.shared-reliability.test.ts create mode 100644 packages/sdk/src/overlay-tools/boundLookupResponse.ts diff --git a/docs/packages/sdk/bsv-sdk.md b/docs/packages/sdk/bsv-sdk.md index 88203bf6c..ba94c694f 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.5.0' +version: '3.0.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 03c5a6b84..4c4924755 100644 --- a/docs/reference/package-api-migrations.md +++ b/docs/reference/package-api-migrations.md @@ -48,7 +48,7 @@ and clean-consumer tests remain the executable type authority. | `@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. The proposed reliable path requires explicit chain-tracker and authority configuration; legacy APIs and browser state remain unchanged. This draft is unapproved and must not be published or deployed; see packages/sdk/docs/globalkv-reliability-draft.md. | +| `@bsv/sdk` | `2.4.0` | `3.0.0` | major | [API and usage](../packages/sdk/bsv-sdk.md) | Unapproved SDK 3.0.0 draft; do not publish or deploy. Standard query() now throws LookupUnavailableError for incomplete empty aggregates. queryDetailed()/query$() expose transport status and discoveryComplete; complete does not prove authority or freshness. All standard resolver calls use a 5-second total budget, 2-second host budgets, fresh discovery, 32 candidate hosts and 256 outputs per host; streamed responses are limited to 4 MiB. Legacy reputation records are ignored automatically; atomic v4 storage is configurable. Review packages/sdk/docs/overlay-lookup-migration.md, first-party peer compatibility and service-specific validation before any coordinated migration. The optional KV path additionally requires a trusted chain tracker and authority policy. Existing byte encodings and incorporated-code notice requirements remain unchanged. | | `@bsv/simple` | `0.4.1` | `0.5.2` | 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. 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. | @@ -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. Adds an opt-in, draft GlobalKVStore reliability API with bounded parallel discovery, advisory scoped reputation, verified observations and explicit incomplete/conflict outcomes. -- 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. The proposed reliable path requires explicit chain-tracker and authority configuration; legacy APIs and browser state remain unchanged. This draft is unapproved and must not be published or deployed; see packages/sdk/docs/globalkv-reliability-draft.md. +- 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. Moves bounded discovery, advisory network/service/host reputation, cancellation, response limits and failure evidence into the standard LookupResolver for all overlay lookup services. Adds optional verified GlobalKVStore observations and write reconciliation. +- Migration: Unapproved SDK 3.0.0 draft; do not publish or deploy. Standard query() now throws LookupUnavailableError for incomplete empty aggregates. queryDetailed()/query$() expose transport status and discoveryComplete; complete does not prove authority or freshness. All standard resolver calls use a 5-second total budget, 2-second host budgets, fresh discovery, 32 candidate hosts and 256 outputs per host; streamed responses are limited to 4 MiB. Legacy reputation records are ignored automatically; atomic v4 storage is configurable. Review packages/sdk/docs/overlay-lookup-migration.md, first-party peer compatibility and service-specific validation before any coordinated migration. The optional KV path additionally requires a trusted chain tracker and authority policy. Existing byte encodings and incorporated-code notice requirements remain unchanged. | Public subpath | Runtime target(s) | Declaration target(s) | | ---------------------------------- | ---------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------- | diff --git a/docs/reference/stack-facts.md b/docs/reference/stack-facts.md index 459e64e8e..02008a8f8 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.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/sdk` | `3.0.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 486622e05..c68a936df 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": "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 an opt-in, draft GlobalKVStore reliability API with bounded parallel discovery, advisory scoped reputation, verified observations and explicit incomplete/conflict outcomes.", - "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. The proposed reliable path requires explicit chain-tracker and authority configuration; legacy APIs and browser state remain unchanged. This draft is unapproved and must not be published or deployed; see packages/sdk/docs/globalkv-reliability-draft.md." + "releaseType": "major", + "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. Moves bounded discovery, advisory network/service/host reputation, cancellation, response limits and failure evidence into the standard LookupResolver for all overlay lookup services. Adds optional verified GlobalKVStore observations and write reconciliation.", + "migration": "Unapproved SDK 3.0.0 draft; do not publish or deploy. Standard query() now throws LookupUnavailableError for incomplete empty aggregates. queryDetailed()/query$() expose transport status and discoveryComplete; complete does not prove authority or freshness. All standard resolver calls use a 5-second total budget, 2-second host budgets, fresh discovery, 32 candidate hosts and 256 outputs per host; streamed responses are limited to 4 MiB. Legacy reputation records are ignored automatically; atomic v4 storage is configurable. Review packages/sdk/docs/overlay-lookup-migration.md, first-party peer compatibility and service-specific validation before any coordinated migration. The optional KV path additionally requires a trusted chain tracker and authority policy. Existing byte encodings and incorporated-code notice requirements remain unchanged." }, { "name": "@bsv/simple", diff --git a/governance/repository-health/baselines.json b/governance/repository-health/baselines.json index d8ec9f27a..aa6428bb0 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.5.0", + "@bsv/sdk": "3.0.0", "@bsv/verifast": "0.3.5", "@bsv/btms": "1.2.2", "@bsv/btms-permission-module": "1.1.4", diff --git a/packages/sdk/CHANGELOG.md b/packages/sdk/CHANGELOG.md index 6bd668f34..e2da159e7 100644 --- a/packages/sdk/CHANGELOG.md +++ b/packages/sdk/CHANGELOG.md @@ -214,8 +214,11 @@ All notable changes to this project will be documented in this file. The format ## [Unreleased] -- Draft 2.5.0: add opt-in verified GlobalKVStore observations, advisory scoped - reputation, bounded concurrent lookup and explicit incomplete/conflict states. +- Draft 3.0.0: apply advisory scoped reputation, fresh bounded discovery, + request cancellation, response limits and incomplete/unavailable evidence to + every standard overlay resolver method. Incomplete empty `query()` calls now + throw a retryable error. Add optional verified GlobalKVStore observations and + write reconciliation. See `docs/overlay-lookup-migration.md`. Unapproved; see `docs/globalkv-reliability-draft.md` before any migration. ### Added diff --git a/packages/sdk/README.md b/packages/sdk/README.md index 8fba32f40..c622b33e0 100644 --- a/packages/sdk/README.md +++ b/packages/sdk/README.md @@ -186,11 +186,19 @@ Incorporated material remains under the separate terms identified in Thank you for being a part of the BSV Blockchain Libraries Project. Let's build the future of BSV Blockchain together! -## Draft GlobalKVStore reliability API - -The optional `@bsv/sdk/kvstore/reliable` entry point exposes a `getResult` API distinguishes verified observations, partial answers, -authoritative absence, conflicts and temporary failure. It requires an explicit -network-correct chain tracker and completeness policy. See the -[unapproved design and migration proposal](docs/globalkv-reliability-draft.md) -for local evidence and material limits. Legacy behavior remains unchanged. -This proposal is not ready for publication or deployment. +## Draft overlay lookup reliability + +The unapproved SDK 3.0.0 candidate applies shared reliability to the standard +`LookupResolver.query`, `queryDetailed`, `query$`, and `queryReliable` methods for +all overlay lookup services. It includes bounded parallel discovery and requests, +advisory scoped reputation, cancellation, response limits, and explicit failure +and completion evidence. No resolver opt-in or KV-specific import is needed. + +`query()` now throws a retryable `LookupUnavailableError` for an incomplete empty +aggregate. See the [shared migration guide](docs/overlay-lookup-migration.md). +Transport completion does not prove authoritative absence, freshness, or a +service's cryptographic rules. Services can use `queryReliable` with their own +validator; the optional `@bsv/sdk/kvstore/reliable` integration supplies KV-specific +proof validation, spend reconciliation, write recovery, and UI state helpers. +See the [unapproved design proposal](docs/globalkv-reliability-draft.md) for evidence +and limits. This draft is not ready for publication or deployment. diff --git a/packages/sdk/docs/globalkv-reliability-draft.md b/packages/sdk/docs/globalkv-reliability-draft.md index 303163632..6b8337675 100644 --- a/packages/sdk/docs/globalkv-reliability-draft.md +++ b/packages/sdk/docs/globalkv-reliability-draft.md @@ -1,9 +1,11 @@ -# GlobalKVStore reliability proposal — unapproved, not ready for deployment +# Shared overlay lookup and GlobalKVStore reliability proposal — unapproved, not ready for deployment -This is a local investigation and opt-in implementation proposal, based on +This is a local investigation and shared implementation proposal, based on `98734b07cf` (2026-09-04). Nothing here authorizes merging, publishing, deployment, -changing discovery, or removing existing consumer overrides. The legacy path is -retained for compatibility and as an executable reproduction of the defect. +changing live discovery, or removing existing consumer overrides. The revised +SDK 3.0.0 draft moves shared reliability into the standard resolver for every +lookup service. KV-specific correctness and write recovery remain optional. +See `overlay-lookup-migration.md` for the deliberate contract changes. ## Proven failure sequence @@ -11,10 +13,11 @@ retained for compatibility and as an executable reproduction of the defect. in-process hosts. A future `backoffUntil` excludes a working host entirely. An empty peer then produces an empty answer. Resetting only the synthetic tracker immediately restores the data without changing the query or host behavior. -Both assertions pass against the unchanged legacy implementation. A third test +Both assertions passed against the legacy implementation at draft commit +`ff36b55`. They now assert recovery through the standard resolver. A third test uses only normal `recordFailure`/`flush` calls with a temporarily advanced browser clock; correcting the clock and reloading turns the generated penalty into more -than 300 days of exclusion. +than 300 days of legacy exclusion; the revised resolver ignores that record. The root cause spans several shared boundaries: @@ -111,9 +114,10 @@ URLs cannot be contacted. Existing emergency host overrides remain available. The total monotonic timer includes discovery, request parsing and validation. Each host has its own smaller budget. AbortSignal is passed to facilitators; standard fetch is cancelled. A non-cooperative custom facilitator cannot hold -the consumer promise open, but its own underlying work may continue. No early -first-answer cancellation is used because the current wire protocol supplies no -safe early-completion certificate. Cancellation at deadline/completion is tested. +the consumer promise open, but its own underlying work may continue. The verified `queryReliable` API does not use early +first-answer cancellation because the current wire protocol supplies no +safe early-completion certificate. Cancellation at deadline/completion is tested. Standard progressive callers can +explicitly close their iterator to cancel remaining work with an incomplete snapshot. ## Correctness and structured API @@ -121,7 +125,7 @@ safe early-completion certificate. Cancellation at deadline/completion is tested Each candidate validates BEEF parsing, actual txid versus any hint, output index, PushDrop fields, protocol/controller/key/tags selectors, derived controller lock, field signature and SPV verification before health credit or deduplication. -The optional HTTP facilitator bounds streamed responses to 4 MiB before parsing. +The standard HTTP facilitator bounds streamed responses to 4 MiB before parsing. Bounds are 256 outputs and 4 MiB BEEF per host. Aggregate outputs are deduplicated by the actual transaction hash and index. Within each protocol/controller/key, proven spending successors supersede ancestors. Incomparable tips return conflict; @@ -173,7 +177,8 @@ remain for legacy callers. The effective per-call protocol is used when unlockin The optional `ReliableTopicBroadcaster` now rediscovers SHIP hosts on every submission, bounds discovery and parallel submissions under one five-second deadline, propagates fetch cancellation and checks acknowledgment indices. -Returned errors are failures. Legacy broadcaster behavior is unchanged. +Returned errors are failures. The standard broadcaster retains its own SHIP cache +and submission behavior; its resolver calls inherit the shared lookup improvements. Competing replacement transactions now wait for independent indexing confirmation before the retry helper resumes. Replacement output zero is the GlobalKVStore contract; removal transactions still require a separate absence confirmation. @@ -218,10 +223,14 @@ Proposed sequence after human design approval: ## Review guide -Start with the two legacy reproduction tests. Review `ReliableHostReputation`, -then `ReliableLookupResolver.queryReliable`/`ReliableLookup`, then the cryptographic and reconciliation -boundary in `ReliableKVStore`. Review the opt-in adapter in `ReliableGlobalKVStore` last. The optional subpath keeps the legacy UMD -payload within its unchanged size budget. +Start with `LookupResolver.poison-reproduction.test.ts` and the service-agnostic +`LookupResolver.shared-reliability.test.ts`. Review `ReliableHostReputation`, then +`LookupResolver.resolveHosts`, `query$`, `queryReliable` and `ReliableLookup`. +`ReliableLookupResolver` and `ReliableHTTPSLookupFacilitator` are compatibility +aliases to the standard implementations, with no separate scheduling algorithm. +Review the cryptographic and reconciliation boundary in `ReliableKVStore`, then +the optional `ReliableGlobalKVStore` adapter. KV-specific helpers remain in the +optional subpath; the common fixes are included in the normal SDK and UMD. Run deterministic tests and real loopback fault injection before package checks. Read the limitations above before evaluating latency or completeness claims. This proposal must remain a draft: it is not a complete implementation of all @@ -246,8 +255,9 @@ session.stop() ``` The placeholders above must be supplied by the application; no permissive tracker -or authority is silently invented. Main SDK imports retain legacy behavior. The -new subpath has ESM, CJS and TypeScript export contracts. No production app pin or +or authority is silently invented. Main SDK imports use the shared reliability +implementation for all resolver methods. The KV-specific subpath has ESM, CJS +and TypeScript export contracts. No production app pin or compatibility patch has been removed. Open design decisions: authority membership and fault model; signed index diff --git a/packages/sdk/docs/globalkv-reliability-validation.md b/packages/sdk/docs/globalkv-reliability-validation.md index 56e2f64e1..a3a694a83 100644 --- a/packages/sdk/docs/globalkv-reliability-validation.md +++ b/packages/sdk/docs/globalkv-reliability-validation.md @@ -1,11 +1,60 @@ -# Local validation — unapproved GlobalKVStore draft +# Local validation — unapproved shared overlay lookup draft Date: 2026-09-04. Source base: `98734b07cf` on ts-stack main. All failure injection used synthetic data and isolated local processes. No live wallet, overlay, Kubernetes, DNS, discovery advertisement, secret or reputation state was touched. -## Results +## Shared resolver revision: SDK 3.0.0 candidate + +The standard `LookupResolver` now owns the shared implementation for every service +and every query API. The optional resolver/facilitator names are compatibility +aliases. This revision supersedes the original adapter-only scope below. + +| Check | Result | +| --- | --- | +| Full SDK coverage suite | 164 suites, 5,998 tests, 1 snapshot passed | +| Coverage | Statements 94.32%, branches 86.49%, functions 95.06%, lines 95.23% | +| New standard-export regressions | 15 tests: non-KV services, all three query APIs, advisory cooldown recovery, total deadline including discovery, iterator cancellation, progressive recovery, invalid BEEF/txid hints, scoped reputation, and generic validator API | +| SDK build and workspace typecheck | Passed | +| Root health, lint and format | Passed; 42 projects, 34 public packages, zero contract findings/control errors | +| Packed artifact contract | Passed ESM/CJS, all conditional/wildcard exports, strict types, source maps and publint; includes the root LookupUnavailableError export | +| Exact-tarball browser contract | Passed Vite, esbuild and UMD | +| Main UMD | 554,559 raw bytes, below unchanged 555,000-byte limit | +| Property profile | 3 suites, 6 tests passed | +| Conformance parser | 76 files, 6,690 vectors, zero structure/parse errors; no new portable protocol claim | +| Metanet Docs candidate integration | Exact local 3.0.0 tarball, standard and optional imports verified; 49 files / 185 tests and frontend build passed | +| Gloss candidate integration | Same exact tarball, standard import verified; TypeScript build and 4 tests passed | +| Dependency audit | Still fails: 8 high, 2 moderate (2 existing ignored findings), unchanged dependency graph | + +The original cooldown reproductions now assert successful recovery on the standard +resolver; their failing legacy behavior remains reproducible at commit `ff36b55`. +The shared tests exercise arbitrary `ls_custom`, identity, SHIP and KV service +names using the ordinary package export. Real loopback KV fault-injection tests +also run through the shared resolver and facilitator aliases in the full suite. +The default APIs now enforce the same core policies without a KV import. + +Controlled fake-clock measurements: 250 ms recovered-host/fast-empty case, +2,000 ms healthy-plus-hung case for each API, and 1,600 ms total including +1,400 ms discovery. These are local deterministic measurements, not production +SLO data. Explicit iterator close cancels the remaining requests and leaves no +resolver timers. Service-specific proofs/currentness remain distinct from +transport completion. + +The new default error/deadline/persistence contract is deliberately recorded as +an unapproved major candidate. Consumer manifests, locks, peer ranges, deployed +pins and compatibility adapters are unchanged; a future coordinated SDK 3 peer +migration is still required. Existing consumer preview commits are reused with +local extracted artifacts only. No artifact was published. + +Commands: the same invariant checks listed below; SDK `test:coverage --runInBand`, +`test:property`; `node scripts/check-package-artifact.mjs packages/sdk --exports +PrivateKey,PublicKey,Transaction,Script,WalletClient,ProtoWallet,AuthFetch,IdentityClient,LookupResolver,LookupUnavailableError,RemittanceManager +--esm-only-entrypoints ./umd`; `node scripts/check-browser-package.mjs packages/sdk`; +`pnpm conformance`; `pnpm audit:security`; and each consumer's local test/build. + +## Original adapter-only validation at `ff36b55` (historical) + | Check | Result | | ----------------------------------------------- | ---------------------------------------------------------------------------------------------- | @@ -48,7 +97,7 @@ version beneath root AJV tooling and `toml` beneath docs-site's `remark-mdx-frontmatter`. This branch has no dependency or lockfile change. No advisory was suppressed and no quality threshold or bundle budget was raised. The only baseline metadata change updates the SDK package version to its proposed -minor version; security baselines and accepted findings are unchanged. +major version; security baselines and accepted findings are unchanged. A public CI merge gate must not be represented as passing: the audit gate is known to be blocked on this dependency graph, and the draft is explicitly not diff --git a/packages/sdk/docs/overlay-lookup-migration.md b/packages/sdk/docs/overlay-lookup-migration.md new file mode 100644 index 000000000..609ee210c --- /dev/null +++ b/packages/sdk/docs/overlay-lookup-migration.md @@ -0,0 +1,116 @@ +# Shared overlay lookup migration — unapproved SDK 3.0.0 draft + +This candidate is intentionally a major version. It changes default scheduling, +error behavior, discovery caching, bounds, and persisted health handling. No +package has been published or deployed. All changes remain in a draft PR. + +## Scope + +Every SDK `LookupResolver` instance uses the same scheduling and reputation core, +regardless of service name, through `query`, `queryDetailed`, `query$`, or +`queryReliable`. This includes SDK consumers such as identity resolution and +SHIP discovery when they call this resolver. Custom third-party resolvers that +bypass it must adopt the shared API; server implementations do not change. + +`ReliableLookupResolver` is an alias for the standard resolver. The optional KV +entrypoint remains for KV-specific cryptography, reconciliation, indexing-aware +writes, and retained UI state. Those protocol rules cannot be generalized to +arbitrary services. Standard output aggregation checks response shape, BEEF +parsing and txid hints, but does not verify Merkle proofs, service membership, +output semantics, freshness, conflicts or authoritative absence. Supply a +service validator through `queryReliable` where those checks are needed. + +## Shared behavior + +- Fresh union of up to 32 SLAP trackers, each with a 1,500 ms bound; no stale or + empty discovery snapshot survives into the next operation. +- Up to 32 candidate hosts selected before reputation ordering. Every selected + host is probed concurrently, including cooled hosts. Truncation is explicit; + there is no unconditional availability guarantee outside the selected set. +- Default 2,000 ms host budget within one 5,000 ms operation budget, including + discovery. `options.deadlineMs` and the existing timeout argument tune these + within 1–30,000 ms. `queryReliable` also includes validation in its host budget. +- Standard HTTP responses are bounded to 4 MiB of streamed bytes before parsing, + and output-list responses to 256 outputs per host. Custom facilitators and + fetch implementations exposing only parsed bodies own their byte limits. +- Abort propagates through discovery and requests. Closing a progressive iterator + cancels outstanding work. Non-cooperative custom promises cannot extend the + caller's deadline, but arbitrary synchronous parsing/validator code cannot be + preempted on the same JavaScript thread. Worker isolation remains unresolved. +- v4 reputation is scoped by network/service/normalized host, bounded and decays. + v1–v3 records are ignored without deletion. Web Locks protect browser updates; + custom persistence requires `reliableReputationStorage` with a lock. Unsupported + storage environments and legacy get/set-only adapters use in-memory health. +- HTTP semantic rejection and valid freeform responses remain neutral for + standard availability reputation. HTTP 408/425/429 and 5xx are availability + failures. The explicit validator API can apply reason-specific rejection and + invalid-proof penalties. Successful validated probes rehabilitate immediately. + +Discovery cache knobs `hostsTtlMs`/`hostsMaxEntries` remain accepted but are +ignored. Transaction memo TTL is retained. Response wire encodings, HTTPS rules, +host overrides and additional hosts remain supported. Overrides for `ls_slap` +now follow the same precedence as other services. + +## Failure and completion contracts + +Before, `query()` could return `{ type: 'output-list', outputs: [] } when requests +failed. It now throws `LookupUnavailableError` with `retryable: true` and a +progress envelope for incomplete empty aggregates. A complete empty aggregate +still means only that the selected hosts answered empty. It is not a non-inclusion +proof or a claim about undiscovered hosts. + +```ts +import { LookupResolver, LookupUnavailableError } from '@bsv/sdk' + +const resolver = new LookupResolver() +try { + const answer = await resolver.query(question) + consumeObservedOutputs(answer.outputs) +} catch (error) { + if (!(error instanceof LookupUnavailableError)) throw error + retainLastKnownGoodAndOfferRetry() +} +``` + +`queryDetailed` and `query$` provide `progress.status` / `status`: + +| Status | Meaning | +| --- | --- | +| `complete` | Discovery completed without truncation and every selected host returned a structurally usable output list. | +| `incomplete` | At least one output-list response arrived, but some discovery or host work failed, was truncated or is still pending. | +| `unavailable` | No usable output-list response has arrived. | + +`query()` retains successful nonempty aggregate shapes, including partial data; +use `queryDetailed()` to inspect completeness before replacing cached collections. +`query$()` soft snapshots remain non-final and continue accumulating later answers. +`queryDetailed()` may return early when explicitly configured for soft/progressive +behavior; closing its iterator cancels remaining requests. Freeform results cannot +be aggregated and appear in the progress counters, never as authoritative empty. + +Service-specific cryptographic validation is available on the standard export: + +```ts +const result = await resolver.queryReliable(question, { + signal, + validate: async (answer, signal) => verifyServiceAnswer(answer, signal) +}) +``` + +The validator must establish the service's correctness requirements. The resolver +returns per-host evidence; it does not invent generic quorums or reconcile +arbitrary application state. KV's validator/reconciler is one implementation. + +## Proposed migration and rollback + +Review the changed SDK contract and inventory each consumer's use of empty results, +custom timeouts, response sizes, discovery cache settings and custom facilitators. +Coordinate SDK 3 peer ranges and dependent package releases only after maintainer +approval; this draft does not change their public ranges or deployed pins. Repack +and test each approved consumer, then adopt service-specific validators and UI +failure handling where needed. KV-specific write and authority guarantees have +additional unresolved requirements described in the companion design. + +A later rollback uses the prior package/configuration. Old reputation keys remain +untouched, so the prior resolver can still encounter its original cooldown defect. +Keep production overrides and compatibility patches until an approved rollout +has independently validated their removal. Nothing here authorizes a rollout. diff --git a/packages/sdk/package.json b/packages/sdk/package.json index fb15c63f5..a3b8c3c1b 100644 --- a/packages/sdk/package.json +++ b/packages/sdk/package.json @@ -1,6 +1,6 @@ { "name": "@bsv/sdk", - "version": "2.5.0", + "version": "3.0.0", "sideEffects": false, "engines": { "node": ">=22" diff --git a/packages/sdk/src/__tests/LookupResolver.diagnostics.test.ts b/packages/sdk/src/__tests/LookupResolver.diagnostics.test.ts index 08f724b60..62646812d 100644 --- a/packages/sdk/src/__tests/LookupResolver.diagnostics.test.ts +++ b/packages/sdk/src/__tests/LookupResolver.diagnostics.test.ts @@ -1,7 +1,7 @@ import LookupResolver, { OverlayLookupFacilitator } from '../overlay-tools/LookupResolver' import { TelemetryEvent } from '../telemetry/Telemetry' -function createReputationStorage (): { get: () => undefined, set: () => void } { +function createReputationStorage(): { get: () => undefined; set: () => void } { return { get: () => undefined, set: () => {} @@ -9,7 +9,7 @@ function createReputationStorage (): { get: () => undefined, set: () => void } { } describe('LookupResolver diagnostics', () => { - it('distinguishes authoritative empty results from host failures', async () => { + it('distinguishes complete empty observations from host failures', async () => { const emptyFacilitator: OverlayLookupFacilitator = { lookup: async () => ({ type: 'output-list', outputs: [] }) } @@ -39,7 +39,7 @@ describe('LookupResolver diagnostics', () => { }) const partialFacilitator: OverlayLookupFacilitator = { - lookup: async (host) => { + lookup: async host => { if (host.includes('failed')) throw new Error('network unavailable') return { type: 'output-list', outputs: [] } } @@ -75,7 +75,7 @@ describe('LookupResolver diagnostics', () => { lookup: async () => ({ type: 'output-list', outputs: [] }) }, hostOverrides: { - ls_private: ['https://overlay.example/private/path?secret=yes'] + ls_private: ['https://overlay.example/private/path', 'https://rejected.example?secret=yes'] }, reputationStorage: createReputationStorage(), telemetry: { @@ -94,7 +94,8 @@ describe('LookupResolver diagnostics', () => { service: 'ls_private', query: { presentationHash: privateHash, - snapshot: 'must-never-appear' + snapshot: 'must-never-appear', + secret: 'secret=yes' } }) @@ -106,5 +107,6 @@ describe('LookupResolver diagnostics', () => { expect(serialized).not.toContain('secret=yes') expect(serialized).toContain('https://overlay.example') expect(serialized).toContain('lookup-correlation') + expect(serialized).not.toContain('https://rejected.example') }) }) diff --git a/packages/sdk/src/kvstore/__tests/GlobalKVStore.reliable.test.ts b/packages/sdk/src/kvstore/__tests/GlobalKVStore.reliable.test.ts index 1ebda221a..e784d9a78 100644 --- a/packages/sdk/src/kvstore/__tests/GlobalKVStore.reliable.test.ts +++ b/packages/sdk/src/kvstore/__tests/GlobalKVStore.reliable.test.ts @@ -97,7 +97,7 @@ describe('verified KV reliability', () => { lookupResolver: resolver, reliability: { chainTracker, authoritativeHosts: [good] } }) - expect((await store.getResult(f.query)).kind).toBe(status < 500 ? 'rejected' : 'unavailable') + expect((await store.getResult(f.query)).kind).toBe(status === 404 ? 'rejected' : 'unavailable') }) it.each([ { type: 'output-list', outputs: [{ beef: [1, 2, 3], outputIndex: 0 }] }, @@ -279,7 +279,7 @@ describe('read validation boundaries', () => { jest.fn(async () => new Response('x', { headers: { 'content-length': '5000000' } })) ) await expect(facilitator.lookup(good, { service: 'ls_kvstore', query: {} })).rejects.toThrow( - 'malformed' + /malformed/i ) }) }) diff --git a/packages/sdk/src/overlay-tools/LookupResolver.ts b/packages/sdk/src/overlay-tools/LookupResolver.ts index 418b776a2..aad57e9ae 100644 --- a/packages/sdk/src/overlay-tools/LookupResolver.ts +++ b/packages/sdk/src/overlay-tools/LookupResolver.ts @@ -1,8 +1,19 @@ +import { boundLookupResponse } from './boundLookupResponse.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 { getOverlayHostReputationTracker, HostReputationTracker } from './HostReputationTracker.js' +import { ReliableHostReputation, type ReliableReputationStorage } from './ReliableHostReputation.js' +import { + withinDeadline, + monotonicNow, + boundedMs, + normalizeHosts, + requestReliableHost, + LookupValidationError, + type ReliableLookupOptions, + type ReliableLookupResult +} from './ReliableLookup.js' import { Telemetry, TelemetryConfig } from '../telemetry/Telemetry.js' import { normalizeBRC100ByteFields, stringifyBRC100 } from '../wallet/BRC100ByteEncoding.js' @@ -50,7 +61,7 @@ export type LookupFacilitatorAnswer = LookupAnswer | LookupFreeformAnswer /** * Per-call options for {@link LookupResolver.query} and {@link LookupResolver.query$}. - * All optional; defaults preserve prior behavior. + * All optional; shared deadline and failure semantics apply to every service. */ export interface LookupQueryOptions { /** @@ -92,6 +103,10 @@ export interface LookupQueryOptions { * alias; `waitForAllHosts` takes precedence when both are supplied. */ waitForAllHosts?: boolean + /** Total discovery and lookup budget. Default 5000 ms; maximum 30000 ms. */ + deadlineMs?: number + /** Cancels discovery and outstanding host requests. */ + signal?: AbortSignal /** Correlates resolver and downstream wallet telemetry without logging the query payload. */ correlationId?: string } @@ -134,6 +149,10 @@ export interface LookupAnswerProgress { rejectedHosts: number /** Hosts that returned a valid but non-aggregatable freeform response. */ freeformHosts: number + /** Whether every selected discovery tracker completed without truncation. */ + discoveryComplete?: boolean + /** Transport completion only; never proof of authoritative absence or freshness. */ + status?: 'complete' | 'incomplete' | 'unavailable' /** Correlation id used for privacy-safe distributed diagnostics. */ correlationId?: string } @@ -144,6 +163,15 @@ export interface LookupResolution { progress: LookupAnswerProgress } +/** An empty aggregate could not be distinguished from infrastructure failure. */ +export class LookupUnavailableError extends Error { + readonly retryable = true + constructor(readonly progress: LookupAnswerProgress) { + super('Overlay lookup temporarily unavailable or incomplete') + this.name = 'LookupUnavailableError' + } +} + /** Default SLAP trackers */ export const DEFAULT_SLAP_TRACKERS: string[] = [ // BSVA clusters @@ -177,7 +205,7 @@ export const DEFAULT_TTN_SLAP_TRACKERS: string[] = [ /** Public overlay network presets understood by lookup and SHIP routing. */ export type LookupNetworkPreset = 'mainnet' | 'testnet' | 'teratestnet' | 'local' -const MAX_TRACKER_WAIT_TIME = 5000 +const MAX_TRACKER_WAIT_TIME = 1500 const DEFAULT_LOOKUP_TIMEOUT = 2000 const DEFAULT_UNREACHABLE_NOTIFICATION_COOLDOWN_MS = 60_000 const MAX_NOTIFICATION_DEDUP_ENTRIES = 512 @@ -235,6 +263,7 @@ function isOutputListAnswer(value: unknown): value is LookupAnswer { return ( answer.type === 'output-list' && Array.isArray(answer.outputs) && + answer.outputs.length <= 256 && answer.outputs.every(isLookupOutput) ) } @@ -245,46 +274,6 @@ function isFreeformAnswer(value: unknown): value is LookupFreeformAnswer { return answer.type === 'freeform' && Object.hasOwn(answer, 'result') } -/** A wall-clock deadline that rejects after `timeoutMs`, optionally aborting a controller. */ -interface Deadline { - /** Rejects with `Error('Request timed out')` once the timer fires. */ - promise: Promise - /** Clears the underlying timer. Safe to call after the timer has already fired. */ - cancel: () => void - /** Returns true once the timer has fired. */ - didTimeOut: () => boolean -} - -function createDeadline(timeoutMs: number, controller?: AbortController): Deadline { - let expired = false - let timer: ReturnType | null = null - const promise = new Promise((_resolve, reject) => { - timer = setTimeout(() => { - expired = true - try { - controller?.abort() - } catch { - /* noop */ - } - reject(new Error('Request timed out')) - }, timeoutMs) - }) - return { - promise, - cancel: () => { - if (timer !== null) clearTimeout(timer) - }, - didTimeOut: () => expired - } -} - -function normalizeLookupError(err: unknown, timedOut: boolean): Error { - if (timedOut) return new Error('Request timed out') - if ((err as { name?: string })?.name === 'AbortError') return new Error('Request timed out') - if (err instanceof Error) return err - return new Error(Utils.toSafeString(err, 'Unknown error')) -} - /** * Returns true when the given Content-Type header value represents * `application/octet-stream`, ignoring case and any media-type parameters @@ -298,9 +287,9 @@ function isOctetStream(contentType: string | null): boolean { /** Internal cache options. Kept optional to preserve drop-in compatibility. */ interface CacheOptions { - /** How long (ms) a hosts entry is considered fresh. Default 5 minutes. */ + /** @deprecated Discovery is refreshed on every lookup; this setting is ignored. */ hostsTtlMs?: number - /** How many distinct services’ hosts to cache before evicting. Default 128. */ + /** @deprecated Discovery is no longer cached; this setting is ignored. */ hostsMaxEntries?: number /** How long (ms) to keep txId memoization. Default 10 minutes. */ txMemoTtlMs?: number @@ -324,12 +313,14 @@ export interface LookupResolverConfig { hostOverrides?: Record /** Map of lookup service names to arrays of hosts to use in addition to resolving via SLAP. */ additionalHosts?: Record - /** Optional cache tuning. */ + /** Transaction memo tuning; legacy host-cache options are accepted but ignored. */ cache?: CacheOptions - /** Optional storage for host reputation data. */ + /** @deprecated Use reliableReputationStorage for atomic v4 persistence. Legacy get/set stores use memory-only health. */ reputationStorage?: | 'localStorage' | { get: (key: string) => string | null | undefined; set: (key: string, value: string) => void } + /** Atomic v4 reputation storage. Legacy host-only records are ignored automatically. */ + reliableReputationStorage?: ReliableReputationStorage /** Optional privacy-bounded telemetry sink. Query payloads are never emitted. */ telemetry?: TelemetryConfig } @@ -369,32 +360,23 @@ export class HTTPSOverlayLookupFacilitator implements OverlayLookupFacilitator { async lookup( url: string, question: LookupQuestion, - timeout: number = 2000 + timeout: number = 2000, + signal?: AbortSignal ): 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() - const deadline = createDeadline(timeout, controller) - - // Hard wall-clock deadline: in some environments (e.g. browser/Electron CORS - // failures) the underlying fetch can stall without ever settling, and the - // 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) - // Swallow background rejection if the deadline wins first. - fetchPromise.catch(() => { - /* noop */ - }) - try { - return await Promise.race([fetchPromise, deadline.promise]) - } catch (e) { - throw normalizeLookupError(e, deadline.didTimeOut()) - } finally { - deadline.cancel() + return await withinDeadline( + async child => await this.performLookupRequest(url, question, child), + timeout, + signal + ) + } catch (error) { + if ((error as { name?: string })?.name === 'AbortError') throw new Error('Request timed out') + if (error instanceof Error) throw error + throw new Error(Utils.toSafeString(error, 'Unknown error')) } } @@ -412,7 +394,7 @@ export class HTTPSOverlayLookupFacilitator implements OverlayLookupFacilitator { body: stringifyBRC100({ service: question.service, query: question.query }), signal } - const response: Response = await this.fetchClient(`${url}/lookup`, fco) + let response: Response = await this.fetchClient(`${url}/lookup`, fco) if (!response.ok) { // 408/429 are availability/backpressure signals. Other 4xx responses // reject this request but do not prove that the host is unavailable, so @@ -427,6 +409,7 @@ export class HTTPSOverlayLookupFacilitator implements OverlayLookupFacilitator { : 'semantic' throw new LookupHTTPError(response.status, kind, response.statusText) } + response = await boundLookupResponse(response, signal) if (isOctetStream(response.headers.get('content-type'))) { return await this.parseOctetStreamLookup(response) } @@ -450,6 +433,7 @@ export class HTTPSOverlayLookupFacilitator implements OverlayLookupFacilitator { const payload = await response.arrayBuffer() const r = new Utils.Reader([...new Uint8Array(payload)]) const nOutpoints = r.readVarIntNum() + if (nOutpoints > 256) throw new LookupValidationError('malformed') const outpoints: Array<{ txid: string; outputIndex: number; context?: number[] }> = [] for (let i = 0; i < nOutpoints; i++) { const txid = Utils.toHex(r.read(32)) @@ -499,10 +483,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 @@ -510,10 +491,8 @@ interface LookupQuerySessionOptions { softTimeoutMs?: number waitForAllHosts: boolean correlationId?: string - resolveTxId: ( - output: LookupAnswer['outputs'][number], - now: number - ) => string | null + discoveryComplete: boolean + resolveTxId: (output: LookupAnswer['outputs'][number], now: number) => string | null } class LookupQuerySession { @@ -528,6 +507,7 @@ class LookupQuerySession { freeformHosts = 0 emittedFinal = false + private readonly discoveryComplete: boolean private readonly graceMs: number private readonly softTimeoutMs?: number private readonly waitForAllHosts: boolean @@ -547,6 +527,7 @@ class LookupQuerySession { constructor(options: LookupQuerySessionOptions) { this.hostCount = options.hostCount + this.discoveryComplete = options.discoveryComplete this.graceMs = options.graceMs this.softTimeoutMs = options.softTimeoutMs this.waitForAllHosts = options.waitForAllHosts @@ -616,9 +597,16 @@ class LookupQuerySession { failedHosts: this.failedHosts, rejectedHosts: this.rejectedHosts, freeformHosts: this.freeformHosts, - ...(this.correlationId !== undefined - ? { correlationId: this.correlationId } - : {}) + discoveryComplete: this.discoveryComplete, + status: + this.successfulHosts === 0 + ? 'unavailable' + : this.discoveryComplete && + this.completedHosts === this.hostCount && + this.successfulHosts === this.hostCount + ? 'complete' + : 'incomplete', + ...(this.correlationId !== undefined ? { correlationId: this.correlationId } : {}) } } @@ -635,11 +623,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) } @@ -664,9 +648,7 @@ class LookupQuerySession { } return { snapshot, - stop: - typeof this.softTimeoutMs === 'number' && - this.firstResponseAt !== null + stop: false } } @@ -696,18 +678,12 @@ 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 - while (this.completedHosts < this.hostCount && !stop) { + while ((this.completedHosts < this.hostCount || this.queue.length > 0) && !stop) { const event = await this.nextEvent() const outcome = this.processEvent(event) if (outcome.snapshot != null) yield outcome.snapshot @@ -742,15 +718,9 @@ export default class LookupResolver { protected readonly hostOverrides: Record protected readonly additionalHosts: Record protected readonly networkPreset: LookupNetworkPreset - private readonly hostReputation: HostReputationTracker + private readonly hostReputation: ReliableHostReputation private readonly telemetry: Telemetry - // ---- Caches / memoization ---- - private readonly hostsCache: Map - private readonly hostsInFlight: Map> - private readonly hostsTtlMs: number - private readonly hostsMaxEntries: number - private readonly txMemo: Map private readonly txMemoTtlMs: number @@ -774,27 +744,14 @@ export default class LookupResolver { this.additionalHosts = config.additionalHosts ?? {} this.telemetry = new Telemetry(config.telemetry) - const rs = config.reputationStorage - if (rs === 'localStorage') { - this.hostReputation = new HostReputationTracker() - } else if ( - typeof rs === 'object' && - rs !== null && - typeof rs.get === 'function' && - typeof rs.set === 'function' - ) { - this.hostReputation = new HostReputationTracker(rs) - } else { - this.hostReputation = getOverlayHostReputationTracker() - } - - // cache tuning - this.hostsTtlMs = config.cache?.hostsTtlMs ?? 5 * 60 * 1000 // 5 min - this.hostsMaxEntries = config.cache?.hostsMaxEntries ?? 128 - this.txMemoTtlMs = config.cache?.txMemoTtlMs ?? 10 * 60 * 1000 // 10 min + // A legacy get/set-only store cannot safely perform cross-tab read/modify/write. + // Keep it memory-only unless the caller supplies an atomic v4 store. + this.hostReputation = new ReliableHostReputation( + config.reliableReputationStorage ?? + (typeof config.reputationStorage === 'object' ? null : undefined) + ) + this.txMemoTtlMs = config.cache?.txMemoTtlMs ?? 10 * 60 * 1000 - this.hostsCache = new Map() - this.hostsInFlight = new Map() this.txMemo = new Map() this.advertisedBy = new Map() this.lastUnreachableNotificationAt = new Map() @@ -818,14 +775,17 @@ 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`). + * answered (or with a retryable error if no host has answered by `softTimeoutMs`). */ async query( question: LookupQuestion, timeout?: number, options?: LookupQueryOptions ): Promise { - return (await this.queryDetailed(question, timeout, options)).answer + const resolution = await this.queryDetailed(question, timeout, options) + if (resolution.answer.outputs.length === 0 && resolution.progress.status !== 'complete') + throw new LookupUnavailableError(resolution.progress) + return resolution.answer } /** @@ -878,84 +838,121 @@ 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] + /** Fresh bounded union across trackers; health never removes an eligible candidate. */ + private async resolveHosts( + question: LookupQuestion, + signal: AbortSignal, + remaining: () => number + ): Promise<{ hosts: string[]; complete: boolean }> { + let candidates: string[] + let complete = true + if (this.hostOverrides[question.service] !== undefined) { + candidates = this.hostOverrides[question.service].slice() } else if (this.networkPreset === 'local') { - hosts = ['http://localhost:8080'] + candidates = ['http://localhost:8080'] + } else if (question.service === 'ls_slap') { + candidates = this.slapTrackers.slice() } 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}` + const trackers = normalizeHosts(this.slapTrackers, false) + if (trackers.length === 0 || trackers.length > 32) complete = false + const answers = await Promise.all( + trackers.slice(0, 32).map(async tracker => { + const result = await requestReliableHost( + this.facilitator, + this.hostReputation, + this.networkPreset, + tracker, + { service: 'ls_slap', query: { service: question.service } }, + { + hostTimeoutMs: MAX_TRACKER_WAIT_TIME, + validate: async answer => { + if (!isOutputListAnswer(answer)) throw new LookupValidationError('malformed') + const hosts = this.extractHostsFromAnswer(answer, question.service) + if (hosts.length !== answer.outputs.length) complete = false + return hosts + } + }, + remaining(), + signal + ) + if (result.kind !== 'answer') { + complete = false + return [] + } + for (const host of result.values) { + if (this.advertisedBy.size >= 512) this.evictOldest(this.advertisedBy) + this.advertisedBy.set(host, tracker) + } + return result.values + }) ) + candidates = answers.flat() } - return hosts - } - - private isSlapRecoveryEligible(service: string): boolean { - return ( - service !== 'ls_slap' && - this.hostOverrides[service] == null && - this.networkPreset !== 'local' + candidates.push(...(this.additionalHosts[question.service] ?? [])) + if ( + candidates.some(host => normalizeHosts([host], this.networkPreset === 'local').length === 0) ) + complete = false + const hosts = normalizeHosts(candidates, this.networkPreset === 'local') + if (hosts.length === 0 || hosts.length > 32) complete = false + // Select before ranking: persisted health must not exclude a candidate at the cap. + return { + hosts: this.hostReputation.rank(this.networkPreset, question.service, hosts.slice(0, 32)), + complete + } } - private async rankedHostsFor(question: LookupQuestion): Promise { - const competentHosts = await this.competentHostsFor(question) - let rankedHosts: string[] + /** Service-specific verification on the same discovery, scheduling and reputation path. */ + async queryReliable( + question: LookupQuestion, + options: ReliableLookupOptions + ): Promise> { + const start = monotonicNow() + const deadlineMs = boundedMs(options.deadlineMs, 5000) + boundedMs(options.hostTimeoutMs, DEFAULT_LOOKUP_TIMEOUT) + const remaining = (): number => Math.max(0, deadlineMs - (monotonicNow() - start)) + const hosts: ReliableLookupResult['hosts'] = [] + let discoveryComplete = false 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.` + await withinDeadline( + async signal => { + const discovery = await this.resolveHosts(question, signal, remaining) + discoveryComplete = discovery.complete + await Promise.all( + discovery.hosts.map(async host => { + hosts.push( + await requestReliableHost( + this.facilitator, + this.hostReputation, + this.networkPreset, + host, + question, + { + ...options, + validate: async (answer, child) => { + if (!isOutputListAnswer(answer)) throw new LookupValidationError('malformed') + return await options.validate(answer, child) + } + }, + remaining(), + signal + ) + ) + }) + ) + }, + deadlineMs, + options.signal ) + } catch { + discoveryComplete = false } - return rankedHosts + return { hosts: hosts.slice(), discoveryComplete, durationMs: monotonicNow() - start } } - 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 } @@ -971,13 +968,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) @@ -1016,19 +1009,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, @@ -1050,13 +1034,7 @@ export default class LookupResolver { error ) if (!semanticRejection) { - this.notifyUnreachableHost( - host, - service, - error, - onUnreachableHost, - notificationCooldownMs - ) + this.notifyUnreachableHost(host, service, error, onUnreachableHost, notificationCooldownMs) } } @@ -1065,14 +1043,20 @@ export default class LookupResolver { question: LookupQuestion, timeout: number | undefined, session: LookupQuerySession, - options: LookupQueryOptions | undefined + options: LookupQueryOptions | undefined, + signal: AbortSignal, + remaining: () => number ): 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) + void this.lookupHostWithTracking( + host, + question, + Math.min(timeout ?? DEFAULT_LOOKUP_TIMEOUT, remaining()), + signal + ) .then(answer => { this.recordLookupHostAnswer( session, @@ -1084,15 +1068,22 @@ export default class LookupResolver { ) }) .catch(error => { - this.recordLookupHostFailure({ - session, - service: question.service, - host, - hostStartedAt, - correlationId, - onUnreachableHost: options?.onUnreachableHost, - notificationCooldownMs - }, error) + if (signal.aborted) { + session.recordAvailabilityFailure() + return + } + this.recordLookupHostFailure( + { + session, + service: question.service, + host, + hostStartedAt, + correlationId, + onUnreachableHost: options?.onUnreachableHost, + notificationCooldownMs + }, + error + ) }) .finally(() => { session.recordDone() @@ -1109,26 +1100,45 @@ export default class LookupResolver { * - 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. + * caller can `break` early to abort outstanding work. * - * No host work runs past its per-host `timeout` — there is no leak risk on early break. + * Resolver waits are bounded even for non-cooperative facilitators; only cooperative work can actually be aborted. */ async *query$( question: LookupQuestion, timeout?: number, options?: LookupQueryOptions ): AsyncIterable { - const rankedHosts = await this.rankedHostsFor(question) + const startedAt = monotonicNow() + const deadlineMs = boundedMs(options?.deadlineMs, 5000) + boundedMs(timeout, DEFAULT_LOOKUP_TIMEOUT) + const remaining = (): number => Math.max(0, deadlineMs - (monotonicNow() - startedAt)) + const controller = new AbortController() + const abort = (): void => controller.abort() + const timer = setTimeout(abort, deadlineMs) + if (options?.signal?.aborted === true) abort() + else options?.signal?.addEventListener('abort', abort, { once: true }) + let discovery: { hosts: string[]; complete: boolean } + try { + discovery = await withinDeadline( + async signal => await this.resolveHosts(question, signal, remaining), + remaining(), + controller.signal + ) + } catch { + discovery = { hosts: [], complete: false } + } + const rankedHosts = discovery.hosts const hostCount = rankedHosts.length const correlationId = options?.correlationId ?? (this.telemetry.enabled ? this.telemetry.createCorrelationId() : undefined) const session = new LookupQuerySession({ hostCount, + discoveryComplete: discovery.complete, 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) }) @@ -1150,7 +1160,9 @@ export default class LookupResolver { question, timeout, session, - options + options, + controller.signal, + remaining ) try { @@ -1165,6 +1177,9 @@ export default class LookupResolver { yield progress } } finally { + clearTimeout(timer) + options?.signal?.removeEventListener('abort', abort) + controller.abort() if (!session.emittedFinal) { this.telemetry.capture({ name: 'sdk.overlay.lookup.cancelled', @@ -1182,71 +1197,6 @@ export default class LookupResolver { } } - /** - * 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() - } - - // 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) - }) - ) - } - return cached.hosts.slice() - } - - // no cache: coalesce concurrent requests - if (this.hostsInFlight.has(service)) { - try { - const hosts = await this.hostsInFlight.get(service) - if (typeof hosts !== 'object') { - throw new TypeError('Hosts is not defined.') - } - return hosts.slice() - } catch { - // fall through to a fresh attempt below - } - } - - const promise = this.refreshHosts(service).finally(() => { - this.hostsInFlight.delete(service) - }) - this.hostsInFlight.set(service, promise) - const hosts = await promise - return hosts.slice() - } - - /** - * 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. */ @@ -1270,89 +1220,23 @@ 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 - * the BEEF byte sequence. Returns null when the BEEF is unparseable. + * hint only after it matches Transaction.fromBEEF(beef).id('hex'), memoized by + * the BEEF byte sequence. Returns null for unparseable BEEF or a mismatched hint. */ private resolveTxIdForOutput( output: { txid?: string; beef: number[]; outputIndex: number; context?: number[] }, now: number ): string | null { - if (typeof output.txid === 'string' && output.txid.length > 0) { - return output.txid - } const keyForBeef = Array.isArray(output.beef) ? output.beef.join(',') : '' const memo = this.txMemo.get(keyForBeef) if (typeof memo === 'object' && memo !== null && memo.expiresAt > now) { - return memo.txId + return output.txid === undefined || output.txid.toLowerCase() === memo.txId ? memo.txId : null } try { const txId = Transaction.fromBEEF(output.beef).id('hex') + if (output.txid !== undefined && output.txid.toLowerCase() !== txId) return null if (this.txMemo.size > 4096) this.evictOldest(this.txMemo) this.txMemo.set(keyForBeef, { txId, expiresAt: now + this.txMemoTtlMs }) return txId @@ -1375,64 +1259,43 @@ export default class LookupResolver { } } - private prepareHostsForQuery(hosts: string[], context: string): string[] { - if (hosts.length === 0) return [] - const now = Date.now() - const ranked = this.hostReputation.rankHosts(hosts, now) - const available = ranked.filter(h => h.backoffUntil <= now).map(h => h.host) - if (available.length > 0) return available - - const soonest = Math.min(...ranked.map(h => h.backoffUntil)) - const waitMs = Math.max(soonest - now, 0) - throw new Error( - `All ${context} hosts are backing off for approximately ${waitMs}ms due to repeated failures.` - ) - } - private async lookupHostWithTracking( host: string, question: LookupQuestion, - timeout?: number + timeout: number, + signal: AbortSignal ): Promise { - const startedAt = Date.now() - const effectiveTimeout = - typeof timeout === 'number' && Number.isFinite(timeout) && timeout >= 0 - ? timeout - : DEFAULT_LOOKUP_TIMEOUT - const deadline = createDeadline(effectiveTimeout) - // 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) + let failure: unknown + const result = await requestReliableHost( + this.facilitator, + this.hostReputation, + this.networkPreset, + host, + question, + { + hostTimeoutMs: Math.max(1, timeout), + onError: error => { + failure = error + }, + credit: values => values[0]?.type === 'output-list', + penalizeRejections: false, + validate: async answer => { + if (!isOutputListAnswer(answer) && !isFreeformAnswer(answer)) + throw new LookupValidationError('malformed') + if ( + isOutputListAnswer(answer) && + answer.outputs.some(output => this.resolveTxIdForOutput(output, Date.now()) === null) + ) + throw new LookupValidationError('malformed') + return [answer] + } + }, + timeout, + signal ) - lookupPromise.catch(() => { - /* deadline may win while custom facilitator settles later */ - }) - - let answer: LookupFacilitatorAnswer - try { - answer = await Promise.race([lookupPromise, deadline.promise]) - } catch (err) { - const normalized = normalizeLookupError(err, deadline.didTimeOut()) - if (!isSemanticLookupRejection(err)) this.hostReputation.recordFailure(host, normalized) - throw isSemanticLookupRejection(err) ? err : normalized - } finally { - deadline.cancel() - } - - 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 + if (result.kind === 'answer') return result.values[0] + if (failure !== undefined) throw failure + throw new Error(`Lookup response ${result.kind}`) } private captureHostTelemetry( @@ -1469,8 +1332,7 @@ export default class LookupResolver { progress: LookupAnswerProgress, durationMs: number ): void { - const degraded = - progress.failedHosts > 0 || progress.rejectedHosts > 0 || progress.freeformHosts > 0 + const degraded = progress.status !== 'complete' this.telemetry.capture({ name: 'sdk.overlay.lookup.completed', component: 'sdk.lookup-resolver', diff --git a/packages/sdk/src/overlay-tools/ReliableHTTPSLookupFacilitator.ts b/packages/sdk/src/overlay-tools/ReliableHTTPSLookupFacilitator.ts index d7cd623be..9209fb721 100644 --- a/packages/sdk/src/overlay-tools/ReliableHTTPSLookupFacilitator.ts +++ b/packages/sdk/src/overlay-tools/ReliableHTTPSLookupFacilitator.ts @@ -1,63 +1,2 @@ -import { - HTTPSOverlayLookupFacilitator, - type LookupQuestion, - type LookupFacilitatorAnswer -} from './LookupResolver.js' -import { withinDeadline, LookupValidationError } from './ReliableLookup.js' - -const MAX_RESPONSE_BYTES = 4 * 1024 * 1024 - -/** Bound untrusted wire bytes before JSON or BEEF parsing in the optional adapter. */ -export default class ReliableHTTPSLookupFacilitator extends HTTPSOverlayLookupFacilitator { - constructor(httpClient: typeof fetch = globalThis.fetch.bind(globalThis), allowHTTP = false) { - super(async (input, init) => { - const response = await httpClient(input, init) - if (!response.ok) return response - if (Number(response.headers.get('content-length')) > MAX_RESPONSE_BYTES) { - await response.body?.cancel() - throw new LookupValidationError('malformed') - } - const reader = response.body?.getReader() - if (reader === undefined) throw new LookupValidationError('malformed') - const chunks: Uint8Array[] = [] - let length = 0 - const abort = (): void => { - void reader.cancel().catch(() => {}) - } - init?.signal?.addEventListener('abort', abort, { once: true }) - try { - while (true) { - if (init?.signal?.aborted === true) throw new Error('Lookup aborted') - const { value, done } = await reader.read() - if (done) break - length += value.byteLength - if (length > MAX_RESPONSE_BYTES) throw new LookupValidationError('malformed') - chunks.push(value) - } - const bytes = new Uint8Array(length) - let offset = 0 - for (const chunk of chunks) { - bytes.set(chunk, offset) - offset += chunk.byteLength - } - return new Response(bytes, { status: response.status, headers: response.headers }) - } finally { - init?.signal?.removeEventListener('abort', abort) - await reader.cancel().catch(() => {}) - } - }, allowHTTP) - } - override async lookup( - url: string, - question: LookupQuestion, - timeout = 2000, - signal?: AbortSignal - ): Promise { - if (!url.startsWith('https:') && !this.allowHTTP) throw new Error('HTTPS required') - return await withinDeadline( - async child => await this.performLookupRequest(url, question, child), - timeout, - signal - ) - } -} +/** Compatibility name: response limits and cancellation are now shared by the standard facilitator. */ +export { HTTPSOverlayLookupFacilitator as default } from './LookupResolver.js' diff --git a/packages/sdk/src/overlay-tools/ReliableHostReputation.ts b/packages/sdk/src/overlay-tools/ReliableHostReputation.ts index e77467f3f..badbcfd3e 100644 --- a/packages/sdk/src/overlay-tools/ReliableHostReputation.ts +++ b/packages/sdk/src/overlay-tools/ReliableHostReputation.ts @@ -36,7 +36,10 @@ function browserStorage(): ReliableReputationStorage | undefined { /** v1-v3 are intentionally not imported: their host-only keys cannot be safely scoped. */ export class ReliableHostReputation { private entries: Record = {} - constructor(private readonly storage = browserStorage()) {} + private readonly storage: ReliableReputationStorage | undefined + constructor(storage?: ReliableReputationStorage | null) { + this.storage = storage === null ? undefined : (storage ?? browserStorage()) + } private scope(network: string, service: string, host: string): string { return JSON.stringify([network, service, host]) @@ -104,6 +107,13 @@ export class ReliableHostReputation { return [...new Set(hosts)].sort((a, b) => score(a) - score(b)) } + /** Sanitized diagnostic snapshot for one explicit scope. */ + snapshot(network: string, service: string, host: string): ReliableReputationEntry | undefined { + this.rank(network, service, [host]) + const entry = this.entries[this.scope(network, service, host)] + return entry === undefined ? undefined : { ...entry } + } + async record( network: string, service: string, diff --git a/packages/sdk/src/overlay-tools/ReliableLookup.ts b/packages/sdk/src/overlay-tools/ReliableLookup.ts index 905723cb5..87cf2ea50 100644 --- a/packages/sdk/src/overlay-tools/ReliableLookup.ts +++ b/packages/sdk/src/overlay-tools/ReliableLookup.ts @@ -1,4 +1,9 @@ -import type { LookupAnswer, LookupQuestion, OverlayLookupFacilitator } from './LookupResolver.js' +import type { + LookupAnswer, + LookupFacilitatorAnswer, + LookupQuestion, + OverlayLookupFacilitator +} from './LookupResolver.js' import { ReliableHostReputation, type HostFailureReason } from './ReliableHostReputation.js' export type ReliableHostOutcome = @@ -26,7 +31,7 @@ export class LookupValidationUnavailableError extends Error { } export class LookupValidationError extends Error { constructor(readonly reason: 'malformed' | 'invalid') { - super(`Lookup response ${reason}`) + super(reason === 'malformed' ? 'Malformed lookup response' : 'Invalid lookup response') this.name = 'LookupValidationError' } } @@ -50,7 +55,7 @@ export async function withinDeadline( const deadline = new Promise((_resolve, reject) => { abort = () => { controller.abort() - reject(new Error('Lookup deadline exceeded')) + reject(new Error('Request timed out')) } timer = setTimeout(abort, Math.max(0, ms)) if (parent?.aborted === true) abort() @@ -91,7 +96,12 @@ export async function requestReliableHost( network: string, host: string, question: LookupQuestion, - options: ReliableLookupOptions, + options: Omit, 'validate'> & { + validate: (answer: LookupFacilitatorAnswer, signal: AbortSignal) => Promise + credit?: (values: T[]) => boolean + onError?: (error: unknown) => void + penalizeRejections?: boolean + }, remainingMs: number, parent: AbortSignal ): Promise> { @@ -100,26 +110,34 @@ export async function requestReliableHost( const values = await withinDeadline( async signal => { const answer = await facilitator.lookup(host, question, budget, signal) - if (answer?.type !== 'output-list' || !Array.isArray(answer.outputs)) - throw new LookupValidationError('malformed') return await options.validate(answer, signal) }, budget, parent ) - if (!parent.aborted) void reputation.record(network, question.service, host) + if (!parent.aborted && options.credit?.(values) !== false) + void reputation.record(network, question.service, host) return { host, kind: 'answer', values } } catch (error) { + options.onError?.(error) let reason: HostFailureReason = 'transport' if (error instanceof LookupValidationError) reason = error.reason else if (error instanceof Error && /deadline|timed out|abort/i.test(error.message)) reason = 'timeout' else if (error instanceof SyntaxError) reason = 'malformed' else if (typeof error === 'object' && error !== null && 'status' in error) { - reason = Number(error.status) < 500 ? 'rejected' : 'transport' + const status = Number(error.status) + reason = + status >= 400 && status < 500 && ![408, 425, 429].includes(status) + ? 'rejected' + : 'transport' } // Cancellation belongs to the operation, not to the host. - if (!parent.aborted && !(error instanceof LookupValidationUnavailableError)) + if ( + !parent.aborted && + !(error instanceof LookupValidationUnavailableError) && + !(reason === 'rejected' && options.penalizeRejections === false) + ) void reputation.record(network, question.service, host, reason) return { host, kind: reason } } diff --git a/packages/sdk/src/overlay-tools/ReliableLookupResolver.ts b/packages/sdk/src/overlay-tools/ReliableLookupResolver.ts index ca7ee28d1..d00b6bc91 100644 --- a/packages/sdk/src/overlay-tools/ReliableLookupResolver.ts +++ b/packages/sdk/src/overlay-tools/ReliableLookupResolver.ts @@ -1,109 +1,3 @@ -import ReliableHTTPSLookupFacilitator from './ReliableHTTPSLookupFacilitator.js' -import LookupResolver, { type LookupResolverConfig, type LookupQuestion } from './LookupResolver.js' -import { ReliableHostReputation, type ReliableReputationStorage } from './ReliableHostReputation.js' -import { - withinDeadline, - monotonicNow, - boundedMs, - normalizeHosts, - requestReliableHost, - type ReliableLookupOptions, - type ReliableLookupResult -} from './ReliableLookup.js' - -export interface ReliableLookupResolverConfig extends LookupResolverConfig { - reliableReputationStorage?: ReliableReputationStorage -} -/** Optional resolver adapter; deliberately excluded from the legacy UMD bundle. */ -export default class ReliableLookupResolver extends LookupResolver { - private readonly reliableReputation: ReliableHostReputation - constructor(config: ReliableLookupResolverConfig = {}) { - super({ - ...config, - facilitator: - config.facilitator ?? - new ReliableHTTPSLookupFacilitator(undefined, config.networkPreset === 'local') - }) - this.reliableReputation = new ReliableHostReputation(config.reliableReputationStorage) - } - async queryReliable( - question: LookupQuestion, - options: ReliableLookupOptions - ): Promise> { - const startedAt = monotonicNow() - const deadlineMs = boundedMs(options.deadlineMs, 5000) - boundedMs(options.hostTimeoutMs, 2000) - const remaining = (): number => Math.max(0, deadlineMs - (monotonicNow() - startedAt)) - const settled: ReliableLookupResult['hosts'] = [] - let discoveryComplete = true - try { - await withinDeadline( - async signal => { - let candidates: string[] = [] - if (this.hostOverrides[question.service] !== undefined) { - candidates = this.hostOverrides[question.service].slice() - } else if (this.networkPreset === 'local') { - candidates = ['http://localhost:8080'] - } else if (question.service === 'ls_slap') { - candidates = this.slapTrackers.slice() - } else { - // Discover the union. No first-tracker cache and no reputation exclusion. - const trackers = normalizeHosts(this.slapTrackers, false) - const answers = await Promise.all( - trackers.slice(0, 32).map(async host => { - try { - const answer = await withinDeadline( - async child => - await this.facilitator.lookup( - host, - { service: 'ls_slap', query: { service: question.service } }, - Math.min(1500, remaining()), - child - ), - Math.min(1500, remaining()), - signal - ) - if (answer?.type !== 'output-list' || !Array.isArray(answer.outputs)) - throw new Error('Invalid discovery response') - return this.extractHostsFromAnswer(answer, question.service) - } catch { - discoveryComplete = false - return [] - } - }) - ) - if (trackers.length === 0 || trackers.length > 32) discoveryComplete = false - candidates = answers.flat() - } - candidates.push(...(this.additionalHosts[question.service] ?? [])) - const hosts = normalizeHosts(candidates, this.networkPreset === 'local') - if (hosts.length > 32 || hosts.length === 0) discoveryComplete = false - // Every selected host is probed, including cooled hosts. Reputation is ordering only. - await Promise.all( - this.reliableReputation - .rank(this.networkPreset, question.service, hosts) - .slice(0, 32) - .map(async host => { - const outcome = await requestReliableHost( - this.facilitator, - this.reliableReputation, - this.networkPreset, - host, - question, - options, - remaining(), - signal - ) - settled.push(outcome) - }) - ) - }, - deadlineMs, - options.signal - ) - } catch { - discoveryComplete = false - } - return { hosts: settled.slice(), discoveryComplete, durationMs: monotonicNow() - startedAt } - } -} +/** Compatibility name for the shared resolver; reliability applies to every entrypoint. */ +export { default } from './LookupResolver.js' +export type { LookupResolverConfig as ReliableLookupResolverConfig } from './LookupResolver.js' 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..fa8171e36 100644 --- a/packages/sdk/src/overlay-tools/__tests/LookupResolver.additional.test.ts +++ b/packages/sdk/src/overlay-tools/__tests/LookupResolver.additional.test.ts @@ -1,10 +1,11 @@ +import { ReliableHostReputation } from '../ReliableHostReputation' import LookupResolver, { HTTPSOverlayLookupFacilitator, DEFAULT_SLAP_TRACKERS, DEFAULT_TESTNET_SLAP_TRACKERS, DEFAULT_TTN_SLAP_TRACKERS } from '../LookupResolver' -import { getOverlayHostReputationTracker, HostReputationTracker } from '../HostReputationTracker' +import { getOverlayHostReputationTracker } from '../HostReputationTracker' import OverlayAdminTokenTemplate from '../../overlay-tools/OverlayAdminTokenTemplate' import { CompletedProtoWallet } from '../../auth/certificates/__tests/CompletedProtoWallet' import { PrivateKey } from '../../primitives/index' @@ -26,13 +27,6 @@ const sampleBeef1 = new Transaction( 0 ).toBEEF() -const sampleBeef2 = new Transaction( - 1, - [], - [{ lockingScript: LockingScript.fromHex('88'), satoshis: 2 }], - 0 -).toBEEF() - // -------------------------------------------------------------------------- // Helper: build a SLAP token transaction pointing at a given host/service // -------------------------------------------------------------------------- @@ -113,7 +107,7 @@ describe('LookupResolver – additional coverage', () => { expect(mockFacilitator.lookup.mock.calls[0][0]).toBe('http://localhost:8080') }) - it('includes "testnet" in error message for testnet preset', async () => { + it('reports explicit unavailability for testnet discovery failure', async () => { mockFacilitator.lookup.mockResolvedValueOnce({ type: 'output-list', outputs: [] @@ -121,7 +115,7 @@ describe('LookupResolver – additional coverage', () => { const r = new LookupResolver({ facilitator: mockFacilitator, networkPreset: 'testnet' }) await expect(r.query({ service: 'ls_missing', query: {} })).rejects.toThrow( - 'No competent testnet hosts found' + 'Overlay lookup temporarily unavailable or incomplete' ) }) @@ -198,9 +192,8 @@ describe('LookupResolver – additional coverage', () => { }) await r.query({ service: 'ls_test', query: {} }) - ;((r as any).hostReputation as HostReputationTracker).flush() - // Reputation data should have been written to the store - expect(store.size).toBeGreaterThan(0) + // Unsafe legacy get/set persistence is not used for v4 concurrent updates. + expect(store.size).toBe(0) }) }) @@ -208,136 +201,86 @@ describe('LookupResolver – additional coverage', () => { // Cache tuning options // ----------------------------------------------------------------------- - describe('cache configuration', () => { - it('respects custom hostsTtlMs', () => { - const r = new LookupResolver({ - facilitator: mockFacilitator, - cache: { hostsTtlMs: 999 } - }) - expect((r as any).hostsTtlMs).toBe(999) - }) - - it('respects custom hostsMaxEntries', () => { - const r = new LookupResolver({ - facilitator: mockFacilitator, - cache: { hostsMaxEntries: 5 } - }) - expect((r as any).hostsMaxEntries).toBe(5) - }) - + describe('fresh discovery and compatibility cache options', () => { + it.each([{ hostsTtlMs: 999 }, { hostsMaxEntries: 5 }])( + 'keeps legacy cache options source-compatible but discovers afresh: %j', + async cache => { + const ad = await makeSlapTx(42, 'https://fresh.host', 'ls_cache') + mockFacilitator.lookup.mockImplementation(async (_host, question) => ({ + type: 'output-list', + outputs: question.service === 'ls_slap' ? [{ beef: ad.toBEEF(), outputIndex: 0 }] : [] + })) + const r = new LookupResolver({ + facilitator: mockFacilitator, + slapTrackers: ['https://tracker.host'], + cache + }) + await r.query({ service: 'ls_cache', query: {} }) + await r.query({ service: 'ls_cache', query: {} }) + expect( + mockFacilitator.lookup.mock.calls.filter(c => c[1].service === 'ls_slap') + ).toHaveLength(2) + } + ) it('respects custom txMemoTtlMs', () => { - const r = new LookupResolver({ - facilitator: mockFacilitator, - cache: { txMemoTtlMs: 123 } - }) + const r = new LookupResolver({ facilitator: mockFacilitator, cache: { txMemoTtlMs: 123 } }) expect((r as any).txMemoTtlMs).toBe(123) }) - - it('uses stale hosts from cache while refreshing in the background', async () => { - const slapTx = await makeSlapTx(42, 'https://cached.host', 'ls_cached') - - // First call: populates the cache - mockFacilitator.lookup - .mockResolvedValueOnce({ - type: 'output-list', - outputs: [{ outputIndex: 0, beef: slapTx.toBEEF() }] - }) - .mockResolvedValueOnce({ - type: 'output-list', - outputs: [{ beef: sampleBeef1, outputIndex: 0 }] - }) - + it('replaces a retired advertisement on the next lookup', async () => { + const old = await makeSlapTx(42, 'https://old.host', 'ls_cache') + const fresh = await makeSlapTx(43, 'https://fresh.host', 'ls_cache') + let advertisement = old + mockFacilitator.lookup.mockImplementation(async (host, question) => { + if (question.service === 'ls_slap') + return { + type: 'output-list', + outputs: [{ beef: advertisement.toBEEF(), outputIndex: 0 }] + } + if (host === 'https://old.host') throw new Error('retired') + return { type: 'output-list', outputs: [{ beef: sampleBeef1, outputIndex: 0 }] } + }) const r = new LookupResolver({ facilitator: mockFacilitator, - slapTrackers: ['https://mock.slap'], - cache: { hostsTtlMs: 0 } // immediate expiry to force stale path + slapTrackers: ['https://tracker.host'] }) - - await r.query({ service: 'ls_cached', query: {} }) - - // Second call: cache entry is now stale (ttl=0), should use stale hosts - // while kicking off a background refresh - mockFacilitator.lookup.mockResolvedValue({ - type: 'output-list', - outputs: [{ beef: sampleBeef2, outputIndex: 1 }] - }) - - const res2 = await r.query({ service: 'ls_cached', query: {} }) - - expect(res2.type).toBe('output-list') + expect((await r.queryDetailed({ service: 'ls_cache', query: {} })).progress.status).toBe( + 'unavailable' + ) + advertisement = fresh + expect((await r.query({ service: 'ls_cache', query: {} })).outputs).toHaveLength(1) }) - - it('evicts oldest cache entry when hostsMaxEntries is reached', async () => { + it('bounds candidate fanout and reports truncation', async () => { + const hosts = Array.from({ length: 40 }, (_, i) => `https://host-${i}.example`) + mockFacilitator.lookup.mockResolvedValue({ type: 'output-list', outputs: [] }) const r = new LookupResolver({ facilitator: mockFacilitator, - slapTrackers: ['https://mock.slap'], - cache: { hostsMaxEntries: 2 } + hostOverrides: { ls_cap: hosts } }) - - const hostsCache: Map = (r as any).hostsCache - - // Manually populate the cache to its limit - hostsCache.set('ls_service1', { hosts: ['https://h1.com'], expiresAt: Date.now() + 60000 }) - hostsCache.set('ls_service2', { hosts: ['https://h2.com'], expiresAt: Date.now() + 60000 }) - - expect(hostsCache.size).toBe(2) - - // Force a refresh for a third service which should evict ls_service1 - mockFacilitator.lookup.mockResolvedValueOnce({ - type: 'output-list', - outputs: [] - }) - - // Trigger cache refresh via refreshHosts indirectly - const slapTx = await makeSlapTx(42, 'https://h3.com', 'ls_service3') - mockFacilitator.lookup.mockResolvedValue({ - type: 'output-list', - outputs: [{ outputIndex: 0, beef: slapTx.toBEEF() }] - }) - - try { - await r.query({ service: 'ls_service3', query: {} }) - } catch { - // might fail if no competent hosts for the actual lookup - } - - // Cache size should not exceed hostsMaxEntries + 1 (the new entry) - expect(hostsCache.size).toBeLessThanOrEqual(3) + const result = await r.queryDetailed({ service: 'ls_cap', query: {} }) + expect(mockFacilitator.lookup).toHaveBeenCalledTimes(32) + expect(result.progress).toMatchObject({ discoveryComplete: false, status: 'incomplete' }) }) - - it('coalesces concurrent in-flight host resolution requests for the same service', async () => { - const slapTx = await makeSlapTx(42, 'https://coalesce.host', 'ls_coalesce') - - let resolveSlap: (v: any) => void - const slapPromise = new Promise(res => { - resolveSlap = res - }) - - mockFacilitator.lookup - .mockReturnValueOnce(slapPromise) // slap tracker – delayed - .mockResolvedValue({ - type: 'output-list', - outputs: [{ beef: sampleBeef1, outputIndex: 0 }] - }) - + it('isolates concurrent discovery operations', async () => { + const ad = await makeSlapTx(42, 'https://fresh.host', 'ls_cache') + mockFacilitator.lookup.mockImplementation(async (_host, question) => ({ + type: 'output-list', + outputs: question.service === 'ls_slap' ? [{ beef: ad.toBEEF(), outputIndex: 0 }] : [] + })) const r = new LookupResolver({ facilitator: mockFacilitator, - slapTrackers: ['https://mock.slap'] - }) - - // Fire two concurrent queries before slap resolves - const p1 = r.query({ service: 'ls_coalesce', query: {} }) - const p2 = r.query({ service: 'ls_coalesce', query: {} }) - - // Resolve the SLAP tracker - resolveSlap!({ - type: 'output-list', - outputs: [{ outputIndex: 0, beef: slapTx.toBEEF() }] + slapTrackers: ['https://tracker.host'] }) - - const [res1, res2] = await Promise.all([p1, p2]) - expect(res1.type).toBe('output-list') - expect(res2.type).toBe('output-list') + const results = await Promise.all([ + r.query({ service: 'ls_cache', query: {} }), + r.query({ service: 'ls_cache', query: {} }) + ]) + expect(results).toEqual([ + { type: 'output-list', outputs: [] }, + { type: 'output-list', outputs: [] } + ]) + expect( + mockFacilitator.lookup.mock.calls.filter(c => c[1].service === 'ls_slap') + ).toHaveLength(2) }) }) @@ -379,8 +322,8 @@ describe('LookupResolver – additional coverage', () => { // prepareHostsForQuery – all-backoff error // ----------------------------------------------------------------------- - describe('prepareHostsForQuery – backoff error', () => { - it('throws when all competent hosts are in backoff and no alternatives exist', async () => { + describe('advisory cooldown probes', () => { + it('probes recovered competent hosts even in cooldown', async () => { const slapTx = await makeSlapTx(42, 'https://backing.off', 'ls_backoff_test') // SLAP keeps returning the same backed-off host on every call — including @@ -390,7 +333,7 @@ describe('LookupResolver – additional coverage', () => { if (q.service === 'ls_slap') { return { type: 'output-list', outputs: [{ outputIndex: 0, beef: slapTx.toBEEF() }] } } - // Host queries — shouldn't be reached because backoff filter blocks them. + // The recovered host must be contacted despite its recorded cooldown. return { type: 'output-list', outputs: [] } }) @@ -400,33 +343,35 @@ describe('LookupResolver – additional coverage', () => { }) // Poison the reputation of the host so it enters backoff - const tracker: HostReputationTracker = (r as any).hostReputation + const tracker: ReliableHostReputation = (r as any).hostReputation for (let i = 0; i < 5; i++) { - tracker.recordFailure('https://backing.off', 'connection refused') + await tracker.record('mainnet', 'ls_backoff_test', 'https://backing.off', 'transport') } - // Self-healing re-discovers via SLAP, sees the SAME backed-off host, and - // rethrows the backoff error. - await expect(r.query({ service: 'ls_backoff_test', query: {} })).rejects.toThrow( - /All lookup service ls_backoff_test hosts are backing off/ - ) + // The same host recovers without clearing reputation. + await expect(r.query({ service: 'ls_backoff_test', query: {} })).resolves.toEqual({ + type: 'output-list', + outputs: [] + }) + expect(mockFacilitator.lookup.mock.calls.map(c => c[0])).toContain('https://backing.off') }) - it('throws when all SLAP trackers are in backoff', async () => { + it('probes SLAP trackers even in cooldown', async () => { const r = new LookupResolver({ facilitator: mockFacilitator, slapTrackers: ['https://backed.off.slap'] }) // Put the SLAP tracker into deep backoff - const tracker: HostReputationTracker = (r as any).hostReputation + const tracker: ReliableHostReputation = (r as any).hostReputation for (let i = 0; i < 5; i++) { - tracker.recordFailure('https://backed.off.slap', 'connection refused') + await tracker.record('mainnet', 'ls_slap', 'https://backed.off.slap', 'transport') } await expect(r.query({ service: 'ls_any', query: {} })).rejects.toThrow( - 'All SLAP trackers hosts are backing off' + 'Overlay lookup temporarily unavailable or incomplete' ) + expect(mockFacilitator.lookup).toHaveBeenCalledTimes(1) }) }) @@ -727,17 +672,16 @@ describe('LookupResolver – additional coverage', () => { // Multiple repeated queries shouldn't push the host into backoff. for (let i = 0; i < 6; i++) { - const res = await r.query({ service: 'ls_invalid', query: { i } }) - expect(res.outputs).toHaveLength(0) + const res = await r.queryDetailed({ service: 'ls_invalid', query: { i } }) + expect(res.progress).toMatchObject({ + status: 'unavailable', + freeformHosts: 1, + failedHosts: 0 + }) } - const tracker: HostReputationTracker = (r as any).hostReputation - const snap = tracker.snapshot('https://weird.host') - expect(snap?.totalFailures).toBe(0) - // Freeform is neutral: it must neither penalize the host nor clear a - // concurrent availability backoff by recording a success. - expect(snap?.totalSuccesses).toBe(0) - expect(snap?.backoffUntil).toBe(0) + const tracker: ReliableHostReputation = (r as any).hostReputation + expect(tracker.snapshot('mainnet', 'ls_invalid', 'https://weird.host')).toBeUndefined() }) it('records failure for a structurally MALFORMED response (no type field)', async () => { @@ -748,12 +692,13 @@ describe('LookupResolver – additional coverage', () => { hostOverrides: { ls_bad: ['https://malformed.host'] } }) - const res = await r.query({ service: 'ls_bad', query: {} }) - expect(res.outputs).toHaveLength(0) + const res = await r.queryDetailed({ service: 'ls_bad', query: {} }) + expect(res.progress).toMatchObject({ status: 'unavailable', failedHosts: 1 }) - const tracker: HostReputationTracker = (r as any).hostReputation - const snap = tracker.snapshot('https://malformed.host') - expect(snap?.totalFailures).toBeGreaterThan(0) + const tracker: ReliableHostReputation = (r as any).hostReputation + const snap = tracker.snapshot('mainnet', 'ls_bad', 'https://malformed.host') + expect(snap?.reason).toBe('malformed') + expect(snap?.penalty).toBe(8) }) }) @@ -770,7 +715,7 @@ describe('LookupResolver – additional coverage', () => { }) await expect(r.query({ service: 'ls_foo', query: {} })).rejects.toThrow( - 'No competent mainnet hosts found' + 'Overlay lookup temporarily unavailable or incomplete' ) }) }) diff --git a/packages/sdk/src/overlay-tools/__tests/LookupResolver.poison-reproduction.test.ts b/packages/sdk/src/overlay-tools/__tests/LookupResolver.poison-reproduction.test.ts index 34d8f4f92..b096a8466 100644 --- a/packages/sdk/src/overlay-tools/__tests/LookupResolver.poison-reproduction.test.ts +++ b/packages/sdk/src/overlay-tools/__tests/LookupResolver.poison-reproduction.test.ts @@ -3,8 +3,8 @@ import { HostReputationTracker } from '../HostReputationTracker' import { Transaction } from '../../transaction/index' import { LockingScript } from '../../script/index' -// Synthetic local reproduction retained as evidence for the legacy API. -describe('legacy poisoned reputation reproduction', () => { +// The original failing reproduction is retained in commit ff36b55; these now assert the shared fix. +describe('shared poisoned reputation regression', () => { const host = 'https://recovered.example' const empty = 'https://empty.example' const output = { @@ -24,7 +24,7 @@ describe('legacy poisoned reputation reproduction', () => { }), set: () => {} }) - it('never contacts a reachable host with a future persisted cooldown', async () => { + it('contacts a reachable host despite a future legacy persisted cooldown', async () => { const lookup = jest.fn(async () => ({ type: 'output-list' as const, outputs: [output] })) const resolver = new LookupResolver({ facilitator: { lookup }, @@ -34,12 +34,10 @@ describe('legacy poisoned reputation reproduction', () => { set: () => {} } }) - await expect(resolver.query({ service: 'ls_kvstore', query: {} })).rejects.toThrow( - 'backing off' - ) - expect(lookup).not.toHaveBeenCalled() + expect((await resolver.query({ service: 'ls_kvstore', query: {} })).outputs).toHaveLength(1) + expect(lookup).toHaveBeenCalledTimes(1) }) - it('returns empty while the reachable data host is excluded; reset immediately restores data', async () => { + it('returns data even when the fastest host is empty and a reachable host has legacy poison', async () => { const lookup = jest.fn(async (url: string) => ({ type: 'output-list' as const, outputs: url === host ? [output] : [] @@ -53,14 +51,13 @@ describe('legacy poisoned reputation reproduction', () => { set: () => {} } }) - expect((await resolver.query({ service: 'ls_kvstore', query: {} })).outputs).toHaveLength(0) - expect(lookup.mock.calls.map(call => call[0])).toEqual([empty]) - ;(resolver as any).hostReputation.reset() + expect((await resolver.query({ service: 'ls_kvstore', query: {} })).outputs).toHaveLength(1) + expect(lookup.mock.calls.map(call => call[0])).toEqual([empty, host]) expect((await resolver.query({ service: 'ls_kvstore', query: {} })).outputs).toHaveLength(1) }) }) -it('a normal persisted penalty becomes year-long exclusion after the browser clock is corrected', async () => { +it('ignores a legacy year-long cooldown caused by browser clock correction', async () => { jest.useFakeTimers() const data = new Map() const storage = { @@ -82,10 +79,11 @@ it('a normal persisted penalty becomes year-long exclusion after the browser clo reputationStorage: storage, hostOverrides: { ls_kvstore: [host] } }) - await expect(reloaded.query({ service: 'ls_kvstore', query: {} })).rejects.toThrow( - 'backing off' - ) - expect(lookup).not.toHaveBeenCalled() + await expect(reloaded.query({ service: 'ls_kvstore', query: {} })).resolves.toEqual({ + type: 'output-list', + outputs: [] + }) + expect(lookup).toHaveBeenCalledTimes(1) expect( new HostReputationTracker(storage).snapshot(host)!.backoffUntil - Date.now() ).toBeGreaterThan(300 * 86400000) diff --git a/packages/sdk/src/overlay-tools/__tests/LookupResolver.resiliency.test.ts b/packages/sdk/src/overlay-tools/__tests/LookupResolver.resiliency.test.ts index 38cffb49b..fb13ab3e6 100644 --- a/packages/sdk/src/overlay-tools/__tests/LookupResolver.resiliency.test.ts +++ b/packages/sdk/src/overlay-tools/__tests/LookupResolver.resiliency.test.ts @@ -1,3 +1,4 @@ +import { ReliableHostReputation } from '../ReliableHostReputation' import LookupResolver, { UnreachableHostInfo, LookupAnswerProgress, @@ -8,6 +9,9 @@ import { getOverlayHostReputationTracker } from '../HostReputationTracker' import { Transaction } from '../../transaction/index' import { LockingScript } from '../../script/index' +const reputationOf = (resolver: LookupResolver): ReliableHostReputation => + (resolver as unknown as { hostReputation: ReliableHostReputation }).hostReputation + // -------------------------------------------------------------------------- // Test fixtures: distinct BEEFs representing distinct outputs // -------------------------------------------------------------------------- @@ -97,10 +101,12 @@ describe('LookupResolver resilience', () => { expect(res.outputs).toHaveLength(5) } - const slowSnap = getOverlayHostReputationTracker().snapshot(slowCompleteHost) - const fastSnap = getOverlayHostReputationTracker().snapshot(fastIncompleteHost) - expect(slowSnap?.totalSuccesses).toBe(20) - expect(fastSnap?.totalSuccesses).toBe(20) + const slowSnap = reputationOf(resolver).snapshot('mainnet', 'ls_topic', slowCompleteHost) + const fastSnap = reputationOf(resolver).snapshot('mainnet', 'ls_topic', fastIncompleteHost) + expect(slowSnap?.penalty).toBe(0) + expect(lookup.mock.calls.filter(c => c[0] === slowCompleteHost)).toHaveLength(20) + expect(fastSnap?.penalty).toBe(0) + expect(lookup.mock.calls.filter(c => c[0] === fastIncompleteHost)).toHaveLength(20) expect(slowSnap).not.toHaveProperty('avgCompleteness') expect(fastSnap).not.toHaveProperty('avgCompleteness') }) @@ -248,7 +254,7 @@ describe('LookupResolver resilience', () => { // ----------------------------------------------------------------------- // Self-healing: warm cache + all hosts in backoff → re-discover via SLAP. // ----------------------------------------------------------------------- - it('self-heals when warm-cache hosts have all slid into backoff', async () => { + it('discovers healthy replacements despite previous host penalties', async () => { const { PrivateKey } = await import('../../primitives/index') const { CompletedProtoWallet } = await import('../../auth/certificates/__tests/CompletedProtoWallet') @@ -282,16 +288,10 @@ describe('LookupResolver resilience', () => { slapTrackers: [slapTrackerUrl] }) - // Prime warm cache with the old dead host - ;(resolver as any).hostsCache.set('ls_topic', { - hosts: [oldDeadHost], - expiresAt: Date.now() + 5 * 60 * 1000 - }) - // Push the dead host deep into backoff - const tracker = getOverlayHostReputationTracker() + const tracker = reputationOf(resolver) for (let i = 0; i < 5; i++) { - tracker.recordFailure(oldDeadHost, 'connection refused') + await tracker.record('mainnet', 'ls_topic', oldDeadHost, 'transport') } const queryPromise = resolver.query({ service: 'ls_topic', query: {} }) @@ -328,15 +328,17 @@ describe('LookupResolver resilience', () => { }) for (let i = 0; i < 6; i++) { - const queryPromise = resolver.query({ service: 'ls_kvstore', query: { i } }) + const queryPromise = resolver.queryDetailed({ service: 'ls_kvstore', query: { i } }) await jest.advanceTimersByTimeAsync(50) const res = await queryPromise - expect(res.outputs).toHaveLength(0) + expect(res.progress).toMatchObject({ + status: 'unavailable', + rejectedHosts: 1, + failedHosts: 0 + }) } - - const snap = getOverlayHostReputationTracker().snapshot(partialHost) - expect(snap?.totalFailures ?? 0).toBe(0) - expect(snap?.backoffUntil ?? 0).toBe(0) + expect(reputationOf(resolver).snapshot('mainnet', 'ls_kvstore', partialHost)).toBeUndefined() + expect(fakeFetch).toHaveBeenCalledTimes(6) }) }) @@ -394,15 +396,17 @@ describe('LookupResolver adversarial review regressions', () => { hostOverrides: { ls_malformed: [host] } }) - const query = resolver.query({ service: 'ls_malformed', query: {} }, undefined, { + const query = resolver.queryDetailed({ service: 'ls_malformed', query: {} }, undefined, { onUnreachableHost: callback }) await jest.advanceTimersByTimeAsync(100) - await expect(query).resolves.toMatchObject({ outputs: [] }) + await expect(query).resolves.toMatchObject({ + progress: { status: 'unavailable', failedHosts: 1 } + }) - const snap = getOverlayHostReputationTracker().snapshot(host) - expect(snap?.totalSuccesses).toBe(0) - expect(snap?.totalFailures).toBe(1) + const snap = reputationOf(resolver).snapshot('mainnet', 'ls_malformed', host) + expect(snap?.reason).toBe('malformed') + expect(snap?.penalty).toBe(8) expect(callback).toHaveBeenCalledWith( expect.objectContaining({ host, @@ -423,20 +427,22 @@ describe('LookupResolver adversarial review regressions', () => { hostOverrides: { ls_freeform: [host] } }) - const query = resolver.query({ service: 'ls_freeform', query: {} }) + const query = resolver.queryDetailed({ service: 'ls_freeform', query: {} }) await jest.advanceTimersByTimeAsync(10) - const tracker = getOverlayHostReputationTracker() - tracker.recordFailure(host, 'network down') - tracker.recordFailure(host, 'network down') - tracker.recordFailure(host, 'network down') - const before = tracker.snapshot(host) + const tracker = reputationOf(resolver) + await tracker.record('mainnet', 'ls_freeform', host, 'transport') + await tracker.record('mainnet', 'ls_freeform', host, 'transport') + await tracker.record('mainnet', 'ls_freeform', host, 'transport') + const before = tracker.snapshot('mainnet', 'ls_freeform', host) expect(before).toBeDefined() await jest.advanceTimersByTimeAsync(200) - await expect(query).resolves.toMatchObject({ outputs: [] }) - const after = tracker.snapshot(host) - expect(after?.consecutiveFailures).toBe(before?.consecutiveFailures) - expect(after?.backoffUntil).toBe(before?.backoffUntil) + await expect(query).resolves.toMatchObject({ + progress: { status: 'unavailable', freeformHosts: 1, failedHosts: 0 } + }) + const after = tracker.snapshot('mainnet', 'ls_freeform', host) + expect(after?.penalty).toBe(before?.penalty) + expect(after?.cooldownUntil).toBe(before?.cooldownUntil) }) it('isolates rejected async callbacks and deduplicates notification storms', async () => { @@ -450,11 +456,13 @@ describe('LookupResolver adversarial review regressions', () => { }) for (let i = 0; i < 2; i++) { - const query = resolver.query({ service: 'ls_notify', query: { i } }, undefined, { + const query = resolver.queryDetailed({ service: 'ls_notify', query: { i } }, undefined, { onUnreachableHost: callback }) await jest.advanceTimersByTimeAsync(100) - await expect(query).resolves.toMatchObject({ outputs: [] }) + await expect(query).resolves.toMatchObject({ + progress: { status: 'unavailable', failedHosts: 1 } + }) } await Promise.resolve() expect(callback).toHaveBeenCalledTimes(1) @@ -496,12 +504,9 @@ describe('LookupResolver adversarial review regressions', () => { facilitator: { lookup }, slapTrackers: [fastTracker, slowTracker] }) - ;(resolver as any).hostsCache.set('ls_recovery', { - hosts: [backedOffHost], - expiresAt: Date.now() + 60_000 - }) - const tracker = getOverlayHostReputationTracker() - for (let i = 0; i < 5; i++) tracker.recordFailure(backedOffHost, 'host down') + const tracker = reputationOf(resolver) + for (let i = 0; i < 5; i++) + await tracker.record('mainnet', 'ls_recovery', backedOffHost, 'transport') const query = resolver.query({ service: 'ls_recovery', query: {} }) await jest.advanceTimersByTimeAsync(1000) @@ -558,22 +563,31 @@ describe('LookupResolver adversarial review regressions', () => { hostOverrides: { ls_http_availability: [unavailableHost] } }) - const semanticQuery = semanticResolver.query( + const semanticQuery = semanticResolver.queryDetailed( { service: 'ls_http_semantic', query: {} }, undefined, { onUnreachableHost: callback } ) - const unavailableQuery = unavailableResolver.query( + const unavailableQuery = unavailableResolver.queryDetailed( { service: 'ls_http_availability', query: {} }, undefined, { onUnreachableHost: callback } ) await jest.advanceTimersByTimeAsync(100) - await expect(semanticQuery).resolves.toMatchObject({ outputs: [] }) - await expect(unavailableQuery).resolves.toMatchObject({ outputs: [] }) + await expect(semanticQuery).resolves.toMatchObject({ + progress: { rejectedHosts: 1, failedHosts: 0, status: 'unavailable' } + }) + await expect(unavailableQuery).resolves.toMatchObject({ + progress: { rejectedHosts: 0, failedHosts: 1, status: 'unavailable' } + }) - expect(getOverlayHostReputationTracker().snapshot(semanticHost)?.totalFailures ?? 0).toBe(0) - expect(getOverlayHostReputationTracker().snapshot(unavailableHost)?.totalFailures).toBe(1) + expect( + reputationOf(semanticResolver).snapshot('mainnet', 'ls_http_semantic', semanticHost) + ).toBeUndefined() + expect( + reputationOf(unavailableResolver).snapshot('mainnet', 'ls_http_availability', unavailableHost) + ?.reason + ).toBe('transport') expect(callback).toHaveBeenCalledTimes(1) expect(callback).toHaveBeenCalledWith(expect.objectContaining({ host: unavailableHost })) }) diff --git a/packages/sdk/src/overlay-tools/__tests/LookupResolver.shared-reliability.test.ts b/packages/sdk/src/overlay-tools/__tests/LookupResolver.shared-reliability.test.ts new file mode 100644 index 000000000..fcc4d78fb --- /dev/null +++ b/packages/sdk/src/overlay-tools/__tests/LookupResolver.shared-reliability.test.ts @@ -0,0 +1,282 @@ +import { LookupResolver, LookupUnavailableError, LookupValidationError } from '../../../mod' +import { Transaction } from '../../transaction/index' +import { LockingScript } from '../../script/index' +import type { ReliableReputationStorage } from '../ReliableHostReputation' +import OverlayAdminTokenTemplate from '../OverlayAdminTokenTemplate' +import { CompletedProtoWallet } from '../../auth/certificates/__tests/CompletedProtoWallet' +import { PrivateKey } from '../../primitives/index' + +const good = 'https://recovered.example' +const down = 'https://down.example' +const empty = { type: 'output-list' as const, outputs: [] } +const output = { + beef: new Transaction( + 1, + [], + [{ lockingScript: LockingScript.fromHex('51'), satoshis: 1 }], + 0 + ).toBEEF(), + outputIndex: 0 +} +const answer = { ...empty, outputs: [output] } + +function storageFixture(): { storage: ReliableReputationStorage; data: Map } { + const data = new Map() + return { + data, + storage: { + get: key => data.get(key), + set: (key, value) => { + data.set(key, value) + }, + lock: async (_name, action) => await action() + } + } +} + +describe('standard package resolver reliability for every overlay service', () => { + beforeEach(() => jest.useFakeTimers()) + afterEach(() => jest.useRealTimers()) + + it.each(['ls_identity', 'ls_ship', 'ls_custom', 'ls_kvstore'])( + 'probes a cooled host and merges past a fast empty response for %s', + async service => { + const { storage, data } = storageFixture() + const scope = JSON.stringify(['mainnet', service, good]) + data.set( + 'bsvsdk_overlay_host_reputation_v4', + JSON.stringify({ + version: 4, + entries: { + [scope]: { updatedAt: Date.now(), cooldownUntil: Date.now() + 30000, penalty: 64 } + } + }) + ) + const lookup = jest.fn(async host => { + if (host !== good) return empty + await new Promise(resolve => setTimeout(resolve, 250)) + return answer + }) + const resolver = new LookupResolver({ + facilitator: { lookup }, + reliableReputationStorage: storage, + hostOverrides: { [service]: [down, good] } + }) + const pending = resolver.query({ service, query: {} }) + await jest.advanceTimersByTimeAsync(250) + expect((await pending).outputs).toEqual([output]) + expect(lookup).toHaveBeenCalledTimes(2) + const healed = JSON.parse(data.get('bsvsdk_overlay_host_reputation_v4')!).entries[scope] + expect(healed).toMatchObject({ cooldownUntil: 0, penalty: 0 }) + } + ) + + it.each(['query', 'queryDetailed', 'query$'] as const)( + '%s bounds hung hosts for a non-KV service', + async api => { + const service = 'ls_custom' + const signals: AbortSignal[] = [] + const lookup = jest.fn(async (host, _q, _ms, signal: AbortSignal) => { + signals.push(signal) + return host === good ? answer : await new Promise(() => {}) + }) + const resolver = new LookupResolver({ + facilitator: { lookup }, + hostOverrides: { [service]: [good, down] } + }) + const start = performance.now() + const pending = + api === 'query$' + ? (async () => { + const emissions = [] + for await (const result of resolver.query$({ service, query: {} })) + emissions.push(result) + return emissions.at(-1)! + })() + : resolver[api]({ service, query: {} }) + await jest.advanceTimersByTimeAsync(2000) + const result = await pending + expect(performance.now() - start).toBe(2000) + expect('answer' in result ? result.answer.outputs : result.outputs).toEqual([output]) + if ('progress' in result) + expect(result.progress).toMatchObject({ + status: 'incomplete', + failedHosts: 1, + successfulHosts: 1 + }) + if ('isFinal' in result) + expect(result).toMatchObject({ status: 'incomplete', isFinal: true, failedHosts: 1 }) + expect(signals.every(signal => signal.aborted)).toBe(true) + expect(jest.getTimerCount()).toBe(0) + } + ) + + it('includes discovery in the total deadline even with a non-cooperative host', async () => { + const service = 'ls_custom' + const wallet = new CompletedProtoWallet(new PrivateKey(42)) + const lockingScript = await new OverlayAdminTokenTemplate(wallet).lock('SLAP', down, service) + const advertisement = { + ...empty, + outputs: [ + { + beef: new Transaction(1, [], [{ lockingScript, satoshis: 1 }], 0).toBEEF(), + outputIndex: 0 + } + ] + } + const lookup = jest.fn(async (_host, q) => { + if (q.service === 'ls_slap') { + await new Promise(resolve => setTimeout(resolve, 1400)) + return advertisement + } + return await new Promise(() => {}) + }) + const resolver = new LookupResolver({ + facilitator: { lookup }, + slapTrackers: ['https://tracker.example'] + }) + const pending = resolver.queryDetailed({ service, query: {} }, undefined, { deadlineMs: 1600 }) + await jest.advanceTimersByTimeAsync(1599) + let done = false + void pending.then(() => { + done = true + }) + await Promise.resolve() + expect(done).toBe(false) + await jest.advanceTimersByTimeAsync(1) + expect((await pending).progress).toMatchObject({ + status: 'unavailable', + completedHosts: 1, + failedHosts: 1 + }) + expect(jest.getTimerCount()).toBe(0) + }) + + it('throws retryable unavailability instead of returning a failed empty aggregate', async () => { + const resolver = new LookupResolver({ + facilitator: { + lookup: async () => { + throw new Error('offline') + } + }, + hostOverrides: { ls_identity: [down] } + }) + await expect(resolver.query({ service: 'ls_identity', query: {} })).rejects.toBeInstanceOf( + LookupUnavailableError + ) + await expect(resolver.query({ service: 'ls_identity', query: {} })).rejects.toMatchObject({ + retryable: true, + progress: { status: 'unavailable', failedHosts: 1 } + }) + }) + + it('aborts outstanding requests when a progressive consumer closes the iterator', async () => { + const signals: AbortSignal[] = [] + const resolver = new LookupResolver({ + facilitator: { + lookup: async (host, _q, _ms, signal) => { + signals.push(signal!) + return host === good ? answer : await new Promise(() => {}) + } + }, + hostOverrides: { ls_identity: [good, down] } + }) + const pending = (async () => { + for await (const result of resolver.query$({ service: 'ls_identity', query: {} })) { + expect(result).toMatchObject({ isFinal: false, status: 'incomplete' }) + break + } + })() + await jest.advanceTimersByTimeAsync(80) + await pending + expect(signals).toHaveLength(2) + expect(signals.every(signal => signal.aborted)).toBe(true) + expect(jest.getTimerCount()).toBe(0) + }) + + it('keeps soft snapshots non-final and continues with later data', async () => { + const resolver = new LookupResolver({ + facilitator: { + lookup: async () => { + await new Promise(resolve => setTimeout(resolve, 400)) + return answer + } + }, + hostOverrides: { ls_identity: [good] } + }) + const snapshots: Array<{ isFinal: boolean; outputs: unknown[]; status?: string }> = [] + const pending = (async () => { + for await (const result of resolver.query$({ service: 'ls_identity', query: {} }, undefined, { + softTimeoutMs: 50 + })) + snapshots.push(result) + })() + await jest.advanceTimersByTimeAsync(500) + await pending + expect(snapshots[0]).toMatchObject({ isFinal: false, outputs: [], status: 'unavailable' }) + expect(snapshots.at(-1)).toMatchObject({ isFinal: true, outputs: [output], status: 'complete' }) + }) + + it('exposes service-specific validation from the standard package export', async () => { + const resolver = new LookupResolver({ + facilitator: { lookup: async host => (host === good ? answer : empty) }, + hostOverrides: { ls_custom: [good, down] } + }) + const result = await resolver.queryReliable( + { service: 'ls_custom', query: {} }, + { + validate: async response => { + if (response.outputs.length === 0) throw new LookupValidationError('invalid') + return ['custom verified value'] + } + } + ) + expect(result.hosts).toContainEqual({ + host: good, + kind: 'answer', + values: ['custom verified value'] + }) + expect(result.hosts).toContainEqual({ host: down, kind: 'invalid' }) + }) + it.each([ + { ...output, beef: [1, 2, 3] }, + { ...output, txid: 'ff'.repeat(32) } + ])('does not turn an unparseable BEEF or forged txid hint into empty success', async invalid => { + const resolver = new LookupResolver({ + facilitator: { lookup: async () => ({ ...empty, outputs: [invalid] }) }, + hostOverrides: { ls_custom: [good] } + }) + const result = await resolver.queryDetailed({ service: 'ls_custom', query: {} }) + expect(result.progress).toMatchObject({ + status: 'unavailable', + successfulHosts: 0, + failedHosts: 1 + }) + }) + + it('does not carry one service failure into another service or network', async () => { + const { storage, data } = storageFixture() + const lookup = jest.fn(async (_host, question) => { + if (question.service === 'ls_failed') throw new Error('offline') + return answer + }) + const resolver = new LookupResolver({ + facilitator: { lookup }, + reliableReputationStorage: storage, + hostOverrides: { ls_failed: [good], ls_healthy: [good] } + }) + await resolver.queryDetailed({ service: 'ls_failed', query: {} }) + await resolver.query({ service: 'ls_healthy', query: {} }) + const testnet = new LookupResolver({ + networkPreset: 'testnet', + reliableReputationStorage: storage, + facilitator: { lookup: async () => answer }, + hostOverrides: { ls_failed: [good] } + }) + await testnet.query({ service: 'ls_failed', query: {} }) + const entries = JSON.parse(data.get('bsvsdk_overlay_host_reputation_v4')!).entries + expect(entries[JSON.stringify(['mainnet', 'ls_failed', good])].penalty).toBe(2) + expect(entries[JSON.stringify(['mainnet', 'ls_healthy', good])].penalty).toBe(0) + expect(entries[JSON.stringify(['testnet', 'ls_failed', good])].penalty).toBe(0) + }) +}) diff --git a/packages/sdk/src/overlay-tools/__tests/LookupResolver.test.ts b/packages/sdk/src/overlay-tools/__tests/LookupResolver.test.ts index b0072979e..52b9cff64 100644 --- a/packages/sdk/src/overlay-tools/__tests/LookupResolver.test.ts +++ b/packages/sdk/src/overlay-tools/__tests/LookupResolver.test.ts @@ -105,7 +105,8 @@ describe('LookupResolver', () => { service: 'ls_foo' } }, - 5000 + 1500, + expect.any(AbortSignal) ], [ 'https://slaphost.com', @@ -115,7 +116,8 @@ describe('LookupResolver', () => { test: 1 } }, - undefined + 2000, + expect.any(AbortSignal) ] ]) }) @@ -207,7 +209,8 @@ describe('LookupResolver', () => { service: 'ls_foo' } }, - 5000 + 1500, + expect.any(AbortSignal) ], [ 'https://slaphost.com', @@ -217,7 +220,8 @@ describe('LookupResolver', () => { test: 1 } }, - undefined + 2000, + expect.any(AbortSignal) ], [ // additional host should also have been queried @@ -228,7 +232,8 @@ describe('LookupResolver', () => { test: 1 } }, - undefined + 2000, + expect.any(AbortSignal) ] ]) }) @@ -273,7 +278,8 @@ describe('LookupResolver', () => { test: 1 } }, - undefined + 2000, + expect.any(AbortSignal) ] ]) }) @@ -341,7 +347,8 @@ describe('LookupResolver', () => { test: 1 } }, - undefined + 2000, + expect.any(AbortSignal) ], [ // additional host should also have been queried @@ -352,12 +359,13 @@ describe('LookupResolver', () => { test: 1 } }, - undefined + 2000, + expect.any(AbortSignal) ] ]) }) - it('should handle multiple SLAP trackers and resolve with first responder hosts', async () => { + it('should query the union returned by multiple SLAP trackers', async () => { const slapHostKey1 = new PrivateKey(42) const slapWallet1 = new CompletedProtoWallet(slapHostKey1) const slapLib1 = new OverlayAdminTokenTemplate(slapWallet1) @@ -438,7 +446,7 @@ describe('LookupResolver', () => { outputs: [{ beef: sampleBeef3, outputIndex: 0 }] }) - // Both SLAP trackers are queried, but only the first host is used for the actual query + // Both SLAP trackers contribute hosts for the actual query expect(mockFacilitator.lookup.mock.calls.length).toBeGreaterThanOrEqual(3) expect(mockFacilitator.lookup.mock.calls[0]).toEqual([ 'https://mock.slap1', @@ -448,7 +456,8 @@ describe('LookupResolver', () => { service: 'ls_foo' } }, - 5000 + 1500, + expect.any(AbortSignal) ]) expect(mockFacilitator.lookup.mock.calls[1]).toEqual([ 'https://mock.slap2', @@ -458,7 +467,8 @@ describe('LookupResolver', () => { service: 'ls_foo' } }, - 5000 + 1500, + expect.any(AbortSignal) ]) expect(mockFacilitator.lookup.mock.calls[2]).toEqual([ 'https://slaphost1.com', @@ -468,7 +478,8 @@ describe('LookupResolver', () => { test: 1 } }, - undefined + 2000, + expect.any(AbortSignal) ]) }) @@ -550,7 +561,8 @@ describe('LookupResolver', () => { service: 'ls_foo' } }, - 5000 + 1500, + expect.any(AbortSignal) ], [ 'https://slaphost1.com', @@ -560,7 +572,8 @@ describe('LookupResolver', () => { test: 1 } }, - undefined + 2000, + expect.any(AbortSignal) ], [ 'https://slaphost2.com', @@ -570,7 +583,8 @@ describe('LookupResolver', () => { test: 1 } }, - undefined + 2000, + expect.any(AbortSignal) ] ]) }) @@ -653,7 +667,8 @@ describe('LookupResolver', () => { service: 'ls_foo' } }, - 5000 + 1500, + expect.any(AbortSignal) ], [ 'https://slaphost1.com', @@ -663,7 +678,8 @@ describe('LookupResolver', () => { test: 1 } }, - undefined + 2000, + expect.any(AbortSignal) ], [ 'https://slaphost2.com', @@ -673,7 +689,8 @@ describe('LookupResolver', () => { test: 1 } }, - undefined + 2000, + expect.any(AbortSignal) ] ]) }) @@ -695,9 +712,7 @@ describe('LookupResolver', () => { service: 'ls_foo', query: { test: 1 } }) - ).rejects.toThrow( - 'No competent mainnet hosts found by the SLAP trackers for lookup service: ls_foo' - ) + ).rejects.toThrow('Overlay lookup temporarily unavailable or incomplete') expect(mockFacilitator.lookup.mock.calls).toEqual([ [ @@ -708,7 +723,8 @@ describe('LookupResolver', () => { service: 'ls_foo' } }, - 5000 + 1500, + expect.any(AbortSignal) ] ]) }) @@ -795,7 +811,8 @@ describe('LookupResolver', () => { service: 'ls_foo' } }, - 5000 + 1500, + expect.any(AbortSignal) ], [ 'https://slaphost1.com', @@ -805,7 +822,8 @@ describe('LookupResolver', () => { test: 1 } }, - undefined + 2000, + expect.any(AbortSignal) ], [ 'https://slaphost2.com', @@ -815,7 +833,8 @@ describe('LookupResolver', () => { test: 1 } }, - undefined + 2000, + expect.any(AbortSignal) ] ]) }) @@ -857,7 +876,8 @@ describe('LookupResolver', () => { test: 1 } }, - undefined + 2000, + expect.any(AbortSignal) ] ]) }) @@ -880,9 +900,7 @@ describe('LookupResolver', () => { service: 'ls_foo', query: { test: 1 } }) - ).rejects.toThrow( - 'No competent mainnet hosts found by the SLAP trackers for lookup service: ls_foo' - ) + ).rejects.toThrow('Overlay lookup temporarily unavailable or incomplete') expect(mockFacilitator.lookup.mock.calls).toEqual([ [ @@ -893,7 +911,8 @@ describe('LookupResolver', () => { service: 'ls_foo' } }, - 5000 + 1500, + expect.any(AbortSignal) ] ]) }) @@ -906,7 +925,7 @@ describe('LookupResolver', () => { }) describe('Host reputation tracking', () => { - it('shares performance learnings across resolver instances and prefers low latency hosts', async () => { + it('queries every host across resolver instances without inferring authority from latency', async () => { const fastHost = 'https://fast.host' const slowHost = 'https://slow.host' const hosts = [slowHost, fastHost] @@ -981,13 +1000,13 @@ describe('LookupResolver', () => { }) const orderedHosts = mockFacilitator.lookup.mock.calls.map(call => call[0]) - expect(orderedHosts).toEqual([fastHost, slowHost]) + expect(orderedHosts).toEqual([slowHost, fastHost]) } finally { nowSpy.mockRestore() } }) - it('exponentially backs off consistently failing hosts to avoid repeated work', async () => { + it('probes failing hosts on every bounded query so recovery needs no cooldown reset', async () => { const failingHost = 'https://offline.host' const healthyHost = 'https://healthy.host' let fakeNow = 0 @@ -1030,17 +1049,16 @@ describe('LookupResolver', () => { expect(failingCalls).toBe(3) - // Immediately try again; failing host should now be in backoff and skipped + // Advisory cooldown must not skip the next probe fakeNow += 20 await resolver.query({ service: 'ls_backoff', query: { attempt: 4 } }) - expect(failingCalls).toBe(3) - const lastCall = callLog[callLog.length - 1] - expect(lastCall).toBe(healthyHost) + expect(failingCalls).toBe(4) + expect(callLog.slice(-2)).toEqual(expect.arrayContaining([failingHost, healthyHost])) // Advance beyond the backoff window so the failing host is retried fakeNow = 2000 await resolver.query({ service: 'ls_backoff', query: { attempt: 5 } }) - expect(failingCalls).toBe(4) + expect(failingCalls).toBe(5) } finally { nowSpy.mockRestore() } @@ -1124,7 +1142,8 @@ describe('LookupResolver', () => { service: 'ls_foo' } }, - 5000 + 1500, + expect.any(AbortSignal) ], [ 'https://mock.slap2', @@ -1134,7 +1153,8 @@ describe('LookupResolver', () => { service: 'ls_foo' } }, - 5000 + 1500, + expect.any(AbortSignal) ], [ 'https://slaphost.com', @@ -1144,7 +1164,8 @@ describe('LookupResolver', () => { test: 1 } }, - undefined + 2000, + expect.any(AbortSignal) ] ]) }) @@ -1247,7 +1268,8 @@ describe('LookupResolver', () => { service: 'ls_foo' } }, - 5000 + 1500, + expect.any(AbortSignal) ], [ 'https://slaphost1.com', @@ -1257,7 +1279,8 @@ describe('LookupResolver', () => { test: 1 } }, - undefined + 2000, + expect.any(AbortSignal) ], [ 'https://slaphost2.com', @@ -1267,7 +1290,8 @@ describe('LookupResolver', () => { test: 1 } }, - undefined + 2000, + expect.any(AbortSignal) ] ]) }) @@ -1350,7 +1374,8 @@ describe('LookupResolver', () => { service: 'ls_foo' } }, - 5000 + 1500, + expect.any(AbortSignal) ], [ 'https://slaphost1.com', @@ -1360,7 +1385,8 @@ describe('LookupResolver', () => { test: 1 } }, - undefined + 2000, + expect.any(AbortSignal) ], [ 'https://slaphost2.com', @@ -1370,7 +1396,8 @@ describe('LookupResolver', () => { test: 1 } }, - undefined + 2000, + expect.any(AbortSignal) ] ]) }) @@ -1452,7 +1479,8 @@ describe('LookupResolver', () => { service: 'ls_foo' } }, - 5000 + 1500, + expect.any(AbortSignal) ], [ 'https://slaphost1.com', @@ -1462,7 +1490,8 @@ describe('LookupResolver', () => { test: 1 } }, - undefined + 2000, + expect.any(AbortSignal) ], [ 'https://slaphost2.com', @@ -1472,7 +1501,8 @@ describe('LookupResolver', () => { test: 1 } }, - undefined + 2000, + expect.any(AbortSignal) ] ]) }) @@ -1511,13 +1541,14 @@ describe('LookupResolver', () => { slapTrackers: ['https://mock.slap'] }) - const res = await r.query({ + const res = await r.queryDetailed({ service: 'ls_foo', query: { test: 1 } }) - // Since there are no valid outputs, expect an error - expect(res).toEqual({ + expect(res.progress).toMatchObject({ status: 'unavailable', failedHosts: 1 }) + // The detailed API carries failure evidence alongside the empty aggregate. + expect(res.answer).toEqual({ type: 'output-list', outputs: [] }) @@ -1531,7 +1562,8 @@ describe('LookupResolver', () => { service: 'ls_foo' } }, - 5000 + 1500, + expect.any(AbortSignal) ], [ 'https://slaphost.com', @@ -1541,7 +1573,8 @@ describe('LookupResolver', () => { test: 1 } }, - undefined + 2000, + expect.any(AbortSignal) ] ]) }) @@ -1562,9 +1595,7 @@ describe('LookupResolver', () => { service: 'ls_foo', query: { test: 1 } }) - ).rejects.toThrow( - 'No competent mainnet hosts found by the SLAP trackers for lookup service: ls_foo' - ) + ).rejects.toThrow('Overlay lookup temporarily unavailable or incomplete') expect(mockFacilitator.lookup.mock.calls).toHaveLength(2) }) diff --git a/packages/sdk/src/overlay-tools/boundLookupResponse.ts b/packages/sdk/src/overlay-tools/boundLookupResponse.ts new file mode 100644 index 000000000..71bf92d97 --- /dev/null +++ b/packages/sdk/src/overlay-tools/boundLookupResponse.ts @@ -0,0 +1,43 @@ +import { LookupValidationError } from './ReliableLookup.js' + +const MAX_RESPONSE_BYTES = 4 * 1024 * 1024 + +/** Bound actual streamed wire bytes before JSON or BEEF parsing. */ +export async function boundLookupResponse( + response: Response, + signal?: AbortSignal +): Promise { + if (Number(response.headers.get('content-length')) > MAX_RESPONSE_BYTES) { + await response.body?.cancel() + throw new LookupValidationError('malformed') + } + const reader = response.body?.getReader() + // Custom fetch implementations may expose parsed bodies only; their author owns byte limits. + if (reader === undefined) return response + const chunks: Uint8Array[] = [] + let length = 0 + const abort = (): void => { + void reader.cancel().catch(() => {}) + } + signal?.addEventListener('abort', abort, { once: true }) + try { + while (true) { + if (signal?.aborted === true) throw new Error('Lookup aborted') + const { value, done } = await reader.read() + if (done) break + length += value.byteLength + if (length > MAX_RESPONSE_BYTES) throw new LookupValidationError('malformed') + chunks.push(value) + } + const bytes = new Uint8Array(length) + let offset = 0 + for (const chunk of chunks) { + bytes.set(chunk, offset) + offset += chunk.byteLength + } + return new Response(bytes, { status: response.status, headers: response.headers }) + } finally { + signal?.removeEventListener('abort', abort) + await reader.cancel().catch(() => {}) + } +} diff --git a/packages/sdk/src/overlay-tools/index.ts b/packages/sdk/src/overlay-tools/index.ts index 6a14c978f..9cc7af7aa 100644 --- a/packages/sdk/src/overlay-tools/index.ts +++ b/packages/sdk/src/overlay-tools/index.ts @@ -9,3 +9,14 @@ export { default as TopicBroadcaster } from './SHIPBroadcaster.js' // Historically, it was also known by two other names: export { default as SHIPBroadcaster } from './SHIPBroadcaster.js' export { default as SHIPCast } from './SHIPBroadcaster.js' + +export type { + ReliableLookupOptions, + ReliableLookupResult, + ReliableHostOutcome +} from './ReliableLookup.js' +export { LookupValidationError, LookupValidationUnavailableError } from './ReliableLookup.js' +export type { + ReliableReputationStorage, + ReliableReputationEntry +} from './ReliableHostReputation.js' From 353ab9f5a1f540f726a6c2227359c80da04841fc Mon Sep 17 00:00:00 2001 From: Brayden Langley Date: Fri, 4 Sep 2026 14:55:16 -0700 Subject: [PATCH 03/11] Polish shared lookup recovery and verify browser persistence --- docs-site/scripts/frontmatter-parser.test.mjs | 38 +++ docs/reference/package-api-migrations.md | 4 +- governance/dependency-release-policy.json | 18 +- governance/package-release-notes.json | 2 +- governance/repository-health/exceptions.json | 25 +- packages/sdk/CHANGELOG.md | 2 +- packages/sdk/README.md | 8 +- packages/sdk/browser/lookup-recovery.mjs | 177 ++++++++++ .../sdk/docs/globalkv-reliability-draft.md | 36 +- .../docs/globalkv-reliability-validation.md | 247 ++++++-------- packages/sdk/docs/overlay-lookup-migration.md | 23 +- packages/sdk/package.json | 9 +- packages/sdk/src/kvstore/ReliableKVStore.ts | 308 ++++++++++-------- .../sdk/src/overlay-tools/LookupResolver.ts | 31 +- .../overlay-tools/ReliableHostReputation.ts | 34 +- .../sdk/src/overlay-tools/ReliableLookup.ts | 30 +- .../overlay-tools/ReliableTopicBroadcaster.ts | 119 ++++--- .../__tests/ReliableTopicBroadcaster.test.ts | 2 +- pnpm-lock.yaml | 79 +++-- pnpm-workspace.yaml | 7 +- .../dependency-release-governance.test.mjs | 2 +- 21 files changed, 726 insertions(+), 475 deletions(-) create mode 100644 docs-site/scripts/frontmatter-parser.test.mjs create mode 100644 packages/sdk/browser/lookup-recovery.mjs diff --git a/docs-site/scripts/frontmatter-parser.test.mjs b/docs-site/scripts/frontmatter-parser.test.mjs new file mode 100644 index 000000000..5f7135566 --- /dev/null +++ b/docs-site/scripts/frontmatter-parser.test.mjs @@ -0,0 +1,38 @@ +import assert from 'node:assert/strict' +import { createRequire } from 'node:module' +import test from 'node:test' + +import mdx from '@mdx-js/rollup' +import remarkFrontmatter from 'remark-frontmatter' +import remarkMdxFrontmatter from 'remark-mdx-frontmatter' + +const pluginRequire = createRequire(import.meta.resolve('remark-mdx-frontmatter')) +const toml = pluginRequire('toml') + +async function compile(source, options) { + const plugin = mdx(options) + return (await plugin.transform.call({}, source, 'fixture.mdx')).code +} + +test('patched TOML parser preserves the frontmatter plugin contract', async () => { + const result = await compile( + '+++\ntitle = "Overlay recovery"\n[review]\nready = true\n+++\n\n# Test', + { + remarkPlugins: [[remarkFrontmatter, ['yaml', 'toml']], remarkMdxFrontmatter] + } + ) + assert.match(String(result), /Overlay recovery/) + assert.match(String(result), /"ready": true/) + const yaml = await compile('---\ntitle: Overlay recovery\n---\n\n# Test', { + remarkPlugins: [remarkFrontmatter, remarkMdxFrontmatter] + }) + assert.match(String(yaml), /Overlay recovery/) +}) + +test('patched TOML isolates prototype paths and rejects excessive nesting', () => { + const parsed = toml.parse('[__proto__]\npolluted = true') + assert.equal(Object.getPrototypeOf(parsed), null) + assert.equal(parsed.__proto__.polluted, true) + assert.equal(Object.prototype.polluted, undefined) + assert.throws(() => toml.parse(`value = ${'['.repeat(1000)}0${']'.repeat(1000)}`)) +}) diff --git a/docs/reference/package-api-migrations.md b/docs/reference/package-api-migrations.md index 4c4924755..e6c1c01b5 100644 --- a/docs/reference/package-api-migrations.md +++ b/docs/reference/package-api-migrations.md @@ -48,7 +48,7 @@ and clean-consumer tests remain the executable type authority. | `@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` | `3.0.0` | major | [API and usage](../packages/sdk/bsv-sdk.md) | Unapproved SDK 3.0.0 draft; do not publish or deploy. Standard query() now throws LookupUnavailableError for incomplete empty aggregates. queryDetailed()/query$() expose transport status and discoveryComplete; complete does not prove authority or freshness. All standard resolver calls use a 5-second total budget, 2-second host budgets, fresh discovery, 32 candidate hosts and 256 outputs per host; streamed responses are limited to 4 MiB. Legacy reputation records are ignored automatically; atomic v4 storage is configurable. Review packages/sdk/docs/overlay-lookup-migration.md, first-party peer compatibility and service-specific validation before any coordinated migration. The optional KV path additionally requires a trusted chain tracker and authority policy. Existing byte encodings and incorporated-code notice requirements remain unchanged. | +| `@bsv/sdk` | `2.4.0` | `3.0.0` | major | [API and usage](../packages/sdk/bsv-sdk.md) | SDK 3.0.0 changes default lookup behavior. Standard query() now throws LookupUnavailableError for incomplete empty aggregates. queryDetailed()/query$() expose transport status and discoveryComplete; complete does not prove authority or freshness. All standard resolver calls use a 5-second total budget, 2-second host budgets, fresh discovery, 32 candidate hosts and 256 outputs per host; streamed responses are limited to 4 MiB. Legacy reputation records are ignored automatically; atomic v4 storage is configurable. Review packages/sdk/docs/overlay-lookup-migration.md, first-party peer compatibility and service-specific validation before any coordinated migration. The optional KV path additionally requires a trusted chain tracker and authority policy. Existing byte encodings and incorporated-code notice requirements remain unchanged. | | `@bsv/simple` | `0.4.1` | `0.5.2` | 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. 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. | @@ -360,7 +360,7 @@ 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. Moves bounded discovery, advisory network/service/host reputation, cancellation, response limits and failure evidence into the standard LookupResolver for all overlay lookup services. Adds optional verified GlobalKVStore observations and write reconciliation. -- Migration: Unapproved SDK 3.0.0 draft; do not publish or deploy. Standard query() now throws LookupUnavailableError for incomplete empty aggregates. queryDetailed()/query$() expose transport status and discoveryComplete; complete does not prove authority or freshness. All standard resolver calls use a 5-second total budget, 2-second host budgets, fresh discovery, 32 candidate hosts and 256 outputs per host; streamed responses are limited to 4 MiB. Legacy reputation records are ignored automatically; atomic v4 storage is configurable. Review packages/sdk/docs/overlay-lookup-migration.md, first-party peer compatibility and service-specific validation before any coordinated migration. The optional KV path additionally requires a trusted chain tracker and authority policy. Existing byte encodings and incorporated-code notice requirements remain unchanged. +- Migration: SDK 3.0.0 changes default lookup behavior. Standard query() now throws LookupUnavailableError for incomplete empty aggregates. queryDetailed()/query$() expose transport status and discoveryComplete; complete does not prove authority or freshness. All standard resolver calls use a 5-second total budget, 2-second host budgets, fresh discovery, 32 candidate hosts and 256 outputs per host; streamed responses are limited to 4 MiB. Legacy reputation records are ignored automatically; atomic v4 storage is configurable. Review packages/sdk/docs/overlay-lookup-migration.md, first-party peer compatibility and service-specific validation before any coordinated migration. The optional KV path additionally requires a trusted chain tracker and authority policy. Existing byte encodings and incorporated-code notice requirements remain unchanged. | Public subpath | Runtime target(s) | Declaration target(s) | | ---------------------------------- | ---------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------- | diff --git a/governance/dependency-release-policy.json b/governance/dependency-release-policy.json index 5ebebc46a..1e7012ce9 100644 --- a/governance/dependency-release-policy.json +++ b/governance/dependency-release-policy.json @@ -1,6 +1,6 @@ { "schemaVersion": 1, - "lastReviewed": "2026-08-30", + "lastReviewed": "2026-09-04", "owner": "ts-stack-maintainers", "routineUpdates": { "dependabotConfig": ".github/dependabot.yml", @@ -144,10 +144,10 @@ "closeImplementationWaveAfterPublication": false }, "overrideRemovalReview": { - "reviewedAt": "2026-08-30", - "method": "Rechecked retained root and standalone overrides against the frozen locks, current package metadata, and the current advisory audit. Verified that typed-rest-client 2.3.1 still pins qs 6.15.1, Google and OpenTelemetry paths still request gaxios 6.x or 7.1.3, and teeny-request still permits uuid 9.x.", - "retainedCount": 20, - "result": "All 20 retained overrides still prevent a reproduced vulnerable transitive version or preserve an isolated reproducible toolchain closure. The expired qs, gaxios, and uuid entries remain necessary; the frozen graph resolves their reviewed patched versions and the high-severity audit reports only the two governed image-size advisories.", + "reviewedAt": "2026-09-04", + "method": "Retained the 2026-08-30 removal rehearsal for the 20 existing substitutions. On 2026-09-04, rechecked registry metadata and the frozen audit for typed-rest-client 2.3.1 (still pins qs 6.15.1) and remark-mdx-frontmatter 5.2.0 (still requests toml ^3.0.0). Verified compatible natural lock updates for fast-uri and Express qs; scoped replacements remain necessary for the two constrained parents.", + "retainedCount": 21, + "result": "The registry now contains 21 substitutions: the 20 previously rehearsed entries, with typed-rest-client qs advanced to 6.16.0, plus the frontmatter plugin toml 4.2.0 security fix. TOML/YAML compilation and prototype/recursion regressions pass; the frozen high-severity audit reports only the two previously governed image-size advisories. No new advisory exclusion was added.", "nextReview": "Rehearse removal monthly and immediately after upstream Google client, Jest/minimatch, typed-rest-client, Redocly, AJV, express-rate-limit, Socket.IO, Vite/PostCSS, Metro, or image-size dependency changes." }, "overrideRegistry": [ @@ -166,7 +166,7 @@ { "source": "pnpm-workspace.yaml", "selector": "typed-rest-client@2.3.1>qs", - "value": "6.15.3", + "value": "6.16.0", "exceptionId": "typed-rest-client-qs-override" }, { @@ -275,6 +275,12 @@ "selector": "gaxios", "value": "7.3.0", "exceptionId": "standalone-gaxios-advisory-override" + }, + { + "source": "pnpm-workspace.yaml", + "selector": "remark-mdx-frontmatter@5.2.0>toml", + "value": "4.2.0", + "exceptionId": "remark-mdx-frontmatter-toml-override" } ], "scheduledVerification": { diff --git a/governance/package-release-notes.json b/governance/package-release-notes.json index c68a936df..ceb84b350 100644 --- a/governance/package-release-notes.json +++ b/governance/package-release-notes.json @@ -169,7 +169,7 @@ "publishedVersion": "2.4.0", "releaseType": "major", "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. Moves bounded discovery, advisory network/service/host reputation, cancellation, response limits and failure evidence into the standard LookupResolver for all overlay lookup services. Adds optional verified GlobalKVStore observations and write reconciliation.", - "migration": "Unapproved SDK 3.0.0 draft; do not publish or deploy. Standard query() now throws LookupUnavailableError for incomplete empty aggregates. queryDetailed()/query$() expose transport status and discoveryComplete; complete does not prove authority or freshness. All standard resolver calls use a 5-second total budget, 2-second host budgets, fresh discovery, 32 candidate hosts and 256 outputs per host; streamed responses are limited to 4 MiB. Legacy reputation records are ignored automatically; atomic v4 storage is configurable. Review packages/sdk/docs/overlay-lookup-migration.md, first-party peer compatibility and service-specific validation before any coordinated migration. The optional KV path additionally requires a trusted chain tracker and authority policy. Existing byte encodings and incorporated-code notice requirements remain unchanged." + "migration": "SDK 3.0.0 changes default lookup behavior. Standard query() now throws LookupUnavailableError for incomplete empty aggregates. queryDetailed()/query$() expose transport status and discoveryComplete; complete does not prove authority or freshness. All standard resolver calls use a 5-second total budget, 2-second host budgets, fresh discovery, 32 candidate hosts and 256 outputs per host; streamed responses are limited to 4 MiB. Legacy reputation records are ignored automatically; atomic v4 storage is configurable. Review packages/sdk/docs/overlay-lookup-migration.md, first-party peer compatibility and service-specific validation before any coordinated migration. The optional KV path additionally requires a trusted chain tracker and authority policy. Existing byte encodings and incorporated-code notice requirements remain unchanged." }, { "name": "@bsv/simple", diff --git a/governance/repository-health/exceptions.json b/governance/repository-health/exceptions.json index d1e09124d..7feaa45f2 100644 --- a/governance/repository-health/exceptions.json +++ b/governance/repository-health/exceptions.json @@ -132,16 +132,18 @@ "category": "override", "target": "pnpm-workspace.yaml override typed-rest-client@2.3.1>qs", "owner": "ts-stack-maintainers", - "reason": "The workspace uses Stryker 9.6.1, and the current Stryker 10.0.0 release still depends on typed-rest-client ~2.3.0. typed-rest-client 2.3.1 pins vulnerable qs 6.15.1 exactly. GHSA-q8mj-m7cp-5q26 is fixed in qs 6.15.2 and later, so a parent-scoped substitution to 6.15.3 remains the narrowest durable remediation. The previous lock-only selection was lost when an unrelated dependency change regenerated the graph.", + "reason": "The workspace uses Stryker 9.6.1, and Stryker 10.0.0 still depends on typed-rest-client ~2.3.0. typed-rest-client 2.3.1 pins vulnerable qs 6.15.1 exactly. The parent-scoped qs 6.16.0 replacement preserves the qs 6 API and fixes GHSA-q8mj-m7cp-5q26, GHSA-x5fp-wj9c-mxmx and GHSA-4mjr-xmp4-gh2g. A lock-only selection cannot survive regeneration of the exact parent constraint.", "evidence": [ "pnpm-workspace.yaml#overrides", "https://github.com/advisories/GHSA-q8mj-m7cp-5q26", "https://www.npmjs.com/package/typed-rest-client/v/2.3.1?activeTab=dependencies", - "https://github.com/bsv-blockchain/ts-stack/issues/324" + "https://github.com/bsv-blockchain/ts-stack/issues/324", + "https://github.com/advisories/GHSA-x5fp-wj9c-mxmx", + "https://github.com/advisories/GHSA-4mjr-xmp4-gh2g" ], "created": "2026-07-27", "reviewBy": "2026-09-30", - "removeWhen": "Remove when Stryker no longer depends on typed-rest-client 2.3.1 or a supported typed-rest-client release natively depends on qs 6.15.2 or newer, then regenerate the lock and rerun the complete mutation campaign." + "removeWhen": "Remove when Stryker no longer depends on typed-rest-client 2.3.1 or a supported typed-rest-client release natively depends on qs 6.16.0 or newer, then regenerate the lock and rerun the complete mutation campaign." }, { "id": "nanoid-postcss-advisory-override", @@ -309,6 +311,23 @@ "created": "2026-07-28", "reviewBy": "2026-10-26", "removeWhen": "Remove when the analyzer exempts exhaustive protocol dispatch or a separately reviewed generated opcode table improves auditability and passes every script vector, differential, mutation, fuzz, transaction, browser, and packed-consumer check." + }, + { + "id": "remark-mdx-frontmatter-toml-override", + "category": "override", + "target": "pnpm-workspace.yaml override remark-mdx-frontmatter@5.2.0>toml", + "owner": "ts-stack-maintainers", + "reason": "The latest remark-mdx-frontmatter 5.2.0 still requests toml ^3.0.0. No supported plugin update resolves the recursion and prototype-pollution advisories. The parent-scoped toml 4.2.0 replacement retains its parse() API and supports Node >=20, within the workspace Node >=24.11 contract. The MDX frontmatter regression and complete docs build exercise this build-only dependency.", + "evidence": [ + "pnpm-workspace.yaml#overrides", + "https://github.com/advisories/GHSA-82x6-q7mm-w9cf", + "https://github.com/advisories/GHSA-v5mp-jgw5-2x6j", + "https://www.npmjs.com/package/remark-mdx-frontmatter/v/5.2.0?activeTab=dependencies", + "docs-site/scripts/frontmatter-parser.test.mjs" + ], + "created": "2026-09-04", + "reviewBy": "2026-10-04", + "removeWhen": "Remove when the supported frontmatter plugin naturally resolves toml >=4.2.0, then regenerate the frozen lock and rerun frontmatter tests, the docs build and security audit." } ] } diff --git a/packages/sdk/CHANGELOG.md b/packages/sdk/CHANGELOG.md index e2da159e7..a42e01220 100644 --- a/packages/sdk/CHANGELOG.md +++ b/packages/sdk/CHANGELOG.md @@ -219,7 +219,7 @@ All notable changes to this project will be documented in this file. The format every standard overlay resolver method. Incomplete empty `query()` calls now throw a retryable error. Add optional verified GlobalKVStore observations and write reconciliation. See `docs/overlay-lookup-migration.md`. - Unapproved; see `docs/globalkv-reliability-draft.md` before any migration. + See `docs/overlay-lookup-migration.md` for the SDK 3 migration. ### Added diff --git a/packages/sdk/README.md b/packages/sdk/README.md index c622b33e0..95c8ff440 100644 --- a/packages/sdk/README.md +++ b/packages/sdk/README.md @@ -186,9 +186,9 @@ Incorporated material remains under the separate terms identified in Thank you for being a part of the BSV Blockchain Libraries Project. Let's build the future of BSV Blockchain together! -## Draft overlay lookup reliability +## Overlay lookup reliability -The unapproved SDK 3.0.0 candidate applies shared reliability to the standard +SDK 3.0.0 applies shared reliability to the standard `LookupResolver.query`, `queryDetailed`, `query$`, and `queryReliable` methods for all overlay lookup services. It includes bounded parallel discovery and requests, advisory scoped reputation, cancellation, response limits, and explicit failure @@ -200,5 +200,5 @@ Transport completion does not prove authoritative absence, freshness, or a service's cryptographic rules. Services can use `queryReliable` with their own validator; the optional `@bsv/sdk/kvstore/reliable` integration supplies KV-specific proof validation, spend reconciliation, write recovery, and UI state helpers. -See the [unapproved design proposal](docs/globalkv-reliability-draft.md) for evidence -and limits. This draft is not ready for publication or deployment. +See the [design and recovery evidence](docs/globalkv-reliability-draft.md) for evidence +and the service-specific guarantees. diff --git a/packages/sdk/browser/lookup-recovery.mjs b/packages/sdk/browser/lookup-recovery.mjs new file mode 100644 index 000000000..ca7372dcc --- /dev/null +++ b/packages/sdk/browser/lookup-recovery.mjs @@ -0,0 +1,177 @@ +import assert from 'node:assert/strict' +import { access, mkdtemp, readFile, rm } from 'node:fs/promises' +import { createServer } from 'node:http' +import { tmpdir } from 'node:os' +import path from 'node:path' +import { fileURLToPath } from 'node:url' + +import puppeteer from 'puppeteer-core' + +import { createCommandRunner } from '../../../scripts/lib/command-runner.mjs' + +const packageDirectory = fileURLToPath(new URL('../', import.meta.url)) +const run = createCommandRunner({ timeoutMs: 240_000, maxBufferBytes: 4 * 1024 * 1024 }) +const legacyKey = 'bsvsdk_overlay_host_reputation_v3' +const currentKey = 'bsvsdk_overlay_host_reputation_v4' +const services = ['ls_identity', 'ls_ship', 'ls_custom', 'ls_kvstore'] +const host = 'https://recovered.example' +const temporary = await mkdtemp(path.join(tmpdir(), 'sdk-lookup-browser-')) + +async function chromePath() { + for (const candidate of [ + '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', + '/Applications/Chromium.app/Contents/MacOS/Chromium', + '/usr/bin/google-chrome', + '/usr/bin/chromium' + ]) { + try { + await access(candidate) + return candidate + } catch {} + } + throw new Error('Chrome or Chromium is required for lookup recovery tests') +} + +async function preparePage(page, origin) { + await page.goto(origin) + await page.waitForFunction(() => globalThis.bsv?.LookupResolver !== undefined) + assert.equal(await page.evaluate(() => typeof navigator.locks?.request), 'function') + await page.evaluate( + ({ services, host }) => { + const output = { + beef: new bsv.Transaction( + 1, + [], + [{ lockingScript: bsv.LockingScript.fromHex('51'), satoshis: 1 }], + 0 + ).toBEEF(), + outputIndex: 0 + } + globalThis.lookupFixture = { + up: false, + calls: 0, + resolver: new bsv.LookupResolver({ + facilitator: { + async lookup(url) { + if (url !== host) return { type: 'output-list', outputs: [] } + globalThis.lookupFixture.calls++ + if (!globalThis.lookupFixture.up) throw new Error('synthetic transport failure') + await new Promise(resolve => setTimeout(resolve, 25)) + return { type: 'output-list', outputs: [output] } + } + }, + hostOverrides: Object.fromEntries( + services.map(service => [service, ['https://empty.example', host]]) + ) + }) + } + }, + { services, host } + ) +} + +async function query(page, service, up) { + return await page.evaluate( + async ({ service, up }) => { + const fixture = globalThis.lookupFixture + fixture.up = up + const before = fixture.calls + try { + const answer = await fixture.resolver.query({ service, query: {} }) + return { outputs: answer.outputs.length, probes: fixture.calls - before } + } catch (error) { + if (!(error instanceof bsv.LookupUnavailableError)) throw error + return { unavailable: error.retryable, probes: fixture.calls - before } + } + }, + { service, up } + ) +} + +let browser +let server +try { + // Exercise the published UMD payload, extracted from a real package tarball. + const { stdout } = await run('pnpm', ['pack', '--json', '--pack-destination', temporary], { + cwd: packageDirectory, + env: { ...process.env, npm_config_ignore_scripts: 'true' } + }) + await run('tar', ['-xzf', JSON.parse(stdout).filename, '-C', temporary]) + const bundle = await readFile(path.join(temporary, 'package/dist/umd/bundle.js')) + server = createServer((request, response) => { + if (request.url === '/bundle.js') { + response.writeHead(200, { 'Content-Type': 'text/javascript' }) + response.end(bundle) + } else { + response.writeHead(200, { 'Content-Type': 'text/html' }) + response.end( + 'Lookup recovery fixture' + ) + } + }) + await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)) + const origin = `http://127.0.0.1:${server.address().port}` + browser = await puppeteer.launch({ + executablePath: await chromePath(), + headless: true, + args: ['--no-sandbox'] + }) + const pages = await Promise.all([browser.newPage(), browser.newPage()]) + await Promise.all(pages.map(page => preparePage(page, origin))) + const poison = JSON.stringify({ + [host]: { + backoffUntil: Date.now() + 365 * 86400000, + failCount: 100, + lastUpdated: Date.now() + 86400000 + } + }) + await pages[0].evaluate(({ legacyKey, poison }) => localStorage.setItem(legacyKey, poison), { + legacyKey, + poison + }) + + for (let cycle = 0; cycle < 5; cycle++) { + // Independent tabs write different services concurrently: neither may lose the other's update. + for (let offset = 0; offset < services.length; offset += 2) { + const pair = services.slice(offset, offset + 2) + const failed = await Promise.all( + pair.map((service, index) => query(pages[index], service, false)) + ) + assert.deepEqual( + failed, + pair.map(() => ({ unavailable: true, probes: 1 })) + ) + await pages[0].evaluate(key => navigator.locks.request(key, () => {}), currentKey) + const entries = await pages[0].evaluate( + key => JSON.parse(localStorage.getItem(key)).entries, + currentKey + ) + for (const service of pair) + assert.ok(entries[JSON.stringify(['mainnet', service, host])].penalty > 0) + // Reload while the penalty is still active; success must be possible without clearing state. + await Promise.all(pages.map(page => preparePage(page, origin))) + const recovered = await Promise.all( + pair.map((service, index) => query(pages[index], service, true)) + ) + assert.deepEqual( + recovered, + pair.map(() => ({ outputs: 1, probes: 1 })) + ) + await pages[0].evaluate(key => navigator.locks.request(key, () => {}), currentKey) + const healed = await pages[0].evaluate( + key => JSON.parse(localStorage.getItem(key)).entries, + currentKey + ) + for (const service of pair) + assert.equal(healed[JSON.stringify(['mainnet', service, host])].penalty, 0) + assert.equal(await pages[0].evaluate(key => localStorage.getItem(key), legacyKey), poison) + } + } + console.log( + 'Browser lookup recovery passed: 4 services, 5 outage/recovery cycles, 2 concurrent tabs, active v4 cooldowns, legacy poison preserved, 20 reloads, exact packed UMD.' + ) +} finally { + await browser?.close() + if (server) await new Promise(resolve => server.close(resolve)) + await rm(temporary, { recursive: true, force: true }) +} diff --git a/packages/sdk/docs/globalkv-reliability-draft.md b/packages/sdk/docs/globalkv-reliability-draft.md index 6b8337675..c567f904b 100644 --- a/packages/sdk/docs/globalkv-reliability-draft.md +++ b/packages/sdk/docs/globalkv-reliability-draft.md @@ -1,11 +1,10 @@ -# Shared overlay lookup and GlobalKVStore reliability proposal — unapproved, not ready for deployment +# Shared overlay lookup reliability -This is a local investigation and shared implementation proposal, based on -`98734b07cf` (2026-09-04). Nothing here authorizes merging, publishing, deployment, -changing live discovery, or removing existing consumer overrides. The revised -SDK 3.0.0 draft moves shared reliability into the standard resolver for every -lookup service. KV-specific correctness and write recovery remain optional. -See `overlay-lookup-migration.md` for the deliberate contract changes. +The standard SDK resolver now recovers from persisted host penalties, refreshes +discovery for each operation, and reports incomplete lookups explicitly for every +service. This design explains the reproduced failures, the shared fix and the +additional KV-specific validation available through the optional entrypoint. +See [the SDK 3 migration guide](overlay-lookup-migration.md) for changed defaults. ## Proven failure sequence @@ -106,7 +105,7 @@ and bounded parallel work over suppressing every probe during an outage. arbitrary 33rd healthy host will be contacted; scalable candidate scheduling needs review before a general guarantee can be made. -Discovery gathers all bounded tracker responses anew. The draft does not add a +Discovery gathers all bounded tracker responses anew. The resolver does not add a new persistent discovery cache. Advertisement parsing is routing input, not proof of authority or a guarantee of freshness. Duplicates normalize away; malformed URLs cannot be contacted. Existing emergency host overrides remain available. @@ -158,8 +157,8 @@ Empty responses carry no authenticated completeness/index watermark. Node identi is not an independent authority quorum. Thus the unconditional requested guarantee cannot honestly be delivered by a latency/reputation patch. -The draft exposes this limit rather than labeling observed data globally current. -Before promotion, define a versioned server capability for signed complete query +Observed data is labeled with its actual verification status. A future +authoritative-currentness extension would need to define a versioned server capability for signed complete query snapshots, authority membership/failure assumptions, index watermarks/read tokens, and spend/absence evidence. Pagination requires snapshot-bound cursors and global membership semantics. A server assertion alone still needs a stated trust policy. @@ -182,14 +181,14 @@ and submission behavior; its resolver calls inherit the shared lookup improvemen Competing replacement transactions now wait for independent indexing confirmation before the retry helper resumes. Replacement output zero is the GlobalKVStore contract; removal transactions still require a separate absence confirmation. -Remaining write blockers are material: cross-device uniqueness requires atomic +Write guarantees have explicit limits: cross-device uniqueness requires atomic server admission/reservation or an explicit protocol conflict policy. A browser -lock cannot provide that. The draft blocks subsequent same-key writes after an ambiguous/rejected result, +lock cannot provide that. The KV adapter blocks subsequent same-key writes after an ambiguous/rejected result, shares up to 256 pending records across instances, and exposes `reconcilePendingWrite` to resubmit the same retained signed transaction and confirm without creating another transaction. BEEF is held in private memory, never attached to an error object or written to reputation storage. A durable -cross-tab pending-write journal and tested restart reconciliation are required before claiming idempotence across reloads. Reliable +cross-tab pending-write journal and tested restart reconciliation are required for idempotence across reloads. Reliable history is intentionally rejected by legacy-shaped `get` until ancestor values receive the same verification policy. No current value is substituted for history. @@ -212,13 +211,13 @@ Proposed sequence after human design approval: 2. Complete server capability and atomic write-admission design in overlay-topics and the overlay engine, including portable protocol vectors. 3. Finish SDK browser/mobile, property/mutation, packed consumer and CI evidence. -4. Publish an approved SDK minor and coordinated server capability releases only +4. Publish SDK 3 and coordinated server capability releases only through protected workflows, in a separate explicitly authorized task. 5. Integrate structured reads in Metanet Docs and an independent consumer; retain production pins and compatibility adapters until their backend capabilities are independently validated. Roll out read-only observation first. 6. Verify synthetic canaries, tail latency, stale-state visibility and pending-write - recovery before any write-path upgrade. Human approval gates every deployment. + recovery before any write-path upgrade. Use the normal deployment approval process. 7. Roll back via prior package/config; v4 is additive and legacy records untouched. ## Review guide @@ -233,10 +232,11 @@ the optional `ReliableGlobalKVStore` adapter. KV-specific helpers remain in the optional subpath; the common fixes are included in the normal SDK and UMD. Run deterministic tests and real loopback fault injection before package checks. Read the limitations above before evaluating latency or completeness claims. -This proposal must remain a draft: it is not a complete implementation of all -requested protocol, restart, consumer UX or deployment guarantees. +The shared lookup fix is independently reviewable. Durable pending writes, global +currentness and production UI adoption are separate extensions with the +requirements described above. -## Local use and explicit non-goals of the prototype +## KV integration example ```ts import { GlobalKVStore, KVStoreReadSession } from '@bsv/sdk/kvstore/reliable' diff --git a/packages/sdk/docs/globalkv-reliability-validation.md b/packages/sdk/docs/globalkv-reliability-validation.md index a3a694a83..1b61f4d26 100644 --- a/packages/sdk/docs/globalkv-reliability-validation.md +++ b/packages/sdk/docs/globalkv-reliability-validation.md @@ -1,146 +1,101 @@ -# Local validation — unapproved shared overlay lookup draft - -Date: 2026-09-04. Source base: `98734b07cf` on ts-stack main. All failure -injection used synthetic data and isolated local processes. No live wallet, -overlay, Kubernetes, DNS, discovery advertisement, secret or reputation state -was touched. - -## Shared resolver revision: SDK 3.0.0 candidate - -The standard `LookupResolver` now owns the shared implementation for every service -and every query API. The optional resolver/facilitator names are compatibility -aliases. This revision supersedes the original adapter-only scope below. - -| Check | Result | -| --- | --- | -| Full SDK coverage suite | 164 suites, 5,998 tests, 1 snapshot passed | -| Coverage | Statements 94.32%, branches 86.49%, functions 95.06%, lines 95.23% | -| New standard-export regressions | 15 tests: non-KV services, all three query APIs, advisory cooldown recovery, total deadline including discovery, iterator cancellation, progressive recovery, invalid BEEF/txid hints, scoped reputation, and generic validator API | -| SDK build and workspace typecheck | Passed | -| Root health, lint and format | Passed; 42 projects, 34 public packages, zero contract findings/control errors | -| Packed artifact contract | Passed ESM/CJS, all conditional/wildcard exports, strict types, source maps and publint; includes the root LookupUnavailableError export | -| Exact-tarball browser contract | Passed Vite, esbuild and UMD | -| Main UMD | 554,559 raw bytes, below unchanged 555,000-byte limit | -| Property profile | 3 suites, 6 tests passed | -| Conformance parser | 76 files, 6,690 vectors, zero structure/parse errors; no new portable protocol claim | -| Metanet Docs candidate integration | Exact local 3.0.0 tarball, standard and optional imports verified; 49 files / 185 tests and frontend build passed | -| Gloss candidate integration | Same exact tarball, standard import verified; TypeScript build and 4 tests passed | -| Dependency audit | Still fails: 8 high, 2 moderate (2 existing ignored findings), unchanged dependency graph | - -The original cooldown reproductions now assert successful recovery on the standard -resolver; their failing legacy behavior remains reproducible at commit `ff36b55`. -The shared tests exercise arbitrary `ls_custom`, identity, SHIP and KV service -names using the ordinary package export. Real loopback KV fault-injection tests -also run through the shared resolver and facilitator aliases in the full suite. -The default APIs now enforce the same core policies without a KV import. - -Controlled fake-clock measurements: 250 ms recovered-host/fast-empty case, -2,000 ms healthy-plus-hung case for each API, and 1,600 ms total including -1,400 ms discovery. These are local deterministic measurements, not production -SLO data. Explicit iterator close cancels the remaining requests and leaves no -resolver timers. Service-specific proofs/currentness remain distinct from -transport completion. - -The new default error/deadline/persistence contract is deliberately recorded as -an unapproved major candidate. Consumer manifests, locks, peer ranges, deployed -pins and compatibility adapters are unchanged; a future coordinated SDK 3 peer -migration is still required. Existing consumer preview commits are reused with -local extracted artifacts only. No artifact was published. - -Commands: the same invariant checks listed below; SDK `test:coverage --runInBand`, -`test:property`; `node scripts/check-package-artifact.mjs packages/sdk --exports -PrivateKey,PublicKey,Transaction,Script,WalletClient,ProtoWallet,AuthFetch,IdentityClient,LookupResolver,LookupUnavailableError,RemittanceManager ---esm-only-entrypoints ./umd`; `node scripts/check-browser-package.mjs packages/sdk`; -`pnpm conformance`; `pnpm audit:security`; and each consumer's local test/build. - -## Original adapter-only validation at `ff36b55` (historical) - - -| Check | Result | -| ----------------------------------------------- | ---------------------------------------------------------------------------------------------- | -| Frozen pnpm install, lifecycle scripts disabled | Passed | -| Workspace build | Passed; local package/application artifacts only | -| Repository health | Passed: 42 projects, 34 public packages, zero contract findings/control errors | -| Root lint and format | Passed | -| Workspace typecheck | Passed after building the workspace; fresh checkout first needed Verifast outputs | -| SDK full coverage suite | 163 suites, 5,983 tests, 1 snapshot passed | -| SDK coverage | Statements 94.30%, branches 86.41%, functions 95.09%, lines 95.20% | -| Existing SDK property profile | 3 suites, 6 tests passed | -| SDK packed artifact contract | Passed ESM/CJS imports, conditional/wildcard exports, source maps, publint and type resolution | -| SDK exact-tarball browser contract | Passed Vite, esbuild and UMD | -| Main UMD payload | 554,942 raw bytes; unchanged 555,000-byte budget | -| New root-cause/adapter tests | 59 tests across 7 suites, included in full SDK results | -| Real loopback HTTP fault injection | 5 cases passed: disabled, delayed, corrupt, stale and empty peers | -| Metanet Docs with local SDK artifact | 49 test files, 185 tests and frontend build passed | -| Metanet Docs lint | Zero errors; 10 existing React refresh warnings | -| Gloss with local SDK artifact | TypeScript build and 4 tests passed | -| Security audit | Failed on unchanged dependency graph; details below | - -Commands include `pnpm build`, `pnpm health:check`, `pnpm lint`, -`pnpm format:check`, `pnpm typecheck`, `pnpm --filter @bsv/sdk test:coverage ---runInBand`, `pnpm --filter @bsv/sdk test:property`, -`pnpm --filter @bsv/sdk pack:check`, `pnpm --filter @bsv/sdk test:browser`, -and `pnpm audit:security`. Tests never changed production state. - -The new suites preserve three passing legacy reproductions and test the optional -adapter with real signatures, BEEF parsing and fixture-specific trusted Merkle -roots. Fake clocks establish the 800 ms fast-empty/data case, 2,000 ms degraded -host window and 2,400 ms delayed-discovery/retired-host case. HTTP tests use a -1,000 ms total budget and 300 ms per-host budget and assert success within the -total budget. These are controlled local measurements, not production SLO data. - -## Audit and review limitations - -`pnpm audit:security` reports 10 vulnerabilities: 8 high and 2 moderate, with 2 -already governed ignored findings. High findings include the existing `fast-uri` -version beneath root AJV tooling and `toml` beneath docs-site's -`remark-mdx-frontmatter`. This branch has no dependency or lockfile change. -No advisory was suppressed and no quality threshold or bundle budget was raised. -The only baseline metadata change updates the SDK package version to its proposed -major version; security baselines and accepted findings are unchanged. - -A public CI merge gate must not be represented as passing: the audit gate is -known to be blocked on this dependency graph, and the draft is explicitly not -ready for deployment. This task does not authorize publication or a separate -maintenance dependency migration. - -The full suite's coverage threshold passed, but that is not a claim that all -requested behavior is implemented. Remaining work includes durable cross-tab and -restart pending-write recovery, cross-device uniqueness, a currentness/absence -protocol, verified history, production consumer wiring, real multi-tab browser -scheduling tests, independent portable conformance for any new protocol, and -focused mutation/adversarial resource testing. Read the companion design before -considering promotion. - -## Consumer artifact verification - -Consumer manifests, locks, production factories, host pins and compatibility -patches are unchanged. Local preview commits are: - -- Metanet Docs: `a63e76a`, branch `codex/globalkv-reliability-integration`. -- Gloss: `01c6b7c`, branch `codex/globalkv-reliability-integration`. - -The optional module was verified by directly importing -`@bsv/sdk/kvstore/reliable` from each consumer's dependency tree before testing. -Metanet Docs' normal no-save npm install hit an existing Tiptap peer conflict; -its first baseline test pass therefore did not use the candidate. That result -was discarded as candidate evidence. The local SDK tarball was then extracted -into its disposable `node_modules/@bsv/sdk`, imports were verified, and the full -suite/build rerun successfully. Gloss used the same locally packed artifact. - -These preview commits remain local. Consumer PRs are deferred until an approved, -reproducible package input exists. The React preview tests failure-versus-absence -copy and a functioning Retry button; both consumers use the shared read session -for retained data and recovery. They do not switch production traffic. - -## Human approval required for later work - -Review the SDK draft and agree on the authority/fault model first. Complete the -protocol and remaining durability tests, then obtain explicit approval for each -package publication, server deployment and consumer migration. Preserve existing -pins and compatibility adapters during capability validation. A rollback uses -prior packages/configuration; v4 reputation does not delete legacy records. - -Nothing was merged, published, deployed or changed in production. Network-ops -itself remains unchanged. This report is evidence for a draft proposal, not a -release approval or a claim that the entire requested end state is achieved. +# Overlay lookup recovery validation + +The shared fix applies to the standard `LookupResolver` for every service. The +regressions cover persisted exclusion, stale discovery, bounded settlement and +failure-versus-empty reporting. KV-specific tests additionally exercise proof +validation, spend reconciliation and indexing-aware writes. + +All fault injection uses synthetic data, trusted fixture roots and isolated local +processes. Source comparison starts at ts-stack `98734b07cf` (2026-09-04). + +## Reproductions and regression coverage + +| Failure | Evidence | +| ----------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| Future cooldown excludes a recovered host | `LookupResolver.poison-reproduction.test.ts` reproduces the old exclusion, including a record generated through normal failure/flush calls after clock skew. The current standard resolver contacts the recovered host without clearing storage. | +| Empty peer masks data on another host | Shared resolver tests require both hosts to be contacted and preserve later data. | +| First discovery response retains a retired host | Discovery tests require the union of bounded tracker responses and a fresh discovery operation on the next query. | +| A hung host holds up a lookup | All standard query APIs settle within their host/operation budgets, including discovery; iterator close cancels remaining work. | +| Outage becomes an apparently empty collection | Incomplete empty aggregates throw retryable `LookupUnavailableError`; detailed/progressive APIs retain completion evidence. | +| Bad proof or stale ancestor wins by latency | KV tests validate BEEF, signatures, selectors and fixture-root SPV before reconciliation; incomparable tips produce conflict. | +| Accepted transaction is not yet indexed | Optional KV write tests retain the same transaction identity, report unconfirmed status and reconcile without creating another transaction. | +| Persisted penalties recur across tabs/reloads | `browser/lookup-recovery.mjs` uses the exact packed UMD in Chromium, real localStorage and Web Locks, two concurrent tabs, four services and five outage/recovery cycles. | + +The legacy cooldown behavior can be reproduced at `ff36b55`; the standard-export +recovery assertions are in `LookupResolver.shared-reliability.test.ts`. Services +include identity, SHIP, custom lookups and KV, so these checks do not depend on +using the optional KV entrypoint. + +Real loopback HTTP fault injection covers disabled, delayed, corrupt, stale and +empty peers using actual JSON, BEEF and signatures. Fake-clock tests establish +250 ms recovered-host response, 2,000 ms healthy-plus-hung settlement and a +1,600 ms total budget including 1,400 ms discovery. These measurements describe +the fixtures, not a production latency SLO. + +## Validation commands + +```sh +pnpm install --frozen-lockfile --ignore-scripts +pnpm health:check +pnpm lint +pnpm format:check +pnpm typecheck +pnpm --filter @bsv/sdk test:coverage --runInBand +pnpm --filter @bsv/sdk test:property +pnpm --filter @bsv/sdk pack:check +pnpm --filter @bsv/sdk test:browser +pnpm --filter docs-site test +pnpm --filter docs-site build +pnpm conformance +pnpm audit:security +``` + +The browser command requires Chrome or Chromium in a standard system location, +uses a disposable browser profile, and removes its package extraction afterwards. +No browser account, production endpoint or existing storage is used. + +The final local SDK coverage suite passed 164 suites / 5,998 tests and +one snapshot, with 94.34% statement, 86.48% branch, 95.16% function and 95.26% line +coverage. Packed ESM/CJS exports, strict declarations, source maps, publint, +Vite, esbuild and UMD contracts passed. UMD is 554,779 bytes within the unchanged 555,000-byte budget. +The PR records the final exact-head results; historical measurements are not a +substitute for its current hosted merge gate. + +The existing property profile passed three suites / six tests. Conformance +parsed 76 files / 6,690 vectors without errors; no new portable protocol is +introduced. Four targeted fault mutations were independently detected by the +regressions: reinstating cooldown exclusion (5 failed tests), returning empty on +failure (1), using only the first tracker (1), and sharing reputation across +services (5). All mutated source was restored and the focused suites passed +again. This is evidence for those four mechanisms, not a general mutation score; +the governed hosted mutation campaign remains a separate gate. + +## Consumer checks and scope + +Metanet Docs (`a63e76a`) passed 49 files / 185 tests and its frontend build against +an extracted local SDK 3 tarball. Gloss (`01c6b7c`) passed four tests and its +TypeScript build against the same artifact. The candidate imports were verified +inside each consumer's dependency tree before testing. The previews exercise +retained data, degraded status and Retry through the shared read session. + +Consumer preview commits remain local; production factories, dependency pins and +compatibility adapters are unchanged. Publication and consumer adoption follow +the coordinated SDK 3 migration described in [the migration guide](overlay-lookup-migration.md). + +The shared resolver fixes recovery and distinguishes incomplete transport from +empty results. It cannot establish authoritative global absence/currentness for +an arbitrary service. Those guarantees require a service validator and an +explicit authority/protocol model. Durable pending-write recovery across reloads, +cross-device uniqueness, verified KV history and production UI adoption are +separate extensions; see [the design](globalkv-reliability-draft.md). + +## Tooling dependency remediation + +The audit identified vulnerable `fast-uri`, `qs` and `toml` versions in the build +and test graph. Compatible lock updates select fast-uri 3.1.7 and qs 6.16.0. +The existing typed-rest-client override selects the same patched qs version. +The current frontmatter plugin still requires toml 3, so a parent-scoped toml +4.2.0 substitution is registered with an owner, review date and removal condition. +MDX TOML/YAML integration, parser security regressions and the full documentation +build check its compatibility. No advisory dismissal or quality/bundle threshold +was added or relaxed. diff --git a/packages/sdk/docs/overlay-lookup-migration.md b/packages/sdk/docs/overlay-lookup-migration.md index 609ee210c..bbbdb8bbb 100644 --- a/packages/sdk/docs/overlay-lookup-migration.md +++ b/packages/sdk/docs/overlay-lookup-migration.md @@ -1,8 +1,7 @@ -# Shared overlay lookup migration — unapproved SDK 3.0.0 draft +# SDK 3 overlay lookup migration -This candidate is intentionally a major version. It changes default scheduling, -error behavior, discovery caching, bounds, and persisted health handling. No -package has been published or deployed. All changes remain in a draft PR. +SDK 3 is a major version. It changes default scheduling, +error behavior, discovery caching, bounds, and persisted health handling. Publication and consumer upgrades follow the coordinated release process. ## Scope @@ -54,7 +53,7 @@ now follow the same precedence as other services. ## Failure and completion contracts Before, `query()` could return `{ type: 'output-list', outputs: [] } when requests -failed. It now throws `LookupUnavailableError` with `retryable: true` and a +failed. It now throws `LookupUnavailableError`with`retryable: true` and a progress envelope for incomplete empty aggregates. A complete empty aggregate still means only that the selected hosts answered empty. It is not a non-inclusion proof or a claim about undiscovered hosts. @@ -74,11 +73,11 @@ try { `queryDetailed` and `query$` provide `progress.status` / `status`: -| Status | Meaning | -| --- | --- | -| `complete` | Discovery completed without truncation and every selected host returned a structurally usable output list. | -| `incomplete` | At least one output-list response arrived, but some discovery or host work failed, was truncated or is still pending. | -| `unavailable` | No usable output-list response has arrived. | +| Status | Meaning | +| ------------- | --------------------------------------------------------------------------------------------------------------------- | +| `complete` | Discovery completed without truncation and every selected host returned a structurally usable output list. | +| `incomplete` | At least one output-list response arrived, but some discovery or host work failed, was truncated or is still pending. | +| `unavailable` | No usable output-list response has arrived. | `query()` retains successful nonempty aggregate shapes, including partial data; use `queryDetailed()` to inspect completeness before replacing cached collections. @@ -105,7 +104,7 @@ arbitrary application state. KV's validator/reconciler is one implementation. Review the changed SDK contract and inventory each consumer's use of empty results, custom timeouts, response sizes, discovery cache settings and custom facilitators. Coordinate SDK 3 peer ranges and dependent package releases only after maintainer -approval; this draft does not change their public ranges or deployed pins. Repack +approval; this change does not change their public ranges or deployed pins. Repack and test each approved consumer, then adopt service-specific validators and UI failure handling where needed. KV-specific write and authority guarantees have additional unresolved requirements described in the companion design. @@ -113,4 +112,4 @@ additional unresolved requirements described in the companion design. A later rollback uses the prior package/configuration. Old reputation keys remain untouched, so the prior resolver can still encounter its original cooldown defect. Keep production overrides and compatibility patches until an approved rollout -has independently validated their removal. Nothing here authorizes a rollout. +has independently validated their removal. diff --git a/packages/sdk/package.json b/packages/sdk/package.json index a3b8c3c1b..5160eb184 100644 --- a/packages/sdk/package.json +++ b/packages/sdk/package.json @@ -754,11 +754,11 @@ "test:coverage": "pnpm build && jest --coverage --watchman=false", "test:property": "jest --runInBand --watchman=false --runTestsByPath src/primitives/__tests/utils.property.test.ts src/wallet/__tests/BRC100ByteEncoding.property.test.ts src/auth/clients/__tests__/AuthFetch.property.test.ts", "test:resource": "pnpm build && node ../../scripts/run-governed-test.mjs resource src/primitives/__tests/AESGCM.man.test.ts", - "format:check": "pnpm --workspace-root exec prettier --check \"packages/sdk/README.md\" \"packages/sdk/package.json\" \"packages/sdk/*.{js,json,ts}\"", + "format:check": "pnpm --workspace-root exec prettier --check \"packages/sdk/README.md\" \"packages/sdk/package.json\" \"packages/sdk/*.{js,json,ts}\" \"packages/sdk/browser/**/*.mjs\"", "lint:ci": "pnpm lint", - "lint": "oxlint mod.ts src benchmarks jest.config.js rspack.config.js --deny-warnings", + "lint": "oxlint mod.ts src benchmarks browser jest.config.js rspack.config.js --deny-warnings", "pack:check": "pnpm build && node ../../scripts/check-package-artifact.mjs . --exports PrivateKey,PublicKey,Transaction,Script,WalletClient,ProtoWallet,AuthFetch,IdentityClient,LookupResolver,RemittanceManager --esm-only-entrypoints ./umd", - "test:browser": "pnpm build && node ../../scripts/check-browser-package.mjs .", + "test:browser": "pnpm build && node ../../scripts/check-browser-package.mjs . && node browser/lookup-recovery.mjs", "typecheck": "tsc --build --pretty false", "build": "pnpm build:ts && pnpm build:umd", "build:ts": "tsc -b && tsconfig-to-dual-package tsconfig.cjs.json", @@ -802,7 +802,8 @@ "ts-loader": "^9.6.2", "ts2md": "^0.2.8", "tsconfig-to-dual-package": "^1.2.0", - "typescript": "npm:@typescript/typescript6@6.0.2" + "typescript": "npm:@typescript/typescript6@6.0.2", + "puppeteer-core": "25.4.0" }, "typesVersions": { "*": { diff --git a/packages/sdk/src/kvstore/ReliableKVStore.ts b/packages/sdk/src/kvstore/ReliableKVStore.ts index 896e3bd40..badc9842c 100644 --- a/packages/sdk/src/kvstore/ReliableKVStore.ts +++ b/packages/sdk/src/kvstore/ReliableKVStore.ts @@ -95,22 +95,7 @@ export async function validateKVAnswer( signal: AbortSignal ): Promise { if (answer.outputs.length > 256) throw new LookupValidationError('malformed') - const checkedTracker: ChainTracker = { - currentHeight: async () => { - try { - return await tracker.currentHeight() - } catch { - throw new LookupValidationUnavailableError() - } - }, - isValidRootForHeight: async (root, height) => { - try { - return await tracker.isValidRootForHeight(root, height) - } catch { - throw new LookupValidationUnavailableError() - } - } - } + const checkedTracker = validationTracker(tracker) const values: ValidatedKVOutput[] = [] let totalBytes = 0 for (const result of answer.outputs) { @@ -128,67 +113,99 @@ export async function validateKVAnswer( result.outputIndex < 0 ) throw new LookupValidationError('malformed') - try { - const tx = Transaction.fromBEEF(result.beef) - const txid = tx.id('hex') - if (result.txid !== undefined && result.txid.toLowerCase() !== txid) - throw new Error('Mismatched transaction') - const output = tx.outputs[result.outputIndex] - if (output === undefined) throw new Error('Missing output') - const decoded = PushDrop.decode(output.lockingScript) - if (decoded.fields.length !== 5 && decoded.fields.length !== 6) - throw new Error('Invalid fields') - const signature = decoded.fields.pop() as number[] - const entry: KVStoreEntry = { - protocolID: JSON.parse(Utils.toUTF8(decoded.fields[0])), - key: Utils.toUTF8(decoded.fields[1]), - value: Utils.toUTF8(decoded.fields[2]), - controller: Utils.toHex(decoded.fields[3]) - } - if ( - !Array.isArray(entry.protocolID) || - entry.protocolID.length !== 2 || - ![0, 1, 2].includes(entry.protocolID[0]) || - typeof entry.protocolID[1] !== 'string' || - entry.key.length === 0 - ) - throw new Error('Invalid identity') - if (decoded.fields.length === 5) { - const tags: unknown = JSON.parse(Utils.toUTF8(decoded.fields[4])) - if (!Array.isArray(tags) || !tags.every(x => typeof x === 'string')) - throw new Error('Invalid tags') - entry.tags = tags - } - if (!matches(entry, query)) throw new Error('Off-query output') - const anyone = new ProtoWallet('anyone') - const args = { - protocolID: entry.protocolID, - keyID: entry.key, - counterparty: entry.controller + values.push(await validateKVOutput(result, query, checkedTracker)) + } + return values +} + +function validationTracker(tracker: ChainTracker): ChainTracker { + return { + currentHeight: async () => { + try { + return await tracker.currentHeight() + } catch { + throw new LookupValidationUnavailableError() } - const { valid } = await anyone.verifySignature({ - ...args, - data: decoded.fields.flat(), - signature - }) - if (!valid) throw new Error('Invalid signature') - const { publicKey } = await anyone.getPublicKey(args) - if (decoded.lockingPublicKey.toString() !== publicKey) - throw new Error('Invalid controller lock') - if (!(await tx.verify(checkedTracker))) throw new Error('Invalid transaction proof') - entry.token = { - txid, - outputIndex: result.outputIndex, - beef: Beef.fromBinary(result.beef), - satoshis: output.satoshis ?? 0 + }, + isValidRootForHeight: async (root, height) => { + try { + return await tracker.isValidRootForHeight(root, height) + } catch { + throw new LookupValidationUnavailableError() } - values.push({ entry, transaction: tx, outpoint: `${txid}.${result.outputIndex}` }) - } catch (error) { - if (error instanceof LookupValidationUnavailableError) throw error - throw new LookupValidationError('invalid') } } - return values +} + +function decodeKVFields(fields: number[][]): KVStoreEntry { + const entry: KVStoreEntry = { + protocolID: JSON.parse(Utils.toUTF8(fields[0])), + key: Utils.toUTF8(fields[1]), + value: Utils.toUTF8(fields[2]), + controller: Utils.toHex(fields[3]) + } + if ( + !Array.isArray(entry.protocolID) || + entry.protocolID.length !== 2 || + ![0, 1, 2].includes(entry.protocolID[0]) || + typeof entry.protocolID[1] !== 'string' || + entry.key.length === 0 + ) + throw new Error('Invalid identity') + if (fields.length === 5) { + const tags: unknown = JSON.parse(Utils.toUTF8(fields[4])) + if (!Array.isArray(tags) || !tags.every(x => typeof x === 'string')) + throw new Error('Invalid tags') + entry.tags = tags + } + return entry +} + +async function validateKVOutput( + result: LookupAnswer['outputs'][number], + query: KVStoreQuery, + checkedTracker: ChainTracker +): Promise { + try { + const tx = Transaction.fromBEEF(result.beef) + const txid = tx.id('hex') + if (result.txid !== undefined && result.txid.toLowerCase() !== txid) + throw new Error('Mismatched transaction') + const output = tx.outputs[result.outputIndex] + if (output === undefined) throw new Error('Missing output') + const decoded = PushDrop.decode(output.lockingScript) + if (decoded.fields.length !== 5 && decoded.fields.length !== 6) + throw new Error('Invalid fields') + const signature = decoded.fields.pop() as number[] + const entry = decodeKVFields(decoded.fields) + if (!matches(entry, query)) throw new Error('Off-query output') + const anyone = new ProtoWallet('anyone') + const args = { + protocolID: entry.protocolID, + keyID: entry.key, + counterparty: entry.controller + } + const { valid } = await anyone.verifySignature({ + ...args, + data: decoded.fields.flat(), + signature + }) + if (!valid) throw new Error('Invalid signature') + const { publicKey } = await anyone.getPublicKey(args) + if (decoded.lockingPublicKey.toString() !== publicKey) + throw new Error('Invalid controller lock') + if (!(await tx.verify(checkedTracker))) throw new Error('Invalid transaction proof') + entry.token = { + txid, + outputIndex: result.outputIndex, + beef: Beef.fromBinary(result.beef), + satoshis: output.satoshis ?? 0 + } + return { entry, transaction: tx, outpoint: `${txid}.${result.outputIndex}` } + } catch (error) { + if (error instanceof LookupValidationUnavailableError) throw error + throw new LookupValidationError('invalid') + } } function spends(transaction: Transaction, outpoint: string): boolean { @@ -209,18 +226,9 @@ function spends(transaction: Transaction, outpoint: string): boolean { return false } -/** Select only maximal states whose relationship is proven by transaction inputs. */ -export function reconcileKVResults( - result: ReliableLookupResult, - authorities: string[] = [] -): KVStoreReadResult { - const answers = result.hosts.filter(h => h.kind === 'answer') - const evidence: KVStoreReadEvidence = { - completedHosts: result.hosts.length, - failedHosts: result.hosts.length - answers.length, - discoveryComplete: result.discoveryComplete, - durationMs: result.durationMs - } +function maximalKVOutputs( + answers: Array<{ values: ValidatedKVOutput[] }> +): ValidatedKVOutput[] | undefined { const unique = new Map() for (const host of answers) for (const value of host.values) unique.set(value.outpoint, value) const groups = new Map() @@ -239,9 +247,36 @@ export function reconcileKVResults( const tips = group.filter( value => !group.some(other => other !== value && spends(other.transaction, value.outpoint)) ) - if (tips.length !== 1) return { kind: 'conflict', retryable: true, evidence } + if (tips.length !== 1) return undefined current.push(tips[0]) } + return current +} + +function emptyResultKind( + result: ReliableLookupResult, + answered: boolean +): 'incomplete' | 'malformed' | 'rejected' | 'unavailable' { + if (answered) return 'incomplete' + if (result.hosts.some(h => h.kind === 'invalid' || h.kind === 'malformed')) return 'malformed' + if (result.hosts.some(h => h.kind === 'rejected')) return 'rejected' + return 'unavailable' +} + +/** Select only maximal states whose relationship is proven by transaction inputs. */ +export function reconcileKVResults( + result: ReliableLookupResult, + authorities: string[] = [] +): KVStoreReadResult { + const answers = result.hosts.filter(h => h.kind === 'answer') + const evidence: KVStoreReadEvidence = { + completedHosts: result.hosts.length, + failedHosts: result.hosts.length - answers.length, + discoveryComplete: result.discoveryComplete, + durationMs: result.durationMs + } + const current = maximalKVOutputs(answers) + if (current === undefined) return { kind: 'conflict', retryable: true, evidence } const normalize = (host: string): string => host.replace(/\/$/, '') const authoritative = authorities.length > 0 && @@ -254,10 +289,7 @@ export function reconcileKVResults( evidence.failedHosts === 0 && answers.every(host => { const represented = new Set(host.values.map(value => value.outpoint)) - return ( - current.every(value => represented.has(value.outpoint)) && - host.values.every(value => unique.has(value.outpoint)) - ) + return current.every(value => represented.has(value.outpoint)) }) if (current.length > 0) return { @@ -269,62 +301,62 @@ export function reconcileKVResults( } if (complete && answers.every(host => host.values.length === 0)) return { kind: 'absent', authority: 'configured-hosts', evidence } - const kind = - answers.length > 0 - ? 'incomplete' - : result.hosts.some(h => h.kind === 'invalid' || h.kind === 'malformed') - ? 'malformed' - : result.hosts.some(h => h.kind === 'rejected') - ? 'rejected' - : 'unavailable' + const kind = emptyResultKind(result, answers.length > 0) return { kind, retryable: true, evidence } } +function entryIdentity(entry: KVStoreEntry): string { + return JSON.stringify([entry.protocolID, entry.controller, entry.key]) +} + /** UI state contains only caller-owned memory; it must be discarded on account/query change. */ export class KVStoreReadState { private lastGood: KVStoreEntry[] | undefined - apply(result: KVStoreReadResult): KVStoreReadResult { - if (result.kind === 'data') { - for (const next of result.entries) { - const prior = this.lastGood?.find( - entry => - entry.key === next.key && - entry.controller === next.controller && - JSON.stringify(entry.protocolID) === JSON.stringify(next.protocolID) - ) - if ( - prior?.token === undefined || - next.token === undefined || - prior.token.txid === next.token.txid - ) - continue - try { - const previousTx = Transaction.fromBEEF(prior.token.beef.toBinary(), prior.token.txid) - const nextTx = Transaction.fromBEEF(next.token.beef.toBinary(), next.token.txid) - if (spends(previousTx, `${next.token.txid}.${next.token.outputIndex}`)) { - return { - kind: 'stale', - entries: this.lastGood as KVStoreEntry[], - retryable: true, - evidence: result.evidence - } - } - if (!spends(nextTx, `${prior.token.txid}.${prior.token.outputIndex}`)) - return { kind: 'conflict', retryable: true, evidence: result.evidence } - } catch { - return { kind: 'conflict', retryable: true, evidence: result.evidence } - } + private previousRelationship(entries: KVStoreEntry[]): 'stale' | 'conflict' | undefined { + for (const next of entries) { + const prior = this.lastGood?.find(entry => entryIdentity(entry) === entryIdentity(next)) + if ( + prior?.token === undefined || + next.token === undefined || + prior.token.txid === next.token.txid + ) + continue + try { + const previousTx = Transaction.fromBEEF(prior.token.beef.toBinary(), prior.token.txid) + const nextTx = Transaction.fromBEEF(next.token.beef.toBinary(), next.token.txid) + if (spends(previousTx, `${next.token.txid}.${next.token.outputIndex}`)) return 'stale' + if (!spends(nextTx, `${prior.token.txid}.${prior.token.outputIndex}`)) return 'conflict' + } catch { + return 'conflict' } - if (result.completeness === 'partial' && this.lastGood !== undefined) { - const identity = (entry: KVStoreEntry): string => - JSON.stringify([entry.protocolID, entry.controller, entry.key]) - const combined = new Map(this.lastGood.map(entry => [identity(entry), entry])) - for (const entry of result.entries) combined.set(identity(entry), entry) - this.lastGood = [...combined.values()] - return { kind: 'stale', entries: this.lastGood, retryable: true, evidence: result.evidence } + } + return undefined + } + + private applyData(result: Extract): KVStoreReadResult { + const relationship = this.previousRelationship(result.entries) + if (relationship === 'stale') + return { + kind: 'stale', + entries: this.lastGood as KVStoreEntry[], + retryable: true, + evidence: result.evidence } - this.lastGood = result.entries - } else if (result.kind === 'absent') this.lastGood = undefined + if (relationship === 'conflict') + return { kind: 'conflict', retryable: true, evidence: result.evidence } + if (result.completeness === 'partial' && this.lastGood !== undefined) { + const combined = new Map(this.lastGood.map(entry => [entryIdentity(entry), entry])) + for (const entry of result.entries) combined.set(entryIdentity(entry), entry) + this.lastGood = [...combined.values()] + return { kind: 'stale', entries: this.lastGood, retryable: true, evidence: result.evidence } + } + this.lastGood = result.entries + return result + } + + apply(result: KVStoreReadResult): KVStoreReadResult { + if (result.kind === 'data') return this.applyData(result) + if (result.kind === 'absent') this.lastGood = undefined else if (result.kind !== 'conflict' && this.lastGood !== undefined) return { kind: 'stale', entries: this.lastGood, retryable: true, evidence: result.evidence } return result diff --git a/packages/sdk/src/overlay-tools/LookupResolver.ts b/packages/sdk/src/overlay-tools/LookupResolver.ts index aad57e9ae..b1741a2f6 100644 --- a/packages/sdk/src/overlay-tools/LookupResolver.ts +++ b/packages/sdk/src/overlay-tools/LookupResolver.ts @@ -315,7 +315,7 @@ export interface LookupResolverConfig { additionalHosts?: Record /** Transaction memo tuning; legacy host-cache options are accepted but ignored. */ cache?: CacheOptions - /** @deprecated Use reliableReputationStorage for atomic v4 persistence. Legacy get/set stores use memory-only health. */ + /** Legacy storage option. Use reliableReputationStorage for atomic v4 persistence; get/set-only stores use memory health. */ reputationStorage?: | 'localStorage' | { get: (key: string) => string | null | undefined; set: (key: string, value: string) => void } @@ -584,6 +584,17 @@ class LookupQuerySession { return added } + private completionStatus(): LookupAnswerProgress['status'] { + if (this.successfulHosts === 0) return 'unavailable' + if ( + this.discoveryComplete && + this.completedHosts === this.hostCount && + this.successfulHosts === this.hostCount + ) + return 'complete' + return 'incomplete' + } + snapshot(isFinal: boolean): LookupAnswerProgress { return { type: 'output-list', @@ -598,14 +609,7 @@ class LookupQuerySession { rejectedHosts: this.rejectedHosts, freeformHosts: this.freeformHosts, discoveryComplete: this.discoveryComplete, - status: - this.successfulHosts === 0 - ? 'unavailable' - : this.discoveryComplete && - this.completedHosts === this.hostCount && - this.successfulHosts === this.hostCount - ? 'complete' - : 'incomplete', + status: this.completionStatus(), ...(this.correlationId !== undefined ? { correlationId: this.correlationId } : {}) } } @@ -859,8 +863,7 @@ export default class LookupResolver { trackers.slice(0, 32).map(async tracker => { const result = await requestReliableHost( this.facilitator, - this.hostReputation, - this.networkPreset, + { reputation: this.hostReputation, network: this.networkPreset }, tracker, { service: 'ls_slap', query: { service: question.service } }, { @@ -923,8 +926,7 @@ export default class LookupResolver { hosts.push( await requestReliableHost( this.facilitator, - this.hostReputation, - this.networkPreset, + { reputation: this.hostReputation, network: this.networkPreset }, host, question, { @@ -1268,8 +1270,7 @@ export default class LookupResolver { let failure: unknown const result = await requestReliableHost( this.facilitator, - this.hostReputation, - this.networkPreset, + { reputation: this.hostReputation, network: this.networkPreset }, host, question, { diff --git a/packages/sdk/src/overlay-tools/ReliableHostReputation.ts b/packages/sdk/src/overlay-tools/ReliableHostReputation.ts index badbcfd3e..c8809ecfe 100644 --- a/packages/sdk/src/overlay-tools/ReliableHostReputation.ts +++ b/packages/sdk/src/overlay-tools/ReliableHostReputation.ts @@ -16,7 +16,13 @@ const KEY = 'bsvsdk_overlay_host_reputation_v4' const TTL = 86400000 const MAX_ENTRIES = 256 const MAX_COOLDOWN = 30000 -const reasons: HostFailureReason[] = ['timeout', 'transport', 'rejected', 'malformed', 'invalid'] +const reasons = new Set([ + 'timeout', + 'transport', + 'rejected', + 'malformed', + 'invalid' +]) function browserStorage(): ReliableReputationStorage | undefined { try { @@ -45,6 +51,15 @@ export class ReliableHostReputation { return JSON.stringify([network, service, host]) } + private validScope(key: string): boolean { + try { + const scope: unknown = JSON.parse(key) + return Array.isArray(scope) && scope.length === 3 && scope.every(x => typeof x === 'string') + } catch { + return false + } + } + private sanitize(input: unknown, now: number): Record { if (input === null || typeof input !== 'object' || Array.isArray(input)) return {} const entries: Record = {} @@ -61,15 +76,8 @@ export class ReliableHostReputation { e.cooldownUntil > e.updatedAt + MAX_COOLDOWN ) continue - if (e.reason !== undefined && !reasons.includes(e.reason)) continue - let scope: unknown - try { - scope = JSON.parse(key) - } catch { - continue - } - if (!Array.isArray(scope) || scope.length !== 3 || !scope.every(x => typeof x === 'string')) - continue + if (e.reason !== undefined && !reasons.has(e.reason)) continue + if (!this.validScope(key)) continue entries[key] = { updatedAt: e.updatedAt, cooldownUntil: e.cooldownUntil, @@ -100,9 +108,9 @@ export class ReliableHostReputation { this.entries = this.storage === undefined ? this.sanitize(this.entries, now) : this.read(now) const score = (host: string): number => { const e = this.entries[this.scope(network, service, host)] - return e === undefined - ? 0 - : e.penalty * 2 ** (-(now - e.updatedAt) / 60000) + (e.cooldownUntil > now ? 64 : 0) + if (e === undefined) return 0 + const cooldownPenalty = e.cooldownUntil > now ? 64 : 0 + return e.penalty * 2 ** (-(now - e.updatedAt) / 60000) + cooldownPenalty } return [...new Set(hosts)].sort((a, b) => score(a) - score(b)) } diff --git a/packages/sdk/src/overlay-tools/ReliableLookup.ts b/packages/sdk/src/overlay-tools/ReliableLookup.ts index 87cf2ea50..5dd85c2bf 100644 --- a/packages/sdk/src/overlay-tools/ReliableLookup.ts +++ b/packages/sdk/src/overlay-tools/ReliableLookup.ts @@ -90,10 +90,22 @@ export function normalizeHosts(hosts: string[], allowHTTP: boolean): string[] { return [...normalized] } +function failureReason(error: unknown): HostFailureReason { + if (error instanceof LookupValidationError) return error.reason + if (error instanceof Error && /deadline|timed out|abort/i.test(error.message)) return 'timeout' + if (error instanceof SyntaxError) return 'malformed' + if (typeof error === 'object' && error !== null && 'status' in error) { + const status = Number(error.status) + return status >= 400 && status < 500 && ![408, 425, 429].includes(status) + ? 'rejected' + : 'transport' + } + return 'transport' +} + export async function requestReliableHost( facilitator: OverlayLookupFacilitator, - reputation: ReliableHostReputation, - network: string, + context: { reputation: ReliableHostReputation; network: string }, host: string, question: LookupQuestion, options: Omit, 'validate'> & { @@ -105,6 +117,7 @@ export async function requestReliableHost( remainingMs: number, parent: AbortSignal ): Promise> { + const { reputation, network } = context const budget = Math.min(boundedMs(options.hostTimeoutMs, 2000), remainingMs) try { const values = await withinDeadline( @@ -120,18 +133,7 @@ export async function requestReliableHost( return { host, kind: 'answer', values } } catch (error) { options.onError?.(error) - let reason: HostFailureReason = 'transport' - if (error instanceof LookupValidationError) reason = error.reason - else if (error instanceof Error && /deadline|timed out|abort/i.test(error.message)) - reason = 'timeout' - else if (error instanceof SyntaxError) reason = 'malformed' - else if (typeof error === 'object' && error !== null && 'status' in error) { - const status = Number(error.status) - reason = - status >= 400 && status < 500 && ![408, 425, 429].includes(status) - ? 'rejected' - : 'transport' - } + const reason = failureReason(error) // Cancellation belongs to the operation, not to the host. if ( !parent.aborted && diff --git a/packages/sdk/src/overlay-tools/ReliableTopicBroadcaster.ts b/packages/sdk/src/overlay-tools/ReliableTopicBroadcaster.ts index 6aa4d8d05..99242d1d6 100644 --- a/packages/sdk/src/overlay-tools/ReliableTopicBroadcaster.ts +++ b/packages/sdk/src/overlay-tools/ReliableTopicBroadcaster.ts @@ -1,8 +1,9 @@ +import type { LookupAnswer } from './LookupResolver.js' import type Transaction from '../transaction/Transaction.js' import TransactionParser from '../transaction/Transaction.js' import type { BroadcastResponse, BroadcastFailure } from '../transaction/Broadcaster.js' import type ReliableLookupResolver from './ReliableLookupResolver.js' -import { HTTPSOverlayBroadcastFacilitator } from './SHIPBroadcaster.js' +import { HTTPSOverlayBroadcastFacilitator, type STEAK } from './SHIPBroadcaster.js' import OverlayAdminTokenTemplate from './OverlayAdminTokenTemplate.js' import { withinDeadline, @@ -20,6 +21,66 @@ export class ReliableTopicBroadcaster { private readonly httpClient: typeof fetch = globalThis.fetch.bind(globalThis) ) {} + private advertisedHosts(answer: LookupAnswer): string[] { + if (answer.outputs.length > 256) throw new LookupValidationError('malformed') + const candidates: string[] = [] + for (const output of answer.outputs) { + try { + const tx = TransactionParser.fromBEEF(output.beef) + const ad = OverlayAdminTokenTemplate.decode(tx.outputs[output.outputIndex].lockingScript) + if (ad.protocol === 'SHIP' && this.topics.includes(ad.topicOrService)) + candidates.push(ad.domain) + } catch { + throw new LookupValidationError('malformed') + } + } + return candidates + } + + private acknowledged(response: STEAK, transaction: Transaction): boolean { + return this.topics.every(topic => { + const ack = response?.[topic] + if (ack === undefined) return false + const admitted = ack.outputsToAdmit ?? [] + const retained = ack.coinsToRetain ?? [] + const removed = ack.coinsRemoved ?? [] + const validIndices = (indices: number[], length: number): boolean => + Array.isArray(indices) && + indices.every(index => Number.isInteger(index) && index >= 0 && index < length) + return ( + validIndices(admitted, transaction.outputs.length) && + validIndices(retained, transaction.inputs.length) && + validIndices(removed, transaction.inputs.length) && + admitted.length + retained.length + removed.length > 0 + ) + }) + } + + private async submitHost( + host: string, + transaction: Transaction, + beef: number[], + budget: number, + signal: AbortSignal + ): Promise { + try { + return await withinDeadline( + async child => { + const facilitator = new HTTPSOverlayBroadcastFacilitator( + async (input, init) => await this.httpClient(input, { ...init, signal: child }), + this.allowHTTP + ) + const response = await facilitator.send(host, { beef, topics: this.topics }) + return this.acknowledged(response, transaction) + }, + budget, + signal + ) + } catch { + return false + } + } + async broadcast(transaction: Transaction): Promise { const txid = transaction.id('hex') const start = monotonicNow() @@ -32,23 +93,7 @@ export class ReliableTopicBroadcaster { deadlineMs: 2500, hostTimeoutMs: 1000, signal, - validate: async answer => { - if (answer.outputs.length > 256) throw new LookupValidationError('malformed') - const candidates: string[] = [] - for (const output of answer.outputs) { - try { - const tx = TransactionParser.fromBEEF(output.beef) - const ad = OverlayAdminTokenTemplate.decode( - tx.outputs[output.outputIndex].lockingScript - ) - if (ad.protocol === 'SHIP' && this.topics.includes(ad.topicOrService)) - candidates.push(ad.domain) - } catch { - throw new LookupValidationError('malformed') - } - } - return candidates - } + validate: async answer => this.advertisedHosts(answer) } ) const hosts = normalizeHosts( @@ -57,40 +102,10 @@ export class ReliableTopicBroadcaster { ).slice(0, 32) const beef = transaction.toBEEF() const outcomes = await Promise.all( - hosts.map(async host => { - try { - return await withinDeadline( - async child => { - const facilitator = new HTTPSOverlayBroadcastFacilitator( - async (input, init) => await this.httpClient(input, { ...init, signal: child }), - this.allowHTTP - ) - const response = await facilitator.send(host, { beef, topics: this.topics }) - return this.topics.every(topic => { - const ack = response?.[topic] - if (ack === undefined) return false - const admitted = ack.outputsToAdmit ?? [] - const retained = ack.coinsToRetain ?? [] - const removed = ack.coinsRemoved ?? [] - return ( - [admitted, retained, removed].every( - indices => - Array.isArray(indices) && - indices.every(index => Number.isInteger(index) && index >= 0) - ) && - admitted.every(index => index < transaction.outputs.length) && - [...retained, ...removed].every(index => index < transaction.inputs.length) && - admitted.length + retained.length + removed.length > 0 - ) - }) - }, - Math.min(2000, remaining()), - signal - ) - } catch { - return false - } - }) + hosts.map( + async host => + await this.submitHost(host, transaction, beef, Math.min(2000, remaining()), signal) + ) ) return outcomes.some(Boolean) ? { diff --git a/packages/sdk/src/overlay-tools/__tests/ReliableTopicBroadcaster.test.ts b/packages/sdk/src/overlay-tools/__tests/ReliableTopicBroadcaster.test.ts index 634bd7843..c311f886d 100644 --- a/packages/sdk/src/overlay-tools/__tests/ReliableTopicBroadcaster.test.ts +++ b/packages/sdk/src/overlay-tools/__tests/ReliableTopicBroadcaster.test.ts @@ -44,7 +44,7 @@ describe('bounded submission adapter', () => { const fetch = jest.fn(async (url, init) => { signals.push(init.signal) expect(init.headers['X-Topics']).toBe('tm_kvstore') - if (url.startsWith(bad)) return await new Promise(() => {}) + if (new URL(url).origin === bad) return await new Promise(() => {}) return new Response(JSON.stringify(ack)) }) const broadcaster = new ReliableTopicBroadcaster(['tm_kvstore'], resolver as any, false, fetch) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 57f50aa30..6a36f4086 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -7,7 +7,8 @@ settings: overrides: brace-expansion@<5.0.9: 5.0.9 nanoid@<3.3.18: 3.3.18 - typed-rest-client@2.3.1>qs: 6.15.3 + typed-rest-client@2.3.1>qs: 6.16.0 + remark-mdx-frontmatter@5.2.0>toml: 4.2.0 patchedDependencies: image-size@1.2.1: @@ -78,7 +79,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)(esbuild@0.28.1)(jsdom@26.1.0)(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)(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)) conformance/runner: dependencies: @@ -484,7 +485,7 @@ importers: version: 5.109.2(esbuild@0.28.1)(webpack-cli@7.2.2) webpack-cli: specifier: ^7.2.2 - version: 7.2.2(json5@2.2.3)(toml@3.0.0)(webpack@5.109.2) + version: 7.2.2(json5@2.2.3)(toml@4.2.0)(webpack@5.109.2) packages/helpers/fund-wallet: dependencies: @@ -521,7 +522,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)(esbuild@0.28.1)(jsdom@26.1.0)(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)(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)) packages/helpers/simple: dependencies: @@ -689,7 +690,7 @@ importers: version: 5.109.2(esbuild@0.28.1)(webpack-cli@7.2.2) webpack-cli: specifier: ^7.2.2 - version: 7.2.2(json5@2.2.3)(toml@3.0.0)(webpack@5.109.2) + version: 7.2.2(json5@2.2.3)(toml@4.2.0)(webpack@5.109.2) packages/messaging/message-box-client: dependencies: @@ -735,7 +736,7 @@ importers: version: 5.109.2(esbuild@0.28.1)(webpack-cli@7.2.2) webpack-cli: specifier: ^7.2.2 - version: 7.2.2(json5@2.2.3)(toml@3.0.0)(webpack@5.109.2) + version: 7.2.2(json5@2.2.3)(toml@4.2.0)(webpack@5.109.2) packages/messaging/ts-paymail: dependencies: @@ -851,7 +852,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)(esbuild@0.28.1)(jsdom@26.1.0)(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)(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)) packages/middleware/auth: devDependencies: @@ -1441,6 +1442,9 @@ importers: oxlint: specifier: ^1.76.0 version: 1.76.0 + puppeteer-core: + specifier: 25.4.0 + version: 25.4.0(yauzl@3.4.0) ts-jest: specifier: ^29.4.12 version: 29.4.12(@babel/core@7.29.7)(@jest/transform@30.4.1)(@jest/types@30.4.1)(@typescript/typescript6@6.0.2)(babel-jest@30.4.1(@babel/core@7.29.7))(esbuild@0.28.1)(jest-util@30.4.1)(jest@30.4.2(@types/node@26.1.2)(ts-node@10.9.2(@types/node@26.1.2)(@typescript/typescript6@6.0.2))) @@ -1909,7 +1913,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)(esbuild@0.28.1)(jsdom@26.1.0)(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)(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)) packages/wallet/wallet-toolbox/mobile: dependencies: @@ -1961,7 +1965,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)(esbuild@0.28.1)(jsdom@26.1.0)(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)(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)) packages: @@ -5690,8 +5694,8 @@ packages: fast-string-width@3.0.2: resolution: {integrity: sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg==} - fast-uri@3.1.5: - resolution: {integrity: sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw==} + fast-uri@3.1.7: + resolution: {integrity: sha512-dOvZVzjdZdz7phd9v6jCbwxrBW3fK6n8Rc0CtdmM4bumzMnxywBYhuph6J819RRw/ku+rLbelwfMunktuzVVHg==} fast-wrap-ansi@0.2.2: resolution: {integrity: sha512-7F2Fl+TjRSenLqlU3UjSH0iyqopqoZIu7eZVpEirP2g1GtWa2G/ecEmBdgz31+Mxr+ELclgg6sokpSFIQiZ02Q==} @@ -7480,8 +7484,8 @@ packages: engines: {node: '>=10.13.0'} hasBin: true - qs@6.15.3: - resolution: {integrity: sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==} + qs@6.16.0: + resolution: {integrity: sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA==} engines: {node: '>=0.6'} quansync@1.0.0: @@ -8090,8 +8094,9 @@ packages: resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} engines: {node: '>=0.6'} - toml@3.0.0: - resolution: {integrity: sha512-y/mWCZinnvxjTKYhJ+pYxwD0mRLVvOtdS2Awbgxln6iEnt4rk0yBxeSBHkGJcPucRiG0e55mwWp+g/05rsrd6w==} + toml@4.2.0: + resolution: {integrity: sha512-TvAJjbHZlYmI323+srtqHQFyJsoWy6mI09ppkuj9+iRsqsVKG9fvTcOP7FHF2UCb0QSYtjEavffrKzdd0XgClg==} + engines: {node: '>=20'} tough-cookie@5.1.2: resolution: {integrity: sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==} @@ -8476,6 +8481,7 @@ 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 @@ -10923,7 +10929,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)(esbuild@0.28.1)(jsdom@26.1.0)(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)(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)) '@testing-library/dom@10.4.1': dependencies: @@ -11437,7 +11443,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)(esbuild@0.28.1)(jsdom@26.1.0)(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)(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/expect@4.1.10': dependencies: @@ -11673,14 +11679,14 @@ snapshots: ajv@8.18.0: dependencies: fast-deep-equal: 3.1.3 - fast-uri: 3.1.5 + fast-uri: 3.1.7 json-schema-traverse: 1.0.0 require-from-string: 2.0.2 ajv@8.20.0: dependencies: fast-deep-equal: 3.1.3 - fast-uri: 3.1.5 + fast-uri: 3.1.7 json-schema-traverse: 1.0.0 require-from-string: 2.0.2 @@ -11892,7 +11898,7 @@ snapshots: http-errors: 2.0.1 iconv-lite: 0.7.3 on-finished: 2.4.1 - qs: 6.15.3 + qs: 6.16.0 raw-body: 3.0.2 type-is: 2.1.0 transitivePeerDependencies: @@ -12712,7 +12718,7 @@ snapshots: once: 1.4.0 parseurl: 1.3.3 proxy-addr: 2.0.7 - qs: 6.15.3 + qs: 6.16.0 range-parser: 1.3.0 router: 2.2.0 send: 1.2.1 @@ -12749,7 +12755,7 @@ snapshots: dependencies: fast-string-truncated-width: 3.0.3 - fast-uri@3.1.5: {} + fast-uri@3.1.7: {} fast-wrap-ansi@0.2.2: dependencies: @@ -15157,7 +15163,7 @@ snapshots: pngjs: 5.0.0 yargs: 15.4.1 - qs@6.15.3: + qs@6.16.0: dependencies: es-define-property: 1.0.1 side-channel: 1.1.1 @@ -15340,7 +15346,7 @@ snapshots: dependencies: '@types/mdast': 4.0.4 estree-util-value-to-estree: 3.5.0 - toml: 3.0.0 + toml: 4.2.0 unified: 11.0.5 unist-util-mdx-define: 1.1.2 yaml: 2.9.0 @@ -15826,7 +15832,7 @@ snapshots: formidable: 3.5.4 methods: 1.1.2 mime: 2.6.0 - qs: 6.15.3 + qs: 6.16.0 transitivePeerDependencies: - supports-color @@ -15951,7 +15957,7 @@ snapshots: toidentifier@1.0.1: {} - toml@3.0.0: {} + toml@4.2.0: {} tough-cookie@5.1.2: dependencies: @@ -16090,7 +16096,7 @@ snapshots: dependencies: des.js: 1.1.0 js-md4: 0.3.2 - qs: 6.15.3 + qs: 6.16.0 tunnel: 0.0.6 underscore: 1.13.8 @@ -16310,7 +16316,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)(esbuild@0.28.1)(jsdom@26.1.0)(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)(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)): 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)) @@ -16337,18 +16343,7 @@ 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: {} @@ -16377,7 +16372,7 @@ snapshots: webidl-conversions@7.0.0: {} - webpack-cli@7.2.2(json5@2.2.3)(toml@3.0.0)(webpack@5.109.2): + webpack-cli@7.2.2(json5@2.2.3)(toml@4.2.0)(webpack@5.109.2): dependencies: '@discoveryjs/json-ext': 1.1.0 commander: 14.0.3 @@ -16390,7 +16385,7 @@ snapshots: webpack-merge: 6.0.1 optionalDependencies: json5: 2.2.3 - toml: 3.0.0 + toml: 4.2.0 webpack-merge@6.0.1: dependencies: @@ -16459,7 +16454,7 @@ snapshots: watchpack: 2.5.2 webpack-sources: 3.5.1 optionalDependencies: - webpack-cli: 7.2.2(json5@2.2.3)(toml@3.0.0)(webpack@5.109.2) + webpack-cli: 7.2.2(json5@2.2.3)(toml@4.2.0)(webpack@5.109.2) transitivePeerDependencies: - '@minify-html/node' - '@swc/core' diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index ef14c1621..d5a93d211 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -36,8 +36,11 @@ overrides: nanoid@<3.3.18: 3.3.18 # Stryker's latest typed-rest-client 2.3.1 pins vulnerable qs 6.15.1 # 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 + # release that depends on qs 6.16.0 or newer. + 'typed-rest-client@2.3.1>qs': 6.16.0 + # The current frontmatter plugin still requests vulnerable toml 3.x. + # toml 4.2 retains parse() and fixes prototype pollution and recursion. + 'remark-mdx-frontmatter@5.2.0>toml': 4.2.0 patchedDependencies: image-size@1.2.1: patches/image-size@1.2.1.patch auditConfig: diff --git a/scripts/dependency-release-governance.test.mjs b/scripts/dependency-release-governance.test.mjs index 261205dd2..614ed16eb 100644 --- a/scripts/dependency-release-governance.test.mjs +++ b/scripts/dependency-release-governance.test.mjs @@ -21,7 +21,7 @@ test('dependency and release governance is internally complete', () => { assert.deepEqual(validateDependencyReleaseGovernance(), []) const overrides = collectOverrides() - assert.equal(overrides.length, 20) + assert.equal(overrides.length, 21) 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 6ac7f254d5ba379cbe47ddf55262e19d6de6cb09 Mon Sep 17 00:00:00 2001 From: Brayden Langley Date: Fri, 4 Sep 2026 15:13:01 -0700 Subject: [PATCH 04/11] Coordinate SDK 3 peer compatibility across first-party packages --- docs/packages/content/lch.md | 10 +- docs/packages/helpers/amountinator.md | 10 +- docs/packages/helpers/did-client.md | 10 +- docs/packages/helpers/did.md | 10 +- docs/packages/helpers/fund-wallet.md | 10 +- docs/packages/helpers/simple.md | 10 +- docs/packages/helpers/templates.md | 10 +- docs/packages/helpers/wallet-helper.md | 10 +- docs/packages/messaging/authsocket-client.md | 10 +- docs/packages/messaging/authsocket.md | 10 +- docs/packages/messaging/message-box-client.md | 10 +- docs/packages/messaging/paymail.md | 10 +- docs/packages/middleware/402-pay.md | 10 +- .../middleware/auth-express-middleware.md | 10 +- docs/packages/middleware/auth.md | 10 +- .../middleware/payment-express-middleware.md | 10 +- docs/packages/network/chirp.md | 10 +- docs/packages/network/teranode-listener.md | 10 +- docs/packages/overlays/gasp.md | 10 +- .../overlays/overlay-discovery-services.md | 10 +- docs/packages/overlays/overlay-express.md | 10 +- docs/packages/overlays/overlay-topics.md | 10 +- docs/packages/overlays/overlay.md | 10 +- docs/packages/sdk/verifast.md | 10 +- .../packages/wallet/btms-permission-module.md | 10 +- docs/packages/wallet/btms.md | 10 +- .../packages/wallet/ecpm-permission-module.md | 10 +- docs/packages/wallet/wallet-relay.md | 10 +- docs/packages/wallet/wallet-toolbox-client.md | 10 +- docs/packages/wallet/wallet-toolbox-mobile.md | 10 +- docs/packages/wallet/wallet-toolbox.md | 10 +- docs/reference/package-api-migrations.md | 196 +++++++++--------- docs/reference/stack-facts.md | 62 +++--- governance/package-release-notes.json | 124 +++++------ governance/repository-health/baselines.json | 62 +++--- packages/content/lch/package.json | 4 +- packages/helpers/amountinator/package.json | 4 +- .../helpers/bsv-wallet-helper/package.json | 4 +- packages/helpers/did-client/package.json | 4 +- packages/helpers/did/package.json | 4 +- packages/helpers/fund-wallet/package.json | 4 +- packages/helpers/simple/package.json | 4 +- packages/helpers/ts-templates/CHANGELOG.md | 4 + packages/helpers/ts-templates/package.json | 4 +- .../messaging/authsocket-client/package.json | 4 +- packages/messaging/authsocket/package.json | 4 +- .../messaging/message-box-client/CHANGELOG.md | 4 + .../messaging/message-box-client/package.json | 4 +- packages/messaging/ts-paymail/CHANGELOG.md | 34 ++- packages/messaging/ts-paymail/package.json | 4 +- packages/middleware/402-pay/CHANGELOG.md | 7 + packages/middleware/402-pay/README.md | 39 ++-- packages/middleware/402-pay/package.json | 4 +- .../auth-express-middleware/package.json | 4 +- packages/middleware/auth/package.json | 4 +- .../payment-express-middleware/package.json | 4 +- packages/network/chirp/package.json | 4 +- packages/network/ts-p2p/CHANGELOG.md | 23 +- packages/network/ts-p2p/package.json | 4 +- packages/overlays/gasp-core/package.json | 4 +- .../overlay-discovery-services/CHANGELOG.md | 10 + .../overlay-discovery-services/package.json | 4 +- .../overlays/overlay-express/CHANGELOG.md | 4 + .../overlays/overlay-express/package.json | 4 +- packages/overlays/overlay/CHANGELOG.md | 31 ++- packages/overlays/overlay/package.json | 4 +- packages/overlays/topics/CHANGELOG.md | 4 + packages/overlays/topics/package.json | 4 +- packages/sdk/CHANGELOG.md | 3 +- .../docs/globalkv-reliability-validation.md | 17 ++ packages/sdk/docs/overlay-lookup-migration.md | 5 +- packages/verifast/package.json | 4 +- .../btms-permission-module/package.json | 4 +- packages/wallet/btms/package.json | 4 +- .../ecpm-permission-module/package.json | 4 +- packages/wallet/ts-wallet-relay/package.json | 4 +- packages/wallet/wallet-toolbox/CHANGELOG.md | 12 +- .../wallet/wallet-toolbox/client/package.json | 4 +- .../wallet/wallet-toolbox/mobile/package.json | 4 +- packages/wallet/wallet-toolbox/package.json | 4 +- scripts/brc100-byte-boundary.test.mjs | 2 +- 81 files changed, 650 insertions(+), 427 deletions(-) diff --git a/docs/packages/content/lch.md b/docs/packages/content/lch.md index b67db7c90..24b9907c9 100644 --- a/docs/packages/content/lch.md +++ b/docs/packages/content/lch.md @@ -4,9 +4,9 @@ title: '@bsv/lch' kind: package domain: content npm: '@bsv/lch' -version: '0.1.0' -last_updated: '2026-08-30' -last_verified: '2026-08-30' +version: '0.1.1' +last_updated: '2026-09-04' +last_verified: '2026-09-04' review_cadence_days: 30 repo: 'https://github.com/bsv-blockchain/ts-stack/tree/main/packages/content/lch' status: experimental @@ -15,6 +15,10 @@ tags: ['content', 'licensing', 'brc-170', 'odrl', 'c2pa', 'chirp', 'uhrp'] # @bsv/lch +This version accepts SDK 3 alongside its existing SDK 2 peer range. Follow the +[SDK 3 migration guide](https://github.com/bsv-blockchain/ts-stack/blob/main/packages/sdk/docs/overlay-lookup-migration.md) +when upgrading the application SDK. + > Browser- and Node-compatible reference implementation of published BRC-170 > Licensed Content Header protocol. diff --git a/docs/packages/helpers/amountinator.md b/docs/packages/helpers/amountinator.md index 280a58608..82c455dc4 100644 --- a/docs/packages/helpers/amountinator.md +++ b/docs/packages/helpers/amountinator.md @@ -3,10 +3,10 @@ id: pkg-amountinator title: '@bsv/amountinator' kind: package domain: helpers -version: '2.1.5' +version: '2.1.6' source_repo: 'bsv-blockchain/ts-stack' -last_updated: '2026-08-26' -last_verified: '2026-08-26' +last_updated: '2026-09-04' +last_verified: '2026-09-04' review_cadence_days: 30 npm: 'https://www.npmjs.com/package/@bsv/amountinator' repo: 'https://github.com/bsv-blockchain/ts-stack/tree/main/packages/helpers/amountinator' @@ -16,6 +16,10 @@ tags: [helpers, amounts, satoshis] # @bsv/amountinator +This version accepts SDK 3 alongside its existing SDK 2 peer range. Follow the +[SDK 3 migration guide](https://github.com/bsv-blockchain/ts-stack/blob/main/packages/sdk/docs/overlay-lookup-migration.md) +when upgrading the application SDK. + > Satoshi/BSV/USD and multi-fiat currency conversion with exchange rate caching and wallet settings integration — convert between crypto (SATS, BSV) and 15+ fiat currencies with auto-refresh. ## Install diff --git a/docs/packages/helpers/did-client.md b/docs/packages/helpers/did-client.md index 8f32538e9..1bc014524 100644 --- a/docs/packages/helpers/did-client.md +++ b/docs/packages/helpers/did-client.md @@ -3,10 +3,10 @@ id: pkg-did-client title: '@bsv/did-client' kind: package domain: helpers -version: '1.3.1' +version: '1.3.2' source_repo: 'bsv-blockchain/ts-stack' -last_updated: '2026-08-26' -last_verified: '2026-08-26' +last_updated: '2026-09-04' +last_verified: '2026-09-04' review_cadence_days: 30 npm: 'https://www.npmjs.com/package/@bsv/did-client' repo: 'https://github.com/bsv-blockchain/ts-stack/tree/main/packages/helpers/did-client' @@ -16,6 +16,10 @@ tags: [did, identity, helpers] # @bsv/did-client +This version accepts SDK 3 alongside its existing SDK 2 peer range. Follow the +[SDK 3 migration guide](https://github.com/bsv-blockchain/ts-stack/blob/main/packages/sdk/docs/overlay-lookup-migration.md) +when upgrading the application SDK. + > DID (Decentralized Identifier) client for BSV blockchain — creates, revokes, and queries `did:bsv:` tokens stored as PushDrop outputs on-chain, with overlay broadcast and lookup service integration. ## Install diff --git a/docs/packages/helpers/did.md b/docs/packages/helpers/did.md index 15518d8b1..11d66cb7f 100644 --- a/docs/packages/helpers/did.md +++ b/docs/packages/helpers/did.md @@ -3,10 +3,10 @@ id: pkg-did title: '@bsv/did' kind: package domain: helpers -version: '0.2.5' +version: '0.2.6' source_repo: 'bsv-blockchain/ts-stack' -last_updated: '2026-08-27' -last_verified: '2026-08-27' +last_updated: '2026-09-04' +last_verified: '2026-09-04' review_cadence_days: 30 npm: 'https://www.npmjs.com/package/@bsv/did' repo: 'https://github.com/bsv-blockchain/ts-stack/tree/main/packages/helpers/did' @@ -16,6 +16,10 @@ tags: [did, sd-jwt, credentials, identity, helpers] # @bsv/did +This version accepts SDK 3 alongside its existing SDK 2 peer range. Follow the +[SDK 3 migration guide](https://github.com/bsv-blockchain/ts-stack/blob/main/packages/sdk/docs/overlay-lookup-migration.md) +when upgrading the application SDK. + > SD-JWT VC and optional `did:key` helpers for BSV SDK identity keys. ## Install diff --git a/docs/packages/helpers/fund-wallet.md b/docs/packages/helpers/fund-wallet.md index 2a542630b..745b799bc 100644 --- a/docs/packages/helpers/fund-wallet.md +++ b/docs/packages/helpers/fund-wallet.md @@ -3,10 +3,10 @@ id: pkg-fund-wallet title: '@bsv/fund-wallet' kind: package domain: helpers -version: '1.4.4' +version: '1.4.5' source_repo: 'bsv-blockchain/ts-stack' -last_updated: '2026-08-26' -last_verified: '2026-08-26' +last_updated: '2026-09-04' +last_verified: '2026-09-04' review_cadence_days: 30 npm: 'https://www.npmjs.com/package/@bsv/fund-wallet' repo: 'https://github.com/bsv-blockchain/ts-stack/tree/main/packages/helpers/fund-wallet' @@ -16,6 +16,10 @@ tags: [helpers, testing, faucet, development] # @bsv/fund-wallet +This version accepts SDK 3 alongside its existing SDK 2 peer range. Follow the +[SDK 3 migration guide](https://github.com/bsv-blockchain/ts-stack/blob/main/packages/sdk/docs/overlay-lookup-migration.md) +when upgrading the application SDK. + > Command-line faucet/funding tool for development and testing — funds a remote wallet with satoshis from a local Metanet Desktop wallet via private key derivation. ## Install diff --git a/docs/packages/helpers/simple.md b/docs/packages/helpers/simple.md index e3c370923..fabbfe091 100644 --- a/docs/packages/helpers/simple.md +++ b/docs/packages/helpers/simple.md @@ -3,10 +3,10 @@ id: pkg-simple title: '@bsv/simple' kind: package domain: helpers -version: '0.5.2' +version: '0.5.3' source_repo: 'bsv-blockchain/ts-stack' -last_updated: '2026-08-26' -last_verified: '2026-08-26' +last_updated: '2026-09-04' +last_verified: '2026-09-04' review_cadence_days: 30 npm: 'https://www.npmjs.com/package/@bsv/simple' repo: 'https://github.com/bsv-blockchain/ts-stack/tree/main/packages/helpers/simple' @@ -16,6 +16,10 @@ tags: [helpers, simple, payments] # @bsv/simple +This version accepts SDK 3 alongside its existing SDK 2 peer range. Follow the +[SDK 3 migration guide](https://github.com/bsv-blockchain/ts-stack/blob/main/packages/sdk/docs/overlay-lookup-migration.md) +when upgrading the application SDK. + > High-level wallet API for browser and server — manage payments, tokens, inscriptions, DIDs, and credentials without wrestling with private keys or transactions. ## Install diff --git a/docs/packages/helpers/templates.md b/docs/packages/helpers/templates.md index 1133ba5c8..49ed7dd7d 100644 --- a/docs/packages/helpers/templates.md +++ b/docs/packages/helpers/templates.md @@ -3,10 +3,10 @@ id: pkg-templates title: '@bsv/templates' kind: package domain: helpers -version: '1.10.1' +version: '1.10.2' source_repo: 'bsv-blockchain/ts-stack' -last_updated: '2026-08-27' -last_verified: '2026-08-27' +last_updated: '2026-09-04' +last_verified: '2026-09-04' review_cadence_days: 30 npm: 'https://www.npmjs.com/package/@bsv/templates' repo: 'https://github.com/bsv-blockchain/ts-stack/tree/main/packages/helpers/ts-templates' @@ -16,6 +16,10 @@ tags: [templates, scripts, locking, unlocking] # @bsv/templates +This version accepts SDK 3 alongside its existing SDK 2 peer range. Follow the +[SDK 3 migration guide](https://github.com/bsv-blockchain/ts-stack/blob/main/packages/sdk/docs/overlay-lookup-migration.md) +when upgrading the application SDK. + > Low-level BSV script templates library — provides reusable locking/unlocking script implementations (OpReturn, MultiPushDrop, P2MSKH) for common and advanced Bitcoin SV patterns without abstracting away control. ## Install diff --git a/docs/packages/helpers/wallet-helper.md b/docs/packages/helpers/wallet-helper.md index 8303eeea1..e8390b8d6 100644 --- a/docs/packages/helpers/wallet-helper.md +++ b/docs/packages/helpers/wallet-helper.md @@ -3,10 +3,10 @@ id: pkg-wallet-helper title: '@bsv/wallet-helper' kind: package domain: helpers -version: '0.1.7' +version: '0.1.8' source_repo: 'bsv-blockchain/ts-stack' -last_updated: '2026-08-27' -last_verified: '2026-08-27' +last_updated: '2026-09-04' +last_verified: '2026-09-04' review_cadence_days: 30 npm: 'https://www.npmjs.com/package/@bsv/wallet-helper' repo: 'https://github.com/bsv-blockchain/ts-stack/tree/main/packages/helpers/bsv-wallet-helper' @@ -16,6 +16,10 @@ tags: [helpers, wallet, transaction-builder] # @bsv/wallet-helper +This version accepts SDK 3 alongside its existing SDK 2 peer range. Follow the +[SDK 3 migration guide](https://github.com/bsv-blockchain/ts-stack/blob/main/packages/sdk/docs/overlay-lookup-migration.md) +when upgrading the application SDK. + > Fluent transaction builder and wallet-compatible script templates for BSV — construct multi-output transactions (P2PKH, ordinals, custom) with method chaining, BRC-29 key derivation, and no private key exposure. `@bsv/wallet-helper` is a good starting point for developers coming from other blockchain ecosystems who expect to build transactions explicitly. It gives you a transaction-builder shape for outputs, scripts, ordinals, metadata, inputs, and explicit change destinations, while still delegating keys and signing to a BRC-100 wallet. diff --git a/docs/packages/messaging/authsocket-client.md b/docs/packages/messaging/authsocket-client.md index 589784019..b4930effc 100644 --- a/docs/packages/messaging/authsocket-client.md +++ b/docs/packages/messaging/authsocket-client.md @@ -3,10 +3,10 @@ id: pkg-authsocket-client title: '@bsv/authsocket-client' kind: package domain: messaging -version: '2.1.6' +version: '2.1.7' source_repo: 'bsv-blockchain/ts-stack' -last_updated: '2026-08-26' -last_verified: '2026-08-26' +last_updated: '2026-09-04' +last_verified: '2026-09-04' review_cadence_days: 30 npm: 'https://www.npmjs.com/package/@bsv/authsocket-client' repo: 'https://github.com/bsv-blockchain/ts-stack/tree/main/packages/messaging/authsocket-client' @@ -16,6 +16,10 @@ tags: [messaging, websocket, brc-31, auth] # @bsv/authsocket-client +This version accepts SDK 3 alongside its existing SDK 2 peer range. Follow the +[SDK 3 migration guide](https://github.com/bsv-blockchain/ts-stack/blob/main/packages/sdk/docs/overlay-lookup-migration.md) +when upgrading the application SDK. + > Client-side BRC-103 mutual authentication wrapper for socket.io-client. Signs all outbound messages and verifies inbound messages using a wallet, enabling authenticated peer-to-peer WebSocket communication. ## Install diff --git a/docs/packages/messaging/authsocket.md b/docs/packages/messaging/authsocket.md index 24a21bfa4..b6f1f1f91 100644 --- a/docs/packages/messaging/authsocket.md +++ b/docs/packages/messaging/authsocket.md @@ -3,10 +3,10 @@ id: pkg-authsocket title: '@bsv/authsocket' kind: package domain: messaging -version: '2.1.7' +version: '2.1.8' source_repo: 'bsv-blockchain/ts-stack' -last_updated: '2026-08-27' -last_verified: '2026-08-27' +last_updated: '2026-09-04' +last_verified: '2026-09-04' review_cadence_days: 30 npm: 'https://www.npmjs.com/package/@bsv/authsocket' repo: 'https://github.com/bsv-blockchain/ts-stack/tree/main/packages/messaging/authsocket' @@ -16,6 +16,10 @@ tags: [messaging, websocket, brc-31, auth] # @bsv/authsocket +This version accepts SDK 3 alongside its existing SDK 2 peer range. Follow the +[SDK 3 migration guide](https://github.com/bsv-blockchain/ts-stack/blob/main/packages/sdk/docs/overlay-lookup-migration.md) +when upgrading the application SDK. + > Server-side BRC-103 mutual authentication wrapper for Socket.IO. Enforces cryptographic signing and verification on all WebSocket messages, enabling peer-to-peer identity verification and certificate exchange. ## Install diff --git a/docs/packages/messaging/message-box-client.md b/docs/packages/messaging/message-box-client.md index 592e07c9c..0c869572e 100644 --- a/docs/packages/messaging/message-box-client.md +++ b/docs/packages/messaging/message-box-client.md @@ -3,10 +3,10 @@ id: pkg-message-box-client title: '@bsv/message-box-client' kind: package domain: messaging -version: '2.4.2' +version: '2.4.3' source_repo: 'bsv-blockchain/ts-stack' -last_updated: '2026-08-26' -last_verified: '2026-08-26' +last_updated: '2026-09-04' +last_verified: '2026-09-04' review_cadence_days: 30 npm: 'https://www.npmjs.com/package/@bsv/message-box-client' repo: 'https://github.com/bsv-blockchain/ts-stack/tree/main/packages/messaging/message-box-client' @@ -16,6 +16,10 @@ tags: [messaging, message-box, brc-103, brc-29] # @bsv/message-box-client +This version accepts SDK 3 alongside its existing SDK 2 peer range. Follow the +[SDK 3 migration guide](https://github.com/bsv-blockchain/ts-stack/blob/main/packages/sdk/docs/overlay-lookup-migration.md) +when upgrading the application SDK. + > Browser- and Node-compatible authenticated store-and-forward messaging, > live WebSockets, peer payments, token settlement, permissions, quotes, and > push-device registration. diff --git a/docs/packages/messaging/paymail.md b/docs/packages/messaging/paymail.md index d7b75d1c6..b94875a16 100644 --- a/docs/packages/messaging/paymail.md +++ b/docs/packages/messaging/paymail.md @@ -3,10 +3,10 @@ id: pkg-paymail title: '@bsv/paymail' kind: package domain: messaging -version: '2.4.7' +version: '2.4.8' source_repo: 'bsv-blockchain/ts-stack' -last_updated: '2026-08-27' -last_verified: '2026-08-27' +last_updated: '2026-09-04' +last_verified: '2026-09-04' review_cadence_days: 30 npm: 'https://www.npmjs.com/package/@bsv/paymail' repo: 'https://github.com/bsv-blockchain/ts-stack/tree/main/packages/messaging/ts-paymail' @@ -16,6 +16,10 @@ tags: [paymail, messaging, brc-29, identity] # @bsv/paymail +This version accepts SDK 3 alongside its existing SDK 2 peer range. Follow the +[SDK 3 migration guide](https://github.com/bsv-blockchain/ts-stack/blob/main/packages/sdk/docs/overlay-lookup-migration.md) +when upgrading the application SDK. + > TypeScript SDK for BSV Paymail (BRC-121 capability discovery and routing). Provides both client-side capability discovery and server-side router with built-in support for PKI, P2P destinations, and public profiles. ## Install diff --git a/docs/packages/middleware/402-pay.md b/docs/packages/middleware/402-pay.md index 9131ffffd..72d7e5ec6 100644 --- a/docs/packages/middleware/402-pay.md +++ b/docs/packages/middleware/402-pay.md @@ -3,10 +3,10 @@ id: pkg-402-pay title: '@bsv/402-pay' kind: package domain: middleware -version: '0.2.5' +version: '0.2.6' source_repo: 'bsv-blockchain/ts-stack' -last_updated: '2026-08-27' -last_verified: '2026-08-27' +last_updated: '2026-09-04' +last_verified: '2026-09-04' review_cadence_days: 30 npm: 'https://www.npmjs.com/package/@bsv/402-pay' repo: 'https://github.com/bsv-blockchain/ts-stack/tree/main/packages/middleware/402-pay' @@ -16,6 +16,10 @@ tags: [middleware, payment, '402', client] # @bsv/402-pay +This version accepts SDK 3 alongside its existing SDK 2 peer range. Follow the +[SDK 3 migration guide](https://github.com/bsv-blockchain/ts-stack/blob/main/packages/sdk/docs/overlay-lookup-migration.md) +when upgrading the application SDK. + > BRC-121 HTTP 402 Payment Required handler for client and server. Client-side: auto-pays 402 responses. Server-side: middleware/validation for accepting BSV micropayments over HTTP. ## Install diff --git a/docs/packages/middleware/auth-express-middleware.md b/docs/packages/middleware/auth-express-middleware.md index 60aab1dbe..d6e8312c0 100644 --- a/docs/packages/middleware/auth-express-middleware.md +++ b/docs/packages/middleware/auth-express-middleware.md @@ -3,10 +3,10 @@ id: pkg-auth-express-middleware title: '@bsv/auth-express-middleware' kind: package domain: middleware -version: '2.2.3' +version: '2.2.4' source_repo: 'bsv-blockchain/ts-stack' -last_updated: '2026-08-27' -last_verified: '2026-08-27' +last_updated: '2026-09-04' +last_verified: '2026-09-04' review_cadence_days: 30 npm: 'https://www.npmjs.com/package/@bsv/auth-express-middleware' repo: 'https://github.com/bsv-blockchain/ts-stack/tree/main/packages/middleware/auth-express-middleware' @@ -16,6 +16,10 @@ tags: [middleware, express, auth, brc-103, brc-104] # @bsv/auth-express-middleware +This version accepts SDK 3 alongside its existing SDK 2 peer range. Follow the +[SDK 3 migration guide](https://github.com/bsv-blockchain/ts-stack/blob/main/packages/sdk/docs/overlay-lookup-migration.md) +when upgrading the application SDK. + Express transport for BRC-103 peer-to-peer mutual authentication over BRC-104 HTTP. It handles the public handshake, verifies authenticated application requests, signs responses, and optionally exchanges verifiable diff --git a/docs/packages/middleware/auth.md b/docs/packages/middleware/auth.md index f596fe7af..3ad4d1868 100644 --- a/docs/packages/middleware/auth.md +++ b/docs/packages/middleware/auth.md @@ -3,9 +3,9 @@ id: pkg-auth title: '@bsv/auth' kind: package domain: middleware -version: '0.1.4' -last_updated: '2026-08-27' -last_verified: '2026-08-27' +version: '0.1.5' +last_updated: '2026-09-04' +last_verified: '2026-09-04' review_cadence_days: 30 npm: 'https://www.npmjs.com/package/@bsv/auth' repo: 'https://github.com/bsv-blockchain/ts-stack/tree/main/packages/middleware/auth' @@ -15,6 +15,10 @@ tags: [middleware, authentication, wallet, replay] # @bsv/auth +This version accepts SDK 3 alongside its existing SDK 2 peer range. Follow the +[SDK 3 migration guide](https://github.com/bsv-blockchain/ts-stack/blob/main/packages/sdk/docs/overlay-lookup-migration.md) +when upgrading the application SDK. + `@bsv/auth` provides framework-neutral, expiry-bound, single-use wallet authentication proofs. It separates proof creation and verification from HTTP, Express, and database choices. diff --git a/docs/packages/middleware/payment-express-middleware.md b/docs/packages/middleware/payment-express-middleware.md index 6ec6caffd..4a98e3c03 100644 --- a/docs/packages/middleware/payment-express-middleware.md +++ b/docs/packages/middleware/payment-express-middleware.md @@ -3,10 +3,10 @@ id: pkg-payment-express-middleware title: '@bsv/payment-express-middleware' kind: package domain: middleware -version: '2.1.6' +version: '2.1.7' source_repo: 'bsv-blockchain/ts-stack' -last_updated: '2026-08-27' -last_verified: '2026-08-27' +last_updated: '2026-09-04' +last_verified: '2026-09-04' review_cadence_days: 30 npm: 'https://www.npmjs.com/package/@bsv/payment-express-middleware' repo: 'https://github.com/bsv-blockchain/ts-stack/tree/main/packages/middleware/payment-express-middleware' @@ -16,6 +16,10 @@ tags: [middleware, express, payment, '402', brc-29] # @bsv/payment-express-middleware +This version accepts SDK 3 alongside its existing SDK 2 peer range. Follow the +[SDK 3 migration guide](https://github.com/bsv-blockchain/ts-stack/blob/main/packages/sdk/docs/overlay-lookup-migration.md) +when upgrading the application SDK. + Express middleware for the legacy authenticated `x-bsv-payment` JSON flow. It runs after `@bsv/auth-express-middleware`, validates an Atomic BEEF payment, atomically rejects transaction-ID reuse, internalizes output zero, and exposes diff --git a/docs/packages/network/chirp.md b/docs/packages/network/chirp.md index 5f34b63a7..aea51c1a4 100644 --- a/docs/packages/network/chirp.md +++ b/docs/packages/network/chirp.md @@ -4,9 +4,9 @@ title: '@bsv/chirp' kind: package domain: network npm: '@bsv/chirp' -version: '0.1.1' -last_updated: '2026-08-28' -last_verified: '2026-08-28' +version: '0.1.2' +last_updated: '2026-09-04' +last_verified: '2026-09-04' review_cadence_days: 30 repo: 'https://github.com/bsv-blockchain/ts-stack/tree/main/packages/network/chirp' status: experimental @@ -15,6 +15,10 @@ tags: ['network', 'storage', 'uhrp', 'merkle', 'brc-167'] # @bsv/chirp +This version accepts SDK 3 alongside its existing SDK 2 peer range. Follow the +[SDK 3 migration guide](https://github.com/bsv-blockchain/ts-stack/blob/main/packages/sdk/docs/overlay-lookup-migration.md) +when upgrading the application SDK. + > Browser- and Node-compatible BRC-167 reference implementation for progressively publishing and resiliently resolving large UHRP-addressed byte streams. ## Install diff --git a/docs/packages/network/teranode-listener.md b/docs/packages/network/teranode-listener.md index dceff24c7..c9719a24a 100644 --- a/docs/packages/network/teranode-listener.md +++ b/docs/packages/network/teranode-listener.md @@ -4,9 +4,9 @@ title: '@bsv/teranode-listener' kind: package domain: network npm: '@bsv/teranode-listener' -version: '1.1.5' -last_updated: '2026-08-26' -last_verified: '2026-08-26' +version: '1.1.6' +last_updated: '2026-09-04' +last_verified: '2026-09-04' review_cadence_days: 30 repo: 'https://github.com/bsv-blockchain/ts-stack/tree/main/packages/network/ts-p2p' status: stable @@ -15,6 +15,10 @@ tags: ['network', 'broadcast', 'teranode', 'p2p', 'libp2p'] # @bsv/teranode-listener +This version accepts SDK 3 alongside its existing SDK 2 peer range. Follow the +[SDK 3 migration guide](https://github.com/bsv-blockchain/ts-stack/blob/main/packages/sdk/docs/overlay-lookup-migration.md) +when upgrading the application SDK. + > TypeScript library for subscribing to Teranode P2P topics (blocks, subtrees, mining updates) via libp2p private DHT network. ## Install diff --git a/docs/packages/overlays/gasp.md b/docs/packages/overlays/gasp.md index 3c3feba5c..0c7b2c7a5 100644 --- a/docs/packages/overlays/gasp.md +++ b/docs/packages/overlays/gasp.md @@ -3,10 +3,10 @@ id: pkg-gasp title: '@bsv/gasp' kind: package domain: overlays -version: '1.3.6' +version: '1.3.7' source_repo: 'bsv-blockchain/ts-stack' -last_updated: '2026-08-27' -last_verified: '2026-08-27' +last_updated: '2026-09-04' +last_verified: '2026-09-04' review_cadence_days: 30 npm: 'https://www.npmjs.com/package/@bsv/gasp' repo: 'https://github.com/bsv-blockchain/ts-stack/tree/main/packages/overlays/gasp-core' @@ -16,6 +16,10 @@ tags: [overlay, sync, gasp, graph] # @bsv/gasp +This version accepts SDK 3 alongside its existing SDK 2 peer range. Follow the +[SDK 3 migration guide](https://github.com/bsv-blockchain/ts-stack/blob/main/packages/sdk/docs/overlay-lookup-migration.md) +when upgrading the application SDK. + > Graph Aware Sync Protocol — synchronize transaction graphs between overlay nodes with incremental building, SPV validation, and bandwidth efficiency. ## Install diff --git a/docs/packages/overlays/overlay-discovery-services.md b/docs/packages/overlays/overlay-discovery-services.md index 8400631f9..72be01234 100644 --- a/docs/packages/overlays/overlay-discovery-services.md +++ b/docs/packages/overlays/overlay-discovery-services.md @@ -4,9 +4,9 @@ title: '@bsv/overlay-discovery-services' kind: package domain: overlays npm: '@bsv/overlay-discovery-services' -version: '2.2.1' -last_updated: '2026-08-27' -last_verified: '2026-08-27' +version: '2.2.2' +last_updated: '2026-09-04' +last_verified: '2026-09-04' review_cadence_days: 30 repo: 'https://github.com/bsv-blockchain/ts-stack/tree/main/packages/overlays/overlay-discovery-services' status: stable @@ -15,6 +15,10 @@ tags: ['overlay', 'discovery'] # @bsv/overlay-discovery-services +This version accepts SDK 3 alongside its existing SDK 2 peer range. Follow the +[SDK 3 migration guide](https://github.com/bsv-blockchain/ts-stack/blob/main/packages/sdk/docs/overlay-lookup-migration.md) +when upgrading the application SDK. + > Implements SHIP and SLAP protocols for peer discovery and service advertisement in overlay networks. ## Install diff --git a/docs/packages/overlays/overlay-express.md b/docs/packages/overlays/overlay-express.md index 2c4880412..e25b32cae 100644 --- a/docs/packages/overlays/overlay-express.md +++ b/docs/packages/overlays/overlay-express.md @@ -4,9 +4,9 @@ title: '@bsv/overlay-express' kind: package domain: overlays npm: '@bsv/overlay-express' -version: '2.6.1' -last_updated: '2026-08-27' -last_verified: '2026-08-27' +version: '2.6.2' +last_updated: '2026-09-04' +last_verified: '2026-09-04' review_cadence_days: 30 repo: 'https://github.com/bsv-blockchain/ts-stack/tree/main/packages/overlays/overlay-express' status: stable @@ -15,6 +15,10 @@ tags: ['overlay', 'express', 'http'] # @bsv/overlay-express +This version accepts SDK 3 alongside its existing SDK 2 peer range. Follow the +[SDK 3 migration guide](https://github.com/bsv-blockchain/ts-stack/blob/main/packages/sdk/docs/overlay-lookup-migration.md) +when upgrading the application SDK. + > Opinionated Express.js HTTP server wrapper for @bsv/overlay with built-in configuration, health checks, and peer discovery. ## Install diff --git a/docs/packages/overlays/overlay-topics.md b/docs/packages/overlays/overlay-topics.md index 691a86814..803065dbc 100644 --- a/docs/packages/overlays/overlay-topics.md +++ b/docs/packages/overlays/overlay-topics.md @@ -4,9 +4,9 @@ title: '@bsv/overlay-topics' kind: package domain: overlays npm: '@bsv/overlay-topics' -version: '1.7.1' -last_updated: '2026-08-27' -last_verified: '2026-08-27' +version: '1.7.2' +last_updated: '2026-09-04' +last_verified: '2026-09-04' review_cadence_days: 30 repo: 'https://github.com/bsv-blockchain/ts-stack/tree/main/packages/overlays/topics' status: stable @@ -15,6 +15,10 @@ tags: ['overlay', 'topics', 'uhrp'] # @bsv/overlay-topics +This version accepts SDK 3 alongside its existing SDK 2 peer range. Follow the +[SDK 3 migration guide](https://github.com/bsv-blockchain/ts-stack/blob/main/packages/sdk/docs/overlay-lookup-migration.md) +when upgrading the application SDK. + > Canonical collection of pre-built BSV overlay topic managers and lookup services for identity, tokens, supply chain, messaging, and more. ## Install diff --git a/docs/packages/overlays/overlay.md b/docs/packages/overlays/overlay.md index 2a4492822..d8042a9c9 100644 --- a/docs/packages/overlays/overlay.md +++ b/docs/packages/overlays/overlay.md @@ -4,9 +4,9 @@ title: '@bsv/overlay' kind: package domain: overlays npm: '@bsv/overlay' -version: '2.3.1' -last_updated: '2026-08-27' -last_verified: '2026-08-27' +version: '2.3.2' +last_updated: '2026-09-04' +last_verified: '2026-09-04' review_cadence_days: 30 repo: 'https://github.com/bsv-blockchain/ts-stack/tree/main/packages/overlays/overlay' status: stable @@ -15,6 +15,10 @@ tags: ['overlay', 'framework'] # @bsv/overlay +This version accepts SDK 3 alongside its existing SDK 2 peer range. Follow the +[SDK 3 migration guide](https://github.com/bsv-blockchain/ts-stack/blob/main/packages/sdk/docs/overlay-lookup-migration.md) +when upgrading the application SDK. + > Core library defining the Overlay Services Engine for UTXO-based systems on BSV. ## Install diff --git a/docs/packages/sdk/verifast.md b/docs/packages/sdk/verifast.md index ed959f6b9..fcced58e5 100644 --- a/docs/packages/sdk/verifast.md +++ b/docs/packages/sdk/verifast.md @@ -3,9 +3,9 @@ id: pkg-verifast title: '@bsv/verifast' kind: package domain: sdk -version: '0.3.5' -last_updated: '2026-08-26' -last_verified: '2026-08-26' +version: '0.3.6' +last_updated: '2026-09-04' +last_verified: '2026-09-04' review_cadence_days: 30 npm: 'https://www.npmjs.com/package/@bsv/verifast' repo: 'https://github.com/bsv-blockchain/ts-stack/tree/main/packages/verifast' @@ -15,6 +15,10 @@ tags: [sdk, verification, wasm, browser, worker] # @bsv/verifast +This version accepts SDK 3 alongside its existing SDK 2 peer range. Follow the +[SDK 3 migration guide](https://github.com/bsv-blockchain/ts-stack/blob/main/packages/sdk/docs/overlay-lookup-migration.md) +when upgrading the application SDK. + `@bsv/verifast` is the optional BSV BDK WebAssembly verification backend for `@bsv/sdk`. It supports Node ESM and CommonJS, browser and worker ESM, and a classic-script/UMD integration. diff --git a/docs/packages/wallet/btms-permission-module.md b/docs/packages/wallet/btms-permission-module.md index b514ec7b5..c38fe10b8 100644 --- a/docs/packages/wallet/btms-permission-module.md +++ b/docs/packages/wallet/btms-permission-module.md @@ -4,9 +4,9 @@ title: '@bsv/btms-permission-module' kind: package domain: wallet npm: '@bsv/btms-permission-module' -version: '1.1.4' -last_updated: '2026-08-27' -last_verified: '2026-08-27' +version: '1.1.5' +last_updated: '2026-09-04' +last_verified: '2026-09-04' review_cadence_days: 30 status: stable tags: ['tokens', 'permissions'] @@ -15,6 +15,10 @@ repo: 'https://github.com/bsv-blockchain/ts-stack/tree/main/packages/wallet/btms # @bsv/btms-permission-module +This version accepts SDK 3 alongside its existing SDK 2 peer range. Follow the +[SDK 3 migration guide](https://github.com/bsv-blockchain/ts-stack/blob/main/packages/sdk/docs/overlay-lookup-migration.md) +when upgrading the application SDK. + The core permission module for BTMS token operations. Framework-agnostic with no UI dependencies. BSV Desktop and BSV Browser show a spend authorization modal whenever BSV is spent. For a BTMS token output the raw satoshi value is typically 1 sat — not the token's actual denomination. This module gives the wallet enough context to show the correct value to the user and gate authorization appropriately. A USD stablecoin issuer, for example, ships a module that causes the modal to display "$1.00 USD" rather than "1 satoshi." diff --git a/docs/packages/wallet/btms.md b/docs/packages/wallet/btms.md index ffa812020..7f5f55c65 100644 --- a/docs/packages/wallet/btms.md +++ b/docs/packages/wallet/btms.md @@ -4,9 +4,9 @@ title: '@bsv/btms' kind: package domain: wallet npm: '@bsv/btms' -version: '1.2.2' -last_updated: '2026-08-27' -last_verified: '2026-08-27' +version: '1.2.3' +last_updated: '2026-09-04' +last_verified: '2026-09-04' review_cadence_days: 30 status: stable tags: ['tokens', 'protocol'] @@ -15,6 +15,10 @@ repo: 'https://github.com/bsv-blockchain/ts-stack/tree/main/packages/wallet/btms # @bsv/btms +This version accepts SDK 3 alongside its existing SDK 2 peer range. Follow the +[SDK 3 migration guide](https://github.com/bsv-blockchain/ts-stack/blob/main/packages/sdk/docs/overlay-lookup-migration.md) +when upgrading the application SDK. + BTMS — Basic Token Management System — is a modular library for issuing, sending, receiving, and burning UTXO-based tokens on the BSV blockchain. ## The wallet context diff --git a/docs/packages/wallet/ecpm-permission-module.md b/docs/packages/wallet/ecpm-permission-module.md index 9eca197c7..fc3bd4b0c 100644 --- a/docs/packages/wallet/ecpm-permission-module.md +++ b/docs/packages/wallet/ecpm-permission-module.md @@ -4,9 +4,9 @@ title: '@bsv/ecpm-permission-module' kind: package domain: wallet npm: '@bsv/ecpm-permission-module' -version: '0.1.0' -last_updated: '2026-08-30' -last_verified: '2026-08-30' +version: '0.1.1' +last_updated: '2026-09-04' +last_verified: '2026-09-04' review_cadence_days: 30 status: experimental tags: ['permissions', 'brc98', 'ecpm', 'cryptography'] @@ -15,6 +15,10 @@ repo: 'https://github.com/bsv-blockchain/ts-stack/tree/main/packages/wallet/ecpm # @bsv/ecpm-permission-module +This version accepts SDK 3 alongside its existing SDK 2 peer range. Follow the +[SDK 3 migration guide](https://github.com/bsv-blockchain/ts-stack/blob/main/packages/sdk/docs/overlay-lookup-migration.md) +when upgrading the application SDK. + `@bsv/ecpm-permission-module` is the reference `p ecpm` semantic module for BRC-100 wallet hosts. It applies or removes a wallet-derived scalar from an arbitrary validated secp256k1 point while retaining the standard diff --git a/docs/packages/wallet/wallet-relay.md b/docs/packages/wallet/wallet-relay.md index 10e03d9e6..b521e7164 100644 --- a/docs/packages/wallet/wallet-relay.md +++ b/docs/packages/wallet/wallet-relay.md @@ -4,9 +4,9 @@ title: '@bsv/wallet-relay' kind: package domain: wallet npm: '@bsv/wallet-relay' -version: '0.3.6' -last_updated: '2026-08-27' -last_verified: '2026-08-27' +version: '0.3.7' +last_updated: '2026-09-04' +last_verified: '2026-09-04' review_cadence_days: 30 status: stable tags: ['wallet', 'relay'] @@ -15,6 +15,10 @@ repo: 'https://github.com/bsv-blockchain/ts-stack/tree/main/packages/wallet/ts-w # @bsv/wallet-relay +This version accepts SDK 3 alongside its existing SDK 2 peer range. Follow the +[SDK 3 migration guide](https://github.com/bsv-blockchain/ts-stack/blob/main/packages/sdk/docs/overlay-lookup-migration.md) +when upgrading the application SDK. + Enables mobile-to-desktop wallet pairing via QR codes and encrypted WebSocket tunnels. A web app (desktop browser) shows a QR code; user scans with their mobile BSV wallet; all wallet operations (signing, key retrieval, etc.) are proxied over HTTPS+WSS relay servers to the mobile without exposing keys or trust chains to the desktop. Provides both the relay server infrastructure (Node.js) and React frontend components for web apps to add "Connect Mobile Wallet" functionality. ## Install diff --git a/docs/packages/wallet/wallet-toolbox-client.md b/docs/packages/wallet/wallet-toolbox-client.md index d5496eb82..d7a1358a7 100644 --- a/docs/packages/wallet/wallet-toolbox-client.md +++ b/docs/packages/wallet/wallet-toolbox-client.md @@ -3,9 +3,9 @@ id: pkg-wallet-toolbox-client title: '@bsv/wallet-toolbox-client' kind: package domain: wallet -version: '2.11.0' -last_updated: '2026-08-31' -last_verified: '2026-08-31' +version: '2.11.1' +last_updated: '2026-09-04' +last_verified: '2026-09-04' review_cadence_days: 30 npm: 'https://www.npmjs.com/package/@bsv/wallet-toolbox-client' repo: 'https://github.com/bsv-blockchain/ts-stack/tree/main/packages/wallet/wallet-toolbox/client' @@ -15,6 +15,10 @@ tags: [wallet, browser, indexeddb, storage, brc-100] # @bsv/wallet-toolbox-client +This version accepts SDK 3 alongside its existing SDK 2 peer range. Follow the +[SDK 3 migration guide](https://github.com/bsv-blockchain/ts-stack/blob/main/packages/sdk/docs/overlay-lookup-migration.md) +when upgrading the application SDK. + `@bsv/wallet-toolbox-client` is the browser-safe Wallet Toolbox distribution. It includes the BRC-100 wallet, signer, services, IndexedDB storage, and remote storage client without Node-only Knex, SQLite, MySQL, or filesystem adapters. diff --git a/docs/packages/wallet/wallet-toolbox-mobile.md b/docs/packages/wallet/wallet-toolbox-mobile.md index beb53276c..0fed88e06 100644 --- a/docs/packages/wallet/wallet-toolbox-mobile.md +++ b/docs/packages/wallet/wallet-toolbox-mobile.md @@ -3,9 +3,9 @@ id: pkg-wallet-toolbox-mobile title: '@bsv/wallet-toolbox-mobile' kind: package domain: wallet -version: '2.11.0' -last_updated: '2026-08-31' -last_verified: '2026-08-31' +version: '2.11.1' +last_updated: '2026-09-04' +last_verified: '2026-09-04' review_cadence_days: 30 npm: 'https://www.npmjs.com/package/@bsv/wallet-toolbox-mobile' repo: 'https://github.com/bsv-blockchain/ts-stack/tree/main/packages/wallet/wallet-toolbox/mobile' @@ -15,6 +15,10 @@ tags: [wallet, react-native, mobile, storage, brc-100] # @bsv/wallet-toolbox-mobile +This version accepts SDK 3 alongside its existing SDK 2 peer range. Follow the +[SDK 3 migration guide](https://github.com/bsv-blockchain/ts-stack/blob/main/packages/sdk/docs/overlay-lookup-migration.md) +when upgrading the application SDK. + `@bsv/wallet-toolbox-mobile` is the React Native and mobile-safe Wallet Toolbox distribution. It includes wallet, signer, services, monitoring, and remote storage surfaces without Knex, SQLite/MySQL, IndexedDB, or Node-only IO. diff --git a/docs/packages/wallet/wallet-toolbox.md b/docs/packages/wallet/wallet-toolbox.md index 5f5487c55..a570ce092 100644 --- a/docs/packages/wallet/wallet-toolbox.md +++ b/docs/packages/wallet/wallet-toolbox.md @@ -4,9 +4,9 @@ title: '@bsv/wallet-toolbox' kind: package domain: wallet npm: '@bsv/wallet-toolbox' -version: '2.11.0' -last_updated: '2026-08-31' -last_verified: '2026-08-31' +version: '2.11.1' +last_updated: '2026-09-04' +last_verified: '2026-09-04' review_cadence_days: 30 status: stable tags: ['wallet', 'brc100'] @@ -15,6 +15,10 @@ repo: 'https://github.com/bsv-blockchain/ts-stack/tree/main/packages/wallet/wall # @bsv/wallet-toolbox +This version accepts SDK 3 alongside its existing SDK 2 peer range. Follow the +[SDK 3 migration guide](https://github.com/bsv-blockchain/ts-stack/blob/main/packages/sdk/docs/overlay-lookup-migration.md) +when upgrading the application SDK. + `@bsv/wallet-toolbox` is the reference toolkit for building BRC-100 wallets. It connects `@bsv/sdk` primitives to wallet storage, key derivation, signing, services, monitoring, permissions, and authentication flows. Use this package when you are building a wallet product, a wallet-like service, or another implementation that must match BRC-100 behavior. diff --git a/docs/reference/package-api-migrations.md b/docs/reference/package-api-migrations.md index e6c1c01b5..5fad1f29e 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.4.2` | patch | [API and usage](../packages/messaging/message-box-client.md) | No API migration is required. Upgrade @bsv/sdk and @bsv/message-box-client together; historical number-array wallets, current Uint8Array substrates, and already-pending numeric-key messages interoperate through the same portable transaction form. 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` | `3.0.0` | major | [API and usage](../packages/sdk/bsv-sdk.md) | SDK 3.0.0 changes default lookup behavior. Standard query() now throws LookupUnavailableError for incomplete empty aggregates. queryDetailed()/query$() expose transport status and discoveryComplete; complete does not prove authority or freshness. All standard resolver calls use a 5-second total budget, 2-second host budgets, fresh discovery, 32 candidate hosts and 256 outputs per host; streamed responses are limited to 4 MiB. Legacy reputation records are ignored automatically; atomic v4 storage is configurable. Review packages/sdk/docs/overlay-lookup-migration.md, first-party peer compatibility and service-specific validation before any coordinated migration. The optional KV path additionally requires a trusted chain tracker and authority policy. Existing byte encodings and incorporated-code notice requirements remain unchanged. | -| `@bsv/simple` | `0.4.1` | `0.5.2` | 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. 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, and permission modules require no migration. To use BRC-177, migrate every active Knex store before serving requests and run the default Wallet Toolbox monitor; IndexedDB upgrades automatically to schema version 5. Upgrade signer, active storage service, and remote monitor together to 2.11.0 or later; older remote storage is rejected before prefunding. 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. Canonical AtomicBEEF and number-array behavior are unchanged; use @bsv/sdk 2.4.2 or later, use docs/storage.md instead of the removed JSight export, and retain THIRD_PARTY_NOTICES.md and LICENSES/. | -| `@bsv/wallet-toolbox-client` | `2.10.4` | `2.11.0` | minor | [API and usage](../packages/wallet/wallet-toolbox-client.md) | Existing browser actions and permission modules require no migration; IndexedDB upgrades automatically. To use BRC-177 with remote storage, upgrade the active storage service and its default monitor to Wallet Toolbox 2.11.0 or later before upgrading clients; an older server is rejected before prefunding. 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. Browser exports, wire types, canonical AtomicBEEF behavior, and pagination contracts are unchanged; use @bsv/sdk 2.4.2 or later and retain THIRD_PARTY_NOTICES.md and LICENSES/. | -| `@bsv/wallet-toolbox-mobile` | `2.10.4` | `2.11.0` | minor | [API and usage](../packages/wallet/wallet-toolbox-mobile.md) | Existing mobile actions and permission modules require no migration. To use BRC-177, migrate and upgrade the active remote storage service and its default monitor to Wallet Toolbox 2.11.0 or later before upgrading clients; an older server is rejected before prefunding. 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. 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.6` | 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. SDK 3 is supported alongside the existing SDK 2 peer range. Applications adopting SDK 3 must follow packages/sdk/docs/overlay-lookup-migration.md for changed lookup failure, deadline and persistence behavior; existing SDK 2 installations remain supported. | +| `@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.6` | patch | [API and usage](../packages/helpers/amountinator.md) | No consumer migration is required; this is a backward-compatible patch candidate. SDK 3 is supported alongside the existing SDK 2 peer range. Applications adopting SDK 3 must follow packages/sdk/docs/overlay-lookup-migration.md for changed lookup failure, deadline and persistence behavior; existing SDK 2 installations remain supported. | +| `@bsv/auth` | `0.1.1` | `0.1.5` | patch | [API and usage](../packages/middleware/auth.md) | No consumer migration is required; authentication APIs, wire behavior, and runtime defaults are unchanged. SDK 3 is supported alongside the existing SDK 2 peer range. Applications adopting SDK 3 must follow packages/sdk/docs/overlay-lookup-migration.md for changed lookup failure, deadline and persistence behavior; existing SDK 2 installations remain supported. | +| `@bsv/auth-express-middleware` | `2.2.0` | `2.2.4` | 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. SDK 3 is supported alongside the existing SDK 2 peer range. Applications adopting SDK 3 must follow packages/sdk/docs/overlay-lookup-migration.md for changed lookup failure, deadline and persistence behavior; existing SDK 2 installations remain supported. | +| `@bsv/authsocket` | `2.1.1` | `2.1.8` | 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. SDK 3 is supported alongside the existing SDK 2 peer range. Applications adopting SDK 3 must follow packages/sdk/docs/overlay-lookup-migration.md for changed lookup failure, deadline and persistence behavior; existing SDK 2 installations remain supported. | +| `@bsv/authsocket-client` | `2.1.1` | `2.1.7` | 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. SDK 3 is supported alongside the existing SDK 2 peer range. Applications adopting SDK 3 must follow packages/sdk/docs/overlay-lookup-migration.md for changed lookup failure, deadline and persistence behavior; existing SDK 2 installations remain supported. | +| `@bsv/btms` | `1.1.1` | `1.2.3` | 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. SDK 3 is supported alongside the existing SDK 2 peer range. Applications adopting SDK 3 must follow packages/sdk/docs/overlay-lookup-migration.md for changed lookup failure, deadline and persistence behavior; existing SDK 2 installations remain supported. | +| `@bsv/btms-permission-module` | `1.1.1` | `1.1.5` | patch | [API and usage](../packages/wallet/btms-permission-module.md) | No consumer migration is required; permission-module APIs and token semantics are unchanged. SDK 3 is supported alongside the existing SDK 2 peer range. Applications adopting SDK 3 must follow packages/sdk/docs/overlay-lookup-migration.md for changed lookup failure, deadline and persistence behavior; existing SDK 2 installations remain supported. | +| `@bsv/chirp` | `0.0.0` | `0.1.2` | 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. SDK 3 is supported alongside the existing SDK 2 peer range. Applications adopting SDK 3 must follow packages/sdk/docs/overlay-lookup-migration.md for changed lookup failure, deadline and persistence behavior; existing SDK 2 installations remain supported. | +| `@bsv/did` | `0.2.1` | `0.2.6` | patch | [API and usage](../packages/helpers/did.md) | No consumer migration is required; DID APIs, encodings, credential behavior, and supported import forms are unchanged. SDK 3 is supported alongside the existing SDK 2 peer range. Applications adopting SDK 3 must follow packages/sdk/docs/overlay-lookup-migration.md for changed lookup failure, deadline and persistence behavior; existing SDK 2 installations remain supported. | +| `@bsv/did-client` | `1.2.1` | `1.3.2` | 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. SDK 3 is supported alongside the existing SDK 2 peer range. Applications adopting SDK 3 must follow packages/sdk/docs/overlay-lookup-migration.md for changed lookup failure, deadline and persistence behavior; existing SDK 2 installations remain supported. | +| `@bsv/ecpm-permission-module` | `0.0.0` | `0.1.1` | 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. SDK 3 is supported alongside the existing SDK 2 peer range. Applications adopting SDK 3 must follow packages/sdk/docs/overlay-lookup-migration.md for changed lookup failure, deadline and persistence behavior; existing SDK 2 installations remain supported. | +| `@bsv/fund-wallet` | `1.4.1` | `1.4.5` | 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. SDK 3 is supported alongside the existing SDK 2 peer range. Applications adopting SDK 3 must follow packages/sdk/docs/overlay-lookup-migration.md for changed lookup failure, deadline and persistence behavior; existing SDK 2 installations remain supported. | +| `@bsv/gasp` | `1.3.1` | `1.3.7` | 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. SDK 3 is supported alongside the existing SDK 2 peer range. Applications adopting SDK 3 must follow packages/sdk/docs/overlay-lookup-migration.md for changed lookup failure, deadline and persistence behavior; existing SDK 2 installations remain supported. | +| `@bsv/lch` | `0.0.0` | `0.1.1` | 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. SDK 3 is supported alongside the existing SDK 2 peer range. Applications adopting SDK 3 must follow packages/sdk/docs/overlay-lookup-migration.md for changed lookup failure, deadline and persistence behavior; existing SDK 2 installations remain supported. | +| `@bsv/message-box-client` | `2.4.0` | `2.4.3` | patch | [API and usage](../packages/messaging/message-box-client.md) | No API migration is required. Upgrade @bsv/sdk and @bsv/message-box-client together; historical number-array wallets, current Uint8Array substrates, and already-pending numeric-key messages interoperate through the same portable transaction form. Distributors who copy the UMD file must keep THIRD_PARTY_NOTICES.md and LICENSES/ with it. SDK 3 is supported alongside the existing SDK 2 peer range. Applications adopting SDK 3 must follow packages/sdk/docs/overlay-lookup-migration.md for changed lookup failure, deadline and persistence behavior; existing SDK 2 installations remain supported. | +| `@bsv/overlay` | `2.2.1` | `2.3.2` | 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. SDK 3 is supported alongside the existing SDK 2 peer range. Applications adopting SDK 3 must follow packages/sdk/docs/overlay-lookup-migration.md for changed lookup failure, deadline and persistence behavior; existing SDK 2 installations remain supported. | +| `@bsv/overlay-discovery-services` | `2.1.1` | `2.2.2` | 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. SDK 3 is supported alongside the existing SDK 2 peer range. Applications adopting SDK 3 must follow packages/sdk/docs/overlay-lookup-migration.md for changed lookup failure, deadline and persistence behavior; existing SDK 2 installations remain supported. | +| `@bsv/overlay-express` | `2.5.0` | `2.6.2` | 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. SDK 3 is supported alongside the existing SDK 2 peer range. Applications adopting SDK 3 must follow packages/sdk/docs/overlay-lookup-migration.md for changed lookup failure, deadline and persistence behavior; existing SDK 2 installations remain supported. | +| `@bsv/overlay-topics` | `1.6.10` | `1.7.2` | 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. SDK 3 is supported alongside the existing SDK 2 peer range. Applications adopting SDK 3 must follow packages/sdk/docs/overlay-lookup-migration.md for changed lookup failure, deadline and persistence behavior; existing SDK 2 installations remain supported. | +| `@bsv/paymail` | `2.4.2` | `2.4.8` | 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. SDK 3 is supported alongside the existing SDK 2 peer range. Applications adopting SDK 3 must follow packages/sdk/docs/overlay-lookup-migration.md for changed lookup failure, deadline and persistence behavior; existing SDK 2 installations remain supported. | +| `@bsv/payment-express-middleware` | `2.1.1` | `2.1.7` | 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. SDK 3 is supported alongside the existing SDK 2 peer range. Applications adopting SDK 3 must follow packages/sdk/docs/overlay-lookup-migration.md for changed lookup failure, deadline and persistence behavior; existing SDK 2 installations remain supported. | +| `@bsv/sdk` | `2.4.0` | `3.0.0` | major | [API and usage](../packages/sdk/bsv-sdk.md) | SDK 3.0.0 changes default lookup behavior. Standard query() now throws LookupUnavailableError for incomplete empty aggregates. queryDetailed()/query$() expose transport status and discoveryComplete; complete does not prove authority or freshness. All standard resolver calls use a 5-second total budget, 2-second host budgets, fresh discovery, 32 candidate hosts and 256 outputs per host; streamed responses are limited to 4 MiB. Legacy reputation records are ignored automatically; atomic v4 storage is configurable. Review packages/sdk/docs/overlay-lookup-migration.md, first-party peer compatibility and service-specific validation before any coordinated migration. The optional KV path additionally requires a trusted chain tracker and authority policy. Existing byte encodings and incorporated-code notice requirements remain unchanged. | +| `@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. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/. SDK 3 is supported alongside the existing SDK 2 peer range. Applications adopting SDK 3 must follow packages/sdk/docs/overlay-lookup-migration.md for changed lookup failure, deadline and persistence behavior; existing SDK 2 installations remain supported. | +| `@bsv/templates` | `1.9.1` | `1.10.2` | 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. SDK 3 is supported alongside the existing SDK 2 peer range. Applications adopting SDK 3 must follow packages/sdk/docs/overlay-lookup-migration.md for changed lookup failure, deadline and persistence behavior; existing SDK 2 installations remain supported. | +| `@bsv/teranode-listener` | `1.1.1` | `1.1.6` | 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. SDK 3 is supported alongside the existing SDK 2 peer range. Applications adopting SDK 3 must follow packages/sdk/docs/overlay-lookup-migration.md for changed lookup failure, deadline and persistence behavior; existing SDK 2 installations remain supported. | +| `@bsv/verifast` | `0.3.0` | `0.3.6` | 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. SDK 3 is supported alongside the existing SDK 2 peer range. Applications adopting SDK 3 must follow packages/sdk/docs/overlay-lookup-migration.md for changed lookup failure, deadline and persistence behavior; existing SDK 2 installations remain supported. | +| `@bsv/wallet-helper` | `0.1.1` | `0.1.8` | 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. SDK 3 is supported alongside the existing SDK 2 peer range. Applications adopting SDK 3 must follow packages/sdk/docs/overlay-lookup-migration.md for changed lookup failure, deadline and persistence behavior; existing SDK 2 installations remain supported. | +| `@bsv/wallet-relay` | `0.2.2` | `0.3.7` | 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. SDK 3 is supported alongside the existing SDK 2 peer range. Applications adopting SDK 3 must follow packages/sdk/docs/overlay-lookup-migration.md for changed lookup failure, deadline and persistence behavior; existing SDK 2 installations remain supported. | +| `@bsv/wallet-toolbox` | `2.10.4` | `2.11.1` | minor | [API and usage](../packages/wallet/wallet-toolbox.md) | Existing actions, ordinary noSend calls, and permission modules require no migration. To use BRC-177, migrate every active Knex store before serving requests and run the default Wallet Toolbox monitor; IndexedDB upgrades automatically to schema version 5. Upgrade signer, active storage service, and remote monitor together to 2.11.0 or later; older remote storage is rejected before prefunding. 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. 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/. SDK 3 is supported alongside the existing SDK 2 peer range. Applications adopting SDK 3 must follow packages/sdk/docs/overlay-lookup-migration.md for changed lookup failure, deadline and persistence behavior; existing SDK 2 installations remain supported. | +| `@bsv/wallet-toolbox-client` | `2.10.4` | `2.11.1` | minor | [API and usage](../packages/wallet/wallet-toolbox-client.md) | Existing browser actions and permission modules require no migration; IndexedDB upgrades automatically. To use BRC-177 with remote storage, upgrade the active storage service and its default monitor to Wallet Toolbox 2.11.0 or later before upgrading clients; an older server is rejected before prefunding. 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. 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/. SDK 3 is supported alongside the existing SDK 2 peer range. Applications adopting SDK 3 must follow packages/sdk/docs/overlay-lookup-migration.md for changed lookup failure, deadline and persistence behavior; existing SDK 2 installations remain supported. | +| `@bsv/wallet-toolbox-mobile` | `2.10.4` | `2.11.1` | minor | [API and usage](../packages/wallet/wallet-toolbox-mobile.md) | Existing mobile actions and permission modules require no migration. To use BRC-177, migrate and upgrade the active remote storage service and its default monitor to Wallet Toolbox 2.11.0 or later before upgrading clients; an older server is rejected before prefunding. 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. 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/. SDK 3 is supported alongside the existing SDK 2 peer range. Applications adopting SDK 3 must follow packages/sdk/docs/overlay-lookup-migration.md for changed lookup failure, deadline and persistence behavior; existing SDK 2 installations remain supported. | +| `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 @@ -71,8 +71,8 @@ explicitly authorized operations. - Package documentation: [docs/packages/middleware/402-pay.md](../packages/middleware/402-pay.md) - Source: [packages/middleware/402-pay](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/middleware/402-pay) -- Release note: Adds an exact-tarball Vite and esbuild contract for the browser-safe client entry point, including a bundle-size ratchet and an assertion that server exports never leak into browser consumers. Retains the hash-pinned pre-uniformization Open BSV License version 4 grant as a scoped continuity notice. Standardizes first-party author metadata on the current BSV Association name. -- Migration: 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. +- Release note: Adds an exact-tarball Vite and esbuild contract for the browser-safe client entry point, including a bundle-size ratchet and an assertion that server exports never leak into browser consumers. Retains the hash-pinned pre-uniformization Open BSV License version 4 grant as a scoped continuity notice. Standardizes first-party author metadata on the current BSV Association name. Fixes clean installs with SDK 3 by accepting its peer version alongside the existing SDK 2 range. +- Migration: 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. SDK 3 is supported alongside the existing SDK 2 peer range. Applications adopting SDK 3 must follow packages/sdk/docs/overlay-lookup-migration.md for changed lookup failure, deadline and persistence behavior; existing SDK 2 installations remain supported. | Public subpath | Runtime target(s) | Declaration target(s) | | -------------- | ------------------------------------------ | ---------------------------------------------- | @@ -95,8 +95,8 @@ explicitly authorized operations. - Package documentation: [docs/packages/helpers/amountinator.md](../packages/helpers/amountinator.md) - Source: [packages/helpers/amountinator](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/helpers/amountinator) -- Release note: Adds the strict package and artifact contract, hardens amount formatting, and standardizes first-party author metadata on the current BSV Association name. -- Migration: No consumer migration is required; this is a backward-compatible patch candidate. +- Release note: Adds the strict package and artifact contract, hardens amount formatting, and standardizes first-party author metadata on the current BSV Association name. Fixes clean installs with SDK 3 by accepting its peer version alongside the existing SDK 2 range. +- Migration: No consumer migration is required; this is a backward-compatible patch candidate. SDK 3 is supported alongside the existing SDK 2 peer range. Applications adopting SDK 3 must follow packages/sdk/docs/overlay-lookup-migration.md for changed lookup failure, deadline and persistence behavior; existing SDK 2 installations remain supported. | Public subpath | Runtime target(s) | Declaration target(s) | | -------------- | ---------------------------------------- | -------------------------------------------- | @@ -106,8 +106,8 @@ explicitly authorized operations. - Package documentation: [docs/packages/middleware/auth.md](../packages/middleware/auth.md) - Source: [packages/middleware/auth](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/middleware/auth) -- Release note: Adopts the governed strict TypeScript profile and repository-wide zero-warning lint and formatting contract. Standardizes first-party author metadata on the current BSV Association name. -- Migration: No consumer migration is required; authentication APIs, wire behavior, and runtime defaults are unchanged. +- Release note: Adopts the governed strict TypeScript profile and repository-wide zero-warning lint and formatting contract. Standardizes first-party author metadata on the current BSV Association name. Fixes clean installs with SDK 3 by accepting its peer version alongside the existing SDK 2 range. +- Migration: No consumer migration is required; authentication APIs, wire behavior, and runtime defaults are unchanged. SDK 3 is supported alongside the existing SDK 2 peer range. Applications adopting SDK 3 must follow packages/sdk/docs/overlay-lookup-migration.md for changed lookup failure, deadline and persistence behavior; existing SDK 2 installations remain supported. | Public subpath | Runtime target(s) | Declaration target(s) | | -------------- | ---------------------------------------- | -------------------------------------------- | @@ -117,8 +117,8 @@ explicitly authorized operations. - Package documentation: [docs/packages/middleware/auth-express-middleware.md](../packages/middleware/auth-express-middleware.md) - Source: [packages/middleware/auth-express-middleware](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/middleware/auth-express-middleware) -- Release note: Contains late BRC-104 peer-processing failures after the HTTP response or connection has already settled, and preserves BRC-100 byte fields in handshake and buffered JSON responses across number-array, Uint8Array, and historical numeric-key JSON runtimes. Retains the hash-pinned pre-uniformization Open BSV License version 4 grant as a scoped continuity notice. Standardizes first-party author metadata on the current BSV Association name. -- Migration: No API migration is required. Upgrade to @bsv/sdk 2.4.1 or later for the shared byte-boundary contract. Generic signed application-body canonicalization remains unchanged so old and new peers verify identical bytes. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/ with the package. +- Release note: Contains late BRC-104 peer-processing failures after the HTTP response or connection has already settled, and preserves BRC-100 byte fields in handshake and buffered JSON responses across number-array, Uint8Array, and historical numeric-key JSON runtimes. Retains the hash-pinned pre-uniformization Open BSV License version 4 grant as a scoped continuity notice. Standardizes first-party author metadata on the current BSV Association name. Fixes clean installs with SDK 3 by accepting its peer version alongside the existing SDK 2 range. +- Migration: No API migration is required. Upgrade to @bsv/sdk 2.4.1 or later for the shared byte-boundary contract. Generic signed application-body canonicalization remains unchanged so old and new peers verify identical bytes. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/ with the package. SDK 3 is supported alongside the existing SDK 2 peer range. Applications adopting SDK 3 must follow packages/sdk/docs/overlay-lookup-migration.md for changed lookup failure, deadline and persistence behavior; existing SDK 2 installations remain supported. | Public subpath | Runtime target(s) | Declaration target(s) | | ---------------- | ------------------------------------ | ---------------------------------------- | @@ -129,8 +129,8 @@ explicitly authorized operations. - Package documentation: [docs/packages/messaging/authsocket.md](../packages/messaging/authsocket.md) - Source: [packages/messaging/authsocket](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/messaging/authsocket) -- Release note: Contains authentication and application callback failures, caps per-socket authentication concurrency, serializes real typed arrays portably, and preserves arbitrary signed event JSON exactly. Retains the hash-pinned pre-uniformization Open BSV License version 4 grant as a scoped continuity notice. Standardizes first-party author metadata on the current BSV Association name. -- Migration: No API migration is required. Existing event data, including numeric-key objects under byte-like names, is unchanged; typed payment protocols recover historical byte objects at their explicit fields. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/ with the package. +- Release note: Contains authentication and application callback failures, caps per-socket authentication concurrency, serializes real typed arrays portably, and preserves arbitrary signed event JSON exactly. Retains the hash-pinned pre-uniformization Open BSV License version 4 grant as a scoped continuity notice. Standardizes first-party author metadata on the current BSV Association name. Fixes clean installs with SDK 3 by accepting its peer version alongside the existing SDK 2 range. +- Migration: No API migration is required. Existing event data, including numeric-key objects under byte-like names, is unchanged; typed payment protocols recover historical byte objects at their explicit fields. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/ with the package. SDK 3 is supported alongside the existing SDK 2 peer range. Applications adopting SDK 3 must follow packages/sdk/docs/overlay-lookup-migration.md for changed lookup failure, deadline and persistence behavior; existing SDK 2 installations remain supported. | Public subpath | Runtime target(s) | Declaration target(s) | | ---------------- | ------------------------------------ | ---------------------------------------- | @@ -141,8 +141,8 @@ explicitly authorized operations. - Package documentation: [docs/packages/messaging/authsocket-client.md](../packages/messaging/authsocket-client.md) - Source: [packages/messaging/authsocket-client](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/messaging/authsocket-client) -- Release note: Contains authentication and application callback failures, caps authentication concurrency, serializes real typed arrays portably, preserves arbitrary signed event JSON exactly, and ships the complete SDK incorporated-material notice archive with a retained UMD notice banner. Standardizes first-party author metadata on the current BSV Association name. -- Migration: No API migration is required. Existing event data, including numeric-key objects under byte-like names, is unchanged; typed payment protocols recover historical byte objects at their explicit fields. Distributors who copy the UMD file must keep THIRD_PARTY_NOTICES.md and LICENSES/ with it. +- Release note: Contains authentication and application callback failures, caps authentication concurrency, serializes real typed arrays portably, preserves arbitrary signed event JSON exactly, and ships the complete SDK incorporated-material notice archive with a retained UMD notice banner. Standardizes first-party author metadata on the current BSV Association name. Fixes clean installs with SDK 3 by accepting its peer version alongside the existing SDK 2 range. +- Migration: No API migration is required. Existing event data, including numeric-key objects under byte-like names, is unchanged; typed payment protocols recover historical byte objects at their explicit fields. Distributors who copy the UMD file must keep THIRD_PARTY_NOTICES.md and LICENSES/ with it. SDK 3 is supported alongside the existing SDK 2 peer range. Applications adopting SDK 3 must follow packages/sdk/docs/overlay-lookup-migration.md for changed lookup failure, deadline and persistence behavior; existing SDK 2 installations remain supported. | Public subpath | Runtime target(s) | Declaration target(s) | | ---------------- | ----------------------------------- | --------------------------------------- | @@ -153,8 +153,8 @@ explicitly authorized operations. - Package documentation: [docs/packages/wallet/btms.md](../packages/wallet/btms.md) - Source: [packages/wallet/btms](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/wallet/btms) -- Release note: Adds TerraTestNet and preserves token settlement and refund transaction bytes across number-array, Uint8Array, and historical numeric-key JSON wallet runtimes. Standardizes first-party author metadata on the current BSV Association name. -- Migration: Existing local, mainnet, testnet, and number-array behavior is unchanged. TTN consumers select networkPreset teratestnet; all consumers should upgrade to @bsv/sdk 2.4.1 or later for byte-boundary compatibility. +- Release note: Adds TerraTestNet and preserves token settlement and refund transaction bytes across number-array, Uint8Array, and historical numeric-key JSON wallet runtimes. Standardizes first-party author metadata on the current BSV Association name. Fixes clean installs with SDK 3 by accepting its peer version alongside the existing SDK 2 range. +- Migration: Existing local, mainnet, testnet, and number-array behavior is unchanged. TTN consumers select networkPreset teratestnet; all consumers should upgrade to @bsv/sdk 2.4.1 or later for byte-boundary compatibility. SDK 3 is supported alongside the existing SDK 2 peer range. Applications adopting SDK 3 must follow packages/sdk/docs/overlay-lookup-migration.md for changed lookup failure, deadline and persistence behavior; existing SDK 2 installations remain supported. | Public subpath | Runtime target(s) | Declaration target(s) | | -------------- | ---------------------------------------- | -------------------------------------------- | @@ -164,8 +164,8 @@ explicitly authorized operations. - Package documentation: [docs/packages/wallet/btms-permission-module.md](../packages/wallet/btms-permission-module.md) - Source: [packages/wallet/btms-permission-module](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/wallet/btms-permission-module) -- Release note: Adopts the governed strict TypeScript profile and repository-wide zero-warning lint and formatting contract. Standardizes first-party author metadata on the current BSV Association name. -- Migration: No consumer migration is required; permission-module APIs and token semantics are unchanged. +- Release note: Adopts the governed strict TypeScript profile and repository-wide zero-warning lint and formatting contract. Standardizes first-party author metadata on the current BSV Association name. Fixes clean installs with SDK 3 by accepting its peer version alongside the existing SDK 2 range. +- Migration: No consumer migration is required; permission-module APIs and token semantics are unchanged. SDK 3 is supported alongside the existing SDK 2 peer range. Applications adopting SDK 3 must follow packages/sdk/docs/overlay-lookup-migration.md for changed lookup failure, deadline and persistence behavior; existing SDK 2 installations remain supported. | Public subpath | Runtime target(s) | Declaration target(s) | | -------------- | ------------------ | --------------------- | @@ -175,8 +175,8 @@ explicitly authorized operations. - Package documentation: [docs/packages/network/chirp.md](../packages/network/chirp.md) - Source: [packages/network/chirp](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/network/chirp) -- Release note: Introduces the BRC-167 CHIRP reference implementation: canonical Merkle codecs and vectors, progressive and resumable multi-host publication, bounded interleaved and range-aware resolution, a verified-object cache, browser and Node byte-source adapters, closure validation, and publication/retrieval/verification CLI commands. Standardizes first-party author metadata on the current BSV Association name. -- Migration: 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. +- Release note: Introduces the BRC-167 CHIRP reference implementation: canonical Merkle codecs and vectors, progressive and resumable multi-host publication, bounded interleaved and range-aware resolution, a verified-object cache, browser and Node byte-source adapters, closure validation, and publication/retrieval/verification CLI commands. Standardizes first-party author metadata on the current BSV Association name. Fixes clean installs with SDK 3 by accepting its peer version alongside the existing SDK 2 range. +- Migration: 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. SDK 3 is supported alongside the existing SDK 2 peer range. Applications adopting SDK 3 must follow packages/sdk/docs/overlay-lookup-migration.md for changed lookup failure, deadline and persistence behavior; existing SDK 2 installations remain supported. CLI entry points: `{"chirp":"./dist/cli.js"}`. @@ -190,8 +190,8 @@ CLI entry points: `{"chirp":"./dist/cli.js"}`. - Package documentation: [docs/packages/helpers/did.md](../packages/helpers/did.md) - Source: [packages/helpers/did](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/helpers/did) -- Release note: Adds an exact-tarball Vite and esbuild browser-consumer contract with public-export, source-map, server-dependency, and bundle-size ratchets. Standardizes first-party author metadata on the current BSV Association name. -- Migration: No consumer migration is required; DID APIs, encodings, credential behavior, and supported import forms are unchanged. +- Release note: Adds an exact-tarball Vite and esbuild browser-consumer contract with public-export, source-map, server-dependency, and bundle-size ratchets. Standardizes first-party author metadata on the current BSV Association name. Fixes clean installs with SDK 3 by accepting its peer version alongside the existing SDK 2 range. +- Migration: No consumer migration is required; DID APIs, encodings, credential behavior, and supported import forms are unchanged. SDK 3 is supported alongside the existing SDK 2 peer range. Applications adopting SDK 3 must follow packages/sdk/docs/overlay-lookup-migration.md for changed lookup failure, deadline and persistence behavior; existing SDK 2 installations remain supported. | Public subpath | Runtime target(s) | Declaration target(s) | | -------------- | --------------------------------------- | ------------------------------------------- | @@ -202,8 +202,8 @@ CLI entry points: `{"chirp":"./dist/cli.js"}`. - Package documentation: [docs/packages/helpers/did-client.md](../packages/helpers/did-client.md) - Source: [packages/helpers/did-client](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/helpers/did-client) -- Release note: Adds TerraTestNet as an explicit DID overlay-network preset, retains the governed package-quality contract, ships the complete SDK incorporated-material notice archive with a retained UMD notice banner, and standardizes first-party author metadata on the current BSV Association name. -- Migration: 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. +- Release note: Adds TerraTestNet as an explicit DID overlay-network preset, retains the governed package-quality contract, ships the complete SDK incorporated-material notice archive with a retained UMD notice banner, and standardizes first-party author metadata on the current BSV Association name. Fixes clean installs with SDK 3 by accepting its peer version alongside the existing SDK 2 range. +- Migration: 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. SDK 3 is supported alongside the existing SDK 2 peer range. Applications adopting SDK 3 must follow packages/sdk/docs/overlay-lookup-migration.md for changed lookup failure, deadline and persistence behavior; existing SDK 2 installations remain supported. | Public subpath | Runtime target(s) | Declaration target(s) | | ---------------- | --------------------------------------- | ------------------------------------------- | @@ -215,8 +215,8 @@ CLI entry points: `{"chirp":"./dist/cli.js"}`. - Package documentation: [docs/packages/wallet/ecpm-permission-module.md](../packages/wallet/ecpm-permission-module.md) - Source: [packages/wallet/ecpm-permission-module](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/wallet/ecpm-permission-module) -- Release note: Introduces the BRC-98 p ecpm semantic module, applying or removing a wallet-derived scalar from validated secp256k1 points through the existing BRC-100 getPublicKey surface, including reason-scoped privileged authorization and privileged-key-provider hooks. -- Migration: 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. +- Release note: Introduces the BRC-98 p ecpm semantic module, applying or removing a wallet-derived scalar from validated secp256k1 points through the existing BRC-100 getPublicKey surface, including reason-scoped privileged authorization and privileged-key-provider hooks. Fixes clean installs with SDK 3 by accepting its peer version alongside the existing SDK 2 range. +- Migration: 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. SDK 3 is supported alongside the existing SDK 2 peer range. Applications adopting SDK 3 must follow packages/sdk/docs/overlay-lookup-migration.md for changed lookup failure, deadline and persistence behavior; existing SDK 2 installations remain supported. | Public subpath | Runtime target(s) | Declaration target(s) | | -------------- | ------------------ | --------------------- | @@ -226,8 +226,8 @@ CLI entry points: `{"chirp":"./dist/cli.js"}`. - Package documentation: [docs/packages/helpers/fund-wallet.md](../packages/helpers/fund-wallet.md) - Source: [packages/helpers/fund-wallet](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/helpers/fund-wallet) -- Release note: Adopts the governed strict TypeScript profile and repository-wide zero-warning lint and formatting contract, and retains the imported fund-metanet source's hash-pinned ISC provenance and notice from its exact source and npm publication. Standardizes first-party author metadata on the current BSV Association name. -- Migration: 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. +- Release note: Adopts the governed strict TypeScript profile and repository-wide zero-warning lint and formatting contract, and retains the imported fund-metanet source's hash-pinned ISC provenance and notice from its exact source and npm publication. Standardizes first-party author metadata on the current BSV Association name. Fixes clean installs with SDK 3 by accepting its peer version alongside the existing SDK 2 range. +- Migration: 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. SDK 3 is supported alongside the existing SDK 2 peer range. Applications adopting SDK 3 must follow packages/sdk/docs/overlay-lookup-migration.md for changed lookup failure, deadline and persistence behavior; existing SDK 2 installations remain supported. CLI entry points: `{"fund-metanet":"./dist/index.mjs"}`. @@ -239,8 +239,8 @@ CLI entry points: `{"fund-metanet":"./dist/index.mjs"}`. - Package documentation: [docs/packages/overlays/gasp.md](../packages/overlays/gasp.md) - Source: [packages/overlays/gasp-core](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/overlays/gasp-core) -- Release note: Preserves the positional GASP constructor contract while improving declaration metadata and production maintainability, and adds an exact-tarball browser-consumer and bundle-size contract. Retains the hash-pinned pre-uniformization Open BSV License version 4 grant as a scoped continuity notice. Standardizes first-party author metadata on the current BSV Association name. -- Migration: 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. +- Release note: Preserves the positional GASP constructor contract while improving declaration metadata and production maintainability, and adds an exact-tarball browser-consumer and bundle-size contract. Retains the hash-pinned pre-uniformization Open BSV License version 4 grant as a scoped continuity notice. Standardizes first-party author metadata on the current BSV Association name. Fixes clean installs with SDK 3 by accepting its peer version alongside the existing SDK 2 range. +- Migration: 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. SDK 3 is supported alongside the existing SDK 2 peer range. Applications adopting SDK 3 must follow packages/sdk/docs/overlay-lookup-migration.md for changed lookup failure, deadline and persistence behavior; existing SDK 2 installations remain supported. | Public subpath | Runtime target(s) | Declaration target(s) | | -------------- | ---------------------------------------------- | ---------------------------------------------------- | @@ -251,8 +251,8 @@ CLI entry points: `{"fund-metanet":"./dist/index.mjs"}`. - Package documentation: [docs/packages/content/lch.md](../packages/content/lch.md) - Source: [packages/content/lch](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/content/lch) -- Release note: Introduces the published BRC-170 Licensed Content Header reference implementation: deterministic CBOR and framing, segmented authenticated encryption, signed acquisition objects, deterministic-CBOR HTTP client/server bindings, independently routed and replay-safe Payee receipts, signed readiness and pending settlement, receipt-complete and offline-capable authorized-output settlement profiles, authenticated late Delivery retrieval, an explicit recovery-safe multipay buyer workflow with exact License, fulfillment, and key-grant binding, commit-aware IndexedDB persistence, UHRP and CHIRP content adapters with resolved-host failover, authority revocation, bounded DAG composition, deterministic malformed-input handling, and URL-canonical SSRF protection including IPv4-mapped IPv6 literals. -- Migration: 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. +- Release note: Introduces the published BRC-170 Licensed Content Header reference implementation: deterministic CBOR and framing, segmented authenticated encryption, signed acquisition objects, deterministic-CBOR HTTP client/server bindings, independently routed and replay-safe Payee receipts, signed readiness and pending settlement, receipt-complete and offline-capable authorized-output settlement profiles, authenticated late Delivery retrieval, an explicit recovery-safe multipay buyer workflow with exact License, fulfillment, and key-grant binding, commit-aware IndexedDB persistence, UHRP and CHIRP content adapters with resolved-host failover, authority revocation, bounded DAG composition, deterministic malformed-input handling, and URL-canonical SSRF protection including IPv4-mapped IPv6 literals. Fixes clean installs with SDK 3 by accepting its peer version alongside the existing SDK 2 range. +- Migration: 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. SDK 3 is supported alongside the existing SDK 2 peer range. Applications adopting SDK 3 must follow packages/sdk/docs/overlay-lookup-migration.md for changed lookup failure, deadline and persistence behavior; existing SDK 2 installations remain supported. CLI entry points: `{"lch":"./dist/cli.js"}`. @@ -265,8 +265,8 @@ CLI entry points: `{"lch":"./dist/cli.js"}`. - Package documentation: [docs/packages/messaging/message-box-client.md](../packages/messaging/message-box-client.md) - Source: [packages/messaging/message-box-client](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/messaging/message-box-client) -- Release note: Preserves BRC-29 payments, paid messages, remittances, and peer tokens across binary Wallet Wire results and historical numeric-key JSON payloads, rejects sparse or invalid byte records, and ships the complete SDK incorporated-material notice archive with a retained UMD notice banner. Standardizes first-party author metadata on the current BSV Association name. -- Migration: No API migration is required. Upgrade @bsv/sdk and @bsv/message-box-client together; historical number-array wallets, current Uint8Array substrates, and already-pending numeric-key messages interoperate through the same portable transaction form. Distributors who copy the UMD file must keep THIRD_PARTY_NOTICES.md and LICENSES/ with it. +- Release note: Preserves BRC-29 payments, paid messages, remittances, and peer tokens across binary Wallet Wire results and historical numeric-key JSON payloads, rejects sparse or invalid byte records, and ships the complete SDK incorporated-material notice archive with a retained UMD notice banner. Standardizes first-party author metadata on the current BSV Association name. Fixes clean installs with SDK 3 by accepting its peer version alongside the existing SDK 2 range. +- Migration: No API migration is required. Upgrade @bsv/sdk and @bsv/message-box-client together; historical number-array wallets, current Uint8Array substrates, and already-pending numeric-key messages interoperate through the same portable transaction form. Distributors who copy the UMD file must keep THIRD_PARTY_NOTICES.md and LICENSES/ with it. SDK 3 is supported alongside the existing SDK 2 peer range. Applications adopting SDK 3 must follow packages/sdk/docs/overlay-lookup-migration.md for changed lookup failure, deadline and persistence behavior; existing SDK 2 installations remain supported. | Public subpath | Runtime target(s) | Declaration target(s) | | ---------------- | ----------------------------------- | --------------------------------------- | @@ -277,8 +277,8 @@ CLI entry points: `{"lch":"./dist/cli.js"}`. - Package documentation: [docs/packages/overlays/overlay.md](../packages/overlays/overlay.md) - Source: [packages/overlays/overlay](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/overlays/overlay) -- Release note: Adds an engine lookup-result cardinality ceiling before transaction/proof hydration and an optional TopicManager abort hook for provisional admission state when strict transaction broadcast fails. Retains the hash-pinned pre-uniformization Open BSV License version 4 grant as a scoped continuity notice. -- Migration: 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. +- Release note: Adds an engine lookup-result cardinality ceiling before transaction/proof hydration and an optional TopicManager abort hook for provisional admission state when strict transaction broadcast fails. Retains the hash-pinned pre-uniformization Open BSV License version 4 grant as a scoped continuity notice. Fixes clean installs with SDK 3 by accepting its peer version alongside the existing SDK 2 range. +- Migration: 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. SDK 3 is supported alongside the existing SDK 2 peer range. Applications adopting SDK 3 must follow packages/sdk/docs/overlay-lookup-migration.md for changed lookup failure, deadline and persistence behavior; existing SDK 2 installations remain supported. | Public subpath | Runtime target(s) | Declaration target(s) | | ---------------- | -------------------------------------------------------------------------- | -------------------------------------------------------------------------------- | @@ -292,8 +292,8 @@ CLI entry points: `{"lch":"./dist/cli.js"}`. - Package documentation: [docs/packages/overlays/overlay-discovery-services.md](../packages/overlays/overlay-discovery-services.md) - Source: [packages/overlays/overlay-discovery-services](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/overlays/overlay-discovery-services) -- Release note: Extends WalletAdvertiser to TerraTestNet with chain-isolated SHIP/SLAP resolution and TTN wallet services. Retains the hash-pinned pre-uniformization Open BSV License version 4 grant as a scoped continuity notice. -- Migration: 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. +- Release note: Extends WalletAdvertiser to TerraTestNet with chain-isolated SHIP/SLAP resolution and TTN wallet services. Retains the hash-pinned pre-uniformization Open BSV License version 4 grant as a scoped continuity notice. Fixes clean installs with SDK 3 by accepting its peer version alongside the existing SDK 2 range. +- Migration: 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. SDK 3 is supported alongside the existing SDK 2 peer range. Applications adopting SDK 3 must follow packages/sdk/docs/overlay-lookup-migration.md for changed lookup failure, deadline and persistence behavior; existing SDK 2 installations remain supported. | Public subpath | Runtime target(s) | Declaration target(s) | | -------------- | ---------------------------------------------- | ---------------------------------------------------- | @@ -304,8 +304,8 @@ CLI entry points: `{"lch":"./dist/cli.js"}`. - Package documentation: [docs/packages/overlays/overlay-express.md](../packages/overlays/overlay-express.md) - Source: [packages/overlays/overlay-express](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/overlays/overlay-express) -- Release note: Adds a TerraTestNet runtime mode with TTN discovery roots, wallet advertising, and explicit ChainTracks requirements while retaining Arcade-first propagation. Retains the hash-pinned pre-uniformization Open BSV License version 4 grant as a scoped continuity notice. -- Migration: 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. +- Release note: Adds a TerraTestNet runtime mode with TTN discovery roots, wallet advertising, and explicit ChainTracks requirements while retaining Arcade-first propagation. Retains the hash-pinned pre-uniformization Open BSV License version 4 grant as a scoped continuity notice. Fixes clean installs with SDK 3 by accepting its peer version alongside the existing SDK 2 range. +- Migration: 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. SDK 3 is supported alongside the existing SDK 2 peer range. Applications adopting SDK 3 must follow packages/sdk/docs/overlay-lookup-migration.md for changed lookup failure, deadline and persistence behavior; existing SDK 2 installations remain supported. | Public subpath | Runtime target(s) | Declaration target(s) | | -------------- | ---------------------------------------------- | ---------------------------------------------------- | @@ -316,8 +316,8 @@ CLI entry points: `{"lch":"./dist/cli.js"}`. - Package documentation: [docs/packages/overlays/overlay-topics.md](../packages/overlays/overlay-topics.md) - Source: [packages/overlays/topics](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/overlays/topics) -- Release note: Adds persistent first-writer reservations for UMP presentation and recovery hashes, aborts provisional claims after strict broadcast failure, keeps confirmed owners protected until successor indexing, retries transient initialization, marks one-time legacy bootstrap, and returns the newest bounded legacy candidates for verified lineage or an operator pin. It also retains the collection-index resilience and opt-in repair controls from the prior candidate. Standardizes first-party author metadata on the current BSV Association name. -- Migration: 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. +- Release note: Adds persistent first-writer reservations for UMP presentation and recovery hashes, aborts provisional claims after strict broadcast failure, keeps confirmed owners protected until successor indexing, retries transient initialization, marks one-time legacy bootstrap, and returns the newest bounded legacy candidates for verified lineage or an operator pin. It also retains the collection-index resilience and opt-in repair controls from the prior candidate. Standardizes first-party author metadata on the current BSV Association name. Fixes clean installs with SDK 3 by accepting its peer version alongside the existing SDK 2 range. +- Migration: 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. SDK 3 is supported alongside the existing SDK 2 peer range. Applications adopting SDK 3 must follow packages/sdk/docs/overlay-lookup-migration.md for changed lookup failure, deadline and persistence behavior; existing SDK 2 installations remain supported. | Public subpath | Runtime target(s) | Declaration target(s) | | -------------- | -------------------------------------- | --------------------- | @@ -327,8 +327,8 @@ CLI entry points: `{"lch":"./dist/cli.js"}`. - Package documentation: [docs/packages/messaging/paymail.md](../packages/messaging/paymail.md) - Source: [packages/messaging/ts-paymail](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/messaging/ts-paymail) -- Release note: Modernizes the package contract, hardens DNS, capability discovery, and browser-compatible Paymail behavior, and shares the host application's Express runtime and types for server routers. Removes local copies of externally authored specification documents whose upstream repositories do not assert reusable terms, replacing them with authoritative links. Retains the hash-pinned pre-uniformization Open BSV License version 4 grant as a scoped continuity notice. Standardizes first-party author metadata on the current BSV Association name. -- Migration: 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. +- Release note: Modernizes the package contract, hardens DNS, capability discovery, and browser-compatible Paymail behavior, and shares the host application's Express runtime and types for server routers. Removes local copies of externally authored specification documents whose upstream repositories do not assert reusable terms, replacing them with authoritative links. Retains the hash-pinned pre-uniformization Open BSV License version 4 grant as a scoped continuity notice. Standardizes first-party author metadata on the current BSV Association name. Fixes clean installs with SDK 3 by accepting its peer version alongside the existing SDK 2 range. +- Migration: 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. SDK 3 is supported alongside the existing SDK 2 peer range. Applications adopting SDK 3 must follow packages/sdk/docs/overlay-lookup-migration.md for changed lookup failure, deadline and persistence behavior; existing SDK 2 installations remain supported. | Public subpath | Runtime target(s) | Declaration target(s) | | ---------------- | --------------------------------------------------------------------------- | ------------------------------------------------------------------------------- | @@ -347,8 +347,8 @@ CLI entry points: `{"lch":"./dist/cli.js"}`. - Package documentation: [docs/packages/middleware/payment-express-middleware.md](../packages/middleware/payment-express-middleware.md) - Source: [packages/middleware/payment-express-middleware](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/middleware/payment-express-middleware) -- Release note: Standardizes package quality, strengthens payment middleware validation, edge policy, and failure handling, and shares the host application's Express runtime and types. Retains the hash-pinned pre-uniformization Open BSV License version 4 grant as a scoped continuity notice. Standardizes first-party author metadata on the current BSV Association name. -- Migration: 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. +- Release note: Standardizes package quality, strengthens payment middleware validation, edge policy, and failure handling, and shares the host application's Express runtime and types. Retains the hash-pinned pre-uniformization Open BSV License version 4 grant as a scoped continuity notice. Standardizes first-party author metadata on the current BSV Association name. Fixes clean installs with SDK 3 by accepting its peer version alongside the existing SDK 2 range. +- Migration: 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. SDK 3 is supported alongside the existing SDK 2 peer range. Applications adopting SDK 3 must follow packages/sdk/docs/overlay-lookup-migration.md for changed lookup failure, deadline and persistence behavior; existing SDK 2 installations remain supported. | Public subpath | Runtime target(s) | Declaration target(s) | | ---------------- | ------------------------------------ | ---------------------------------------- | @@ -442,8 +442,8 @@ CLI entry points: `{"lch":"./dist/cli.js"}`. - Package documentation: [docs/packages/helpers/simple.md](../packages/helpers/simple.md) - Source: [packages/helpers/simple](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/helpers/simple) -- Release note: Adds TerraTestNet, preserves Message Box, token send/receive, and server-adapter transaction bytes across wallet runtimes, and restores the exact scoped MIT notice for the pre-monorepo source alongside Open BSV licensing for later first-party changes. Standardizes first-party author metadata on the current BSV Association name. -- Migration: 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. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/. +- Release note: Adds TerraTestNet, preserves Message Box, token send/receive, and server-adapter transaction bytes across wallet runtimes, and restores the exact scoped MIT notice for the pre-monorepo source alongside Open BSV licensing for later first-party changes. Standardizes first-party author metadata on the current BSV Association name. Fixes clean installs with SDK 3 by accepting its peer version alongside the existing SDK 2 range. +- Migration: 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. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/. SDK 3 is supported alongside the existing SDK 2 peer range. Applications adopting SDK 3 must follow packages/sdk/docs/overlay-lookup-migration.md for changed lookup failure, deadline and persistence behavior; existing SDK 2 installations remain supported. | Public subpath | Runtime target(s) | Declaration target(s) | | -------------- | -------------------------------------------- | ------------------------------------------------ | @@ -455,8 +455,8 @@ CLI entry points: `{"lch":"./dist/cli.js"}`. - Package documentation: [docs/packages/helpers/templates.md](../packages/helpers/templates.md) - Source: [packages/helpers/ts-templates](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/helpers/ts-templates) -- Release note: Adds the R1K1Wallet ScriptTemplate for salted P-256 hardware signing with an independent secp256k1 recovery path, including a checksummed static Runar artifact and browser-safe lazy decompression. Also consolidates MultiPushDrop script construction without changing its output, adds an exact-tarball browser-consumer and bundle-size contract, and points contributors to the canonical root policy. Retains the hash-pinned pre-uniformization Open BSV License version 4 grant as a scoped continuity notice. -- Migration: 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. +- Release note: Adds the R1K1Wallet ScriptTemplate for salted P-256 hardware signing with an independent secp256k1 recovery path, including a checksummed static Runar artifact and browser-safe lazy decompression. Also consolidates MultiPushDrop script construction without changing its output, adds an exact-tarball browser-consumer and bundle-size contract, and points contributors to the canonical root policy. Retains the hash-pinned pre-uniformization Open BSV License version 4 grant as a scoped continuity notice. Fixes clean installs with SDK 3 by accepting its peer version alongside the existing SDK 2 range. +- Migration: 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. SDK 3 is supported alongside the existing SDK 2 peer range. Applications adopting SDK 3 must follow packages/sdk/docs/overlay-lookup-migration.md for changed lookup failure, deadline and persistence behavior; existing SDK 2 installations remain supported. | Public subpath | Runtime target(s) | Declaration target(s) | | -------------- | --------------------------------------- | ------------------------------------------- | @@ -467,8 +467,8 @@ CLI entry points: `{"lch":"./dist/cli.js"}`. - Package documentation: [docs/packages/network/teranode-listener.md](../packages/network/teranode-listener.md) - Source: [packages/network/ts-p2p](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/network/ts-p2p) -- Release note: Refreshes the compatible libp2p transport, discovery, identification, DHT, peer-ID, ping, and private-network dependency set, and retains the imported ts-p2p source's hash-pinned MIT provenance and notice from its exact source and npm publication. Standardizes first-party author metadata on the current BSV Association name. -- Migration: 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. +- Release note: Refreshes the compatible libp2p transport, discovery, identification, DHT, peer-ID, ping, and private-network dependency set, and retains the imported ts-p2p source's hash-pinned MIT provenance and notice from its exact source and npm publication. Standardizes first-party author metadata on the current BSV Association name. Fixes clean installs with SDK 3 by accepting its peer version alongside the existing SDK 2 range. +- Migration: 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. SDK 3 is supported alongside the existing SDK 2 peer range. Applications adopting SDK 3 must follow packages/sdk/docs/overlay-lookup-migration.md for changed lookup failure, deadline and persistence behavior; existing SDK 2 installations remain supported. | Public subpath | Runtime target(s) | Declaration target(s) | | ---------------- | -------------------------------------- | --------------------- | @@ -479,8 +479,8 @@ CLI entry points: `{"lch":"./dist/cli.js"}`. - Package documentation: [docs/packages/sdk/verifast.md](../packages/sdk/verifast.md) - Source: [packages/verifast](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/verifast) -- Release note: Adds exact-tarball browser, WASM, worker, strict-CSP, streaming-fallback, UMD source-map, and bundle contracts, and now ships hash-pinned BDK, bitcoin-sv, libsecp256k1, Emscripten, musl, LLVM, and Boost notices with a retained UMD banner and SBOM component records. Standardizes first-party author metadata on the current BSV Association name. -- Migration: 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. +- Release note: Adds exact-tarball browser, WASM, worker, strict-CSP, streaming-fallback, UMD source-map, and bundle contracts, and now ships hash-pinned BDK, bitcoin-sv, libsecp256k1, Emscripten, musl, LLVM, and Boost notices with a retained UMD banner and SBOM component records. Standardizes first-party author metadata on the current BSV Association name. Fixes clean installs with SDK 3 by accepting its peer version alongside the existing SDK 2 range. +- Migration: 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. SDK 3 is supported alongside the existing SDK 2 peer range. Applications adopting SDK 3 must follow packages/sdk/docs/overlay-lookup-migration.md for changed lookup failure, deadline and persistence behavior; existing SDK 2 installations remain supported. | Public subpath | Runtime target(s) | Declaration target(s) | | -------------------------- | ------------------------------------------------------------------------------------------- | -------------------------------------------------------------------- | @@ -494,8 +494,8 @@ CLI entry points: `{"lch":"./dist/cli.js"}`. - Package documentation: [docs/packages/helpers/wallet-helper.md](../packages/helpers/wallet-helper.md) - Source: [packages/helpers/bsv-wallet-helper](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/helpers/bsv-wallet-helper) -- Release note: Adds strict package contracts and hardens transaction-builder and OP_RETURN validation behavior. Retains the hash-pinned pre-uniformization Open BSV License Version 6 grant as a scoped continuity notice. Standardizes first-party author metadata on the current BSV Association name. -- Migration: 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. +- Release note: Adds strict package contracts and hardens transaction-builder and OP_RETURN validation behavior. Retains the hash-pinned pre-uniformization Open BSV License Version 6 grant as a scoped continuity notice. Standardizes first-party author metadata on the current BSV Association name. Fixes clean installs with SDK 3 by accepting its peer version alongside the existing SDK 2 range. +- Migration: 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. SDK 3 is supported alongside the existing SDK 2 peer range. Applications adopting SDK 3 must follow packages/sdk/docs/overlay-lookup-migration.md for changed lookup failure, deadline and persistence behavior; existing SDK 2 installations remain supported. | Public subpath | Runtime target(s) | Declaration target(s) | | -------------- | ---------------------------------------- | -------------------------------------------- | @@ -505,8 +505,8 @@ CLI entry points: `{"lch":"./dist/cli.js"}`. - Package documentation: [docs/packages/wallet/wallet-relay.md](../packages/wallet/wallet-relay.md) - Source: [packages/wallet/ts-wallet-relay](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/wallet/ts-wallet-relay) -- Release note: Adds the strict package contract and preserves wallet RPC, encrypted relay payloads, signatures, and transaction bytes across number-array, Uint8Array, Buffer, and historical numeric-key JSON runtimes. Standardizes first-party author metadata on the current BSV Association name. -- Migration: No wallet RPC migration is required; upgrade to @bsv/sdk 2.4.1 or later. Existing relay sessions and number arrays remain valid, and host applications continue to provide their matching Express runtime and type graph. +- Release note: Adds the strict package contract and preserves wallet RPC, encrypted relay payloads, signatures, and transaction bytes across number-array, Uint8Array, Buffer, and historical numeric-key JSON runtimes. Standardizes first-party author metadata on the current BSV Association name. Fixes clean installs with SDK 3 by accepting its peer version alongside the existing SDK 2 range. +- Migration: No wallet RPC migration is required; upgrade to @bsv/sdk 2.4.1 or later. Existing relay sessions and number arrays remain valid, and host applications continue to provide their matching Express runtime and type graph. SDK 3 is supported alongside the existing SDK 2 peer range. Applications adopting SDK 3 must follow packages/sdk/docs/overlay-lookup-migration.md for changed lookup failure, deadline and persistence behavior; existing SDK 2 installations remain supported. CLI entry points: `{"wallet-relay":"./bin/init.mjs"}`. @@ -521,8 +521,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, 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, and permission modules require no migration. To use BRC-177, migrate every active Knex store before serving requests and run the default Wallet Toolbox monitor; IndexedDB upgrades automatically to schema version 5. Upgrade signer, active storage service, and remote monitor together to 2.11.0 or later; older remote storage is rejected before prefunding. 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. 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, retains BRC-95/BRC-100 compatibility and stable bounded pagination, removes the obsolete JSight application bundle, and preserves the earlier Open BSV grant. Fixes clean installs with SDK 3 by accepting its peer version alongside the existing SDK 2 range. +- Migration: Existing actions, ordinary noSend calls, and permission modules require no migration. To use BRC-177, migrate every active Knex store before serving requests and run the default Wallet Toolbox monitor; IndexedDB upgrades automatically to schema version 5. Upgrade signer, active storage service, and remote monitor together to 2.11.0 or later; older remote storage is rejected before prefunding. 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. 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/. SDK 3 is supported alongside the existing SDK 2 peer range. Applications adopting SDK 3 must follow packages/sdk/docs/overlay-lookup-migration.md for changed lookup failure, deadline and persistence behavior; existing SDK 2 installations remain supported. | Public subpath | Runtime target(s) | Declaration target(s) | | ---------------- | ---------------------------------------------------- | -------------------------- | @@ -535,8 +535,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, and the optional semantic handleRequest hook. 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 and permission modules require no migration; IndexedDB upgrades automatically. To use BRC-177 with remote storage, upgrade the active storage service and its default monitor to Wallet Toolbox 2.11.0 or later before upgrading clients; an older server is rejected before prefunding. 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. 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, and the optional semantic handleRequest hook. 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. Fixes clean installs with SDK 3 by accepting its peer version alongside the existing SDK 2 range. +- Migration: Existing browser actions and permission modules require no migration; IndexedDB upgrades automatically. To use BRC-177 with remote storage, upgrade the active storage service and its default monitor to Wallet Toolbox 2.11.0 or later before upgrading clients; an older server is rejected before prefunding. 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. 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/. SDK 3 is supported alongside the existing SDK 2 peer range. Applications adopting SDK 3 must follow packages/sdk/docs/overlay-lookup-migration.md for changed lookup failure, deadline and persistence behavior; existing SDK 2 installations remain supported. | Public subpath | Runtime target(s) | Declaration target(s) | | ---------------- | -------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------- | @@ -547,8 +547,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, and the optional semantic handleRequest hook. 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 and permission modules require no migration. To use BRC-177, migrate and upgrade the active remote storage service and its default monitor to Wallet Toolbox 2.11.0 or later before upgrading clients; an older server is rejected before prefunding. 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. 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, and the optional semantic handleRequest hook. 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. Fixes clean installs with SDK 3 by accepting its peer version alongside the existing SDK 2 range. +- Migration: Existing mobile actions and permission modules require no migration. To use BRC-177, migrate and upgrade the active remote storage service and its default monitor to Wallet Toolbox 2.11.0 or later before upgrading clients; an older server is rejected before prefunding. 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. 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/. SDK 3 is supported alongside the existing SDK 2 peer range. Applications adopting SDK 3 must follow packages/sdk/docs/overlay-lookup-migration.md for changed lookup failure, deadline and persistence behavior; existing SDK 2 installations remain supported. | Public subpath | Runtime target(s) | Declaration target(s) | | ---------------- | -------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------- | diff --git a/docs/reference/stack-facts.md b/docs/reference/stack-facts.md index 02008a8f8..53af01427 100644 --- a/docs/reference/stack-facts.md +++ b/docs/reference/stack-facts.md @@ -37,40 +37,40 @@ authorized release action. | Area | Package | Source version | Project profile | Consumer profiles | Runtime targets | Node engine | Source | | --- | --- | --- | --- | --- | --- | --- | --- | -| content | `@bsv/lch` | `0.1.0` | browser-library | browser-bundler, browser-esm, cli, node-esm | browser, node | `>=22` | [packages/content/lch](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/content/lch) | +| content | `@bsv/lch` | `0.1.1` | browser-library | browser-bundler, browser-esm, cli, node-esm | browser, node | `>=22` | [packages/content/lch](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/content/lch) | | helpers | `@bsv/air-gap` | `0.1.2` | browser-library | browser-bundler, browser-esm, node-cjs, node-esm | browser, node | `>=22` | [packages/helpers/air-gap](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/helpers/air-gap) | -| helpers | `@bsv/amountinator` | `2.1.5` | node-library | node-cjs, node-esm | node | `>=22` | [packages/helpers/amountinator](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/helpers/amountinator) | -| helpers | `@bsv/did` | `0.2.5` | browser-library | browser-bundler, browser-esm, node-cjs, node-esm | browser, node | `>=22` | [packages/helpers/did](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/helpers/did) | -| helpers | `@bsv/did-client` | `1.3.1` | browser-library | browser-bundler, browser-esm, node-cjs, node-esm, umd-global | browser, node, umd | `>=22` | [packages/helpers/did-client](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/helpers/did-client) | -| helpers | `@bsv/fund-wallet` | `1.4.4` | cli | cli | node | `>=22` | [packages/helpers/fund-wallet](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/helpers/fund-wallet) | -| helpers | `@bsv/simple` | `0.5.2` | browser-library | browser-bundler, browser-esm, node-cjs, node-esm | browser, node | `>=22` | [packages/helpers/simple](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/helpers/simple) | -| helpers | `@bsv/templates` | `1.10.1` | browser-library | browser-bundler, browser-esm, node-cjs, node-esm | browser, node | `>=22` | [packages/helpers/ts-templates](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/helpers/ts-templates) | -| helpers | `@bsv/wallet-helper` | `0.1.7` | node-library | node-cjs, node-esm | node | `>=22` | [packages/helpers/bsv-wallet-helper](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/helpers/bsv-wallet-helper) | +| helpers | `@bsv/amountinator` | `2.1.6` | node-library | node-cjs, node-esm | node | `>=22` | [packages/helpers/amountinator](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/helpers/amountinator) | +| helpers | `@bsv/did` | `0.2.6` | browser-library | browser-bundler, browser-esm, node-cjs, node-esm | browser, node | `>=22` | [packages/helpers/did](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/helpers/did) | +| helpers | `@bsv/did-client` | `1.3.2` | browser-library | browser-bundler, browser-esm, node-cjs, node-esm, umd-global | browser, node, umd | `>=22` | [packages/helpers/did-client](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/helpers/did-client) | +| helpers | `@bsv/fund-wallet` | `1.4.5` | cli | cli | node | `>=22` | [packages/helpers/fund-wallet](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/helpers/fund-wallet) | +| helpers | `@bsv/simple` | `0.5.3` | browser-library | browser-bundler, browser-esm, node-cjs, node-esm | browser, node | `>=22` | [packages/helpers/simple](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/helpers/simple) | +| helpers | `@bsv/templates` | `1.10.2` | browser-library | browser-bundler, browser-esm, node-cjs, node-esm | browser, node | `>=22` | [packages/helpers/ts-templates](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/helpers/ts-templates) | +| helpers | `@bsv/wallet-helper` | `0.1.8` | node-library | node-cjs, node-esm | node | `>=22` | [packages/helpers/bsv-wallet-helper](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/helpers/bsv-wallet-helper) | | helpers | `create-bsv-app` | `1.1.1` | cli | cli | node | `>=22` | [packages/helpers/create-bsv-app](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/helpers/create-bsv-app) | -| messaging | `@bsv/authsocket` | `2.1.7` | node-library | node-cjs, node-esm | node | `>=22` | [packages/messaging/authsocket](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/messaging/authsocket) | -| messaging | `@bsv/authsocket-client` | `2.1.6` | browser-library | browser-bundler, browser-esm, node-cjs, node-esm, umd-global | browser, node, umd | `>=22` | [packages/messaging/authsocket-client](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/messaging/authsocket-client) | -| messaging | `@bsv/message-box-client` | `2.4.2` | browser-library | browser-bundler, browser-esm, node-cjs, node-esm, umd-global | browser, node, umd | `>=22` | [packages/messaging/message-box-client](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/messaging/message-box-client) | -| messaging | `@bsv/paymail` | `2.4.7` | browser-library | browser-bundler, browser-esm, node-cjs, node-esm | browser, node | `>=22` | [packages/messaging/ts-paymail](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/messaging/ts-paymail) | -| middleware | `@bsv/402-pay` | `0.2.5` | browser-library | browser-bundler, browser-esm, node-cjs, node-esm | browser, node | `>=22` | [packages/middleware/402-pay](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/middleware/402-pay) | -| middleware | `@bsv/auth` | `0.1.4` | node-library | node-cjs, node-esm | node | `>=22` | [packages/middleware/auth](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/middleware/auth) | -| middleware | `@bsv/auth-express-middleware` | `2.2.3` | node-library | node-cjs, node-esm | node | `>=22` | [packages/middleware/auth-express-middleware](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/middleware/auth-express-middleware) | -| middleware | `@bsv/payment-express-middleware` | `2.1.6` | node-library | node-cjs, node-esm | node | `>=22` | [packages/middleware/payment-express-middleware](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/middleware/payment-express-middleware) | -| network | `@bsv/chirp` | `0.1.1` | browser-library | browser-bundler, browser-esm, cli, node-esm | browser, node | `>=22` | [packages/network/chirp](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/network/chirp) | -| network | `@bsv/teranode-listener` | `1.1.5` | node-library | node-esm | node | `>=22` | [packages/network/ts-p2p](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/network/ts-p2p) | -| overlays | `@bsv/gasp` | `1.3.6` | browser-library | browser-bundler, browser-esm, node-cjs, node-esm | browser, node | `>=22` | [packages/overlays/gasp-core](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/overlays/gasp-core) | -| overlays | `@bsv/overlay` | `2.3.1` | node-library | node-cjs, node-esm | node | `>=22` | [packages/overlays/overlay](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/overlays/overlay) | -| 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) | +| messaging | `@bsv/authsocket` | `2.1.8` | node-library | node-cjs, node-esm | node | `>=22` | [packages/messaging/authsocket](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/messaging/authsocket) | +| messaging | `@bsv/authsocket-client` | `2.1.7` | browser-library | browser-bundler, browser-esm, node-cjs, node-esm, umd-global | browser, node, umd | `>=22` | [packages/messaging/authsocket-client](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/messaging/authsocket-client) | +| messaging | `@bsv/message-box-client` | `2.4.3` | browser-library | browser-bundler, browser-esm, node-cjs, node-esm, umd-global | browser, node, umd | `>=22` | [packages/messaging/message-box-client](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/messaging/message-box-client) | +| messaging | `@bsv/paymail` | `2.4.8` | browser-library | browser-bundler, browser-esm, node-cjs, node-esm | browser, node | `>=22` | [packages/messaging/ts-paymail](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/messaging/ts-paymail) | +| middleware | `@bsv/402-pay` | `0.2.6` | browser-library | browser-bundler, browser-esm, node-cjs, node-esm | browser, node | `>=22` | [packages/middleware/402-pay](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/middleware/402-pay) | +| middleware | `@bsv/auth` | `0.1.5` | node-library | node-cjs, node-esm | node | `>=22` | [packages/middleware/auth](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/middleware/auth) | +| middleware | `@bsv/auth-express-middleware` | `2.2.4` | node-library | node-cjs, node-esm | node | `>=22` | [packages/middleware/auth-express-middleware](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/middleware/auth-express-middleware) | +| middleware | `@bsv/payment-express-middleware` | `2.1.7` | node-library | node-cjs, node-esm | node | `>=22` | [packages/middleware/payment-express-middleware](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/middleware/payment-express-middleware) | +| network | `@bsv/chirp` | `0.1.2` | browser-library | browser-bundler, browser-esm, cli, node-esm | browser, node | `>=22` | [packages/network/chirp](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/network/chirp) | +| network | `@bsv/teranode-listener` | `1.1.6` | node-library | node-esm | node | `>=22` | [packages/network/ts-p2p](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/network/ts-p2p) | +| overlays | `@bsv/gasp` | `1.3.7` | browser-library | browser-bundler, browser-esm, node-cjs, node-esm | browser, node | `>=22` | [packages/overlays/gasp-core](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/overlays/gasp-core) | +| overlays | `@bsv/overlay` | `2.3.2` | node-library | node-cjs, node-esm | node | `>=22` | [packages/overlays/overlay](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/overlays/overlay) | +| overlays | `@bsv/overlay-discovery-services` | `2.2.2` | 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.2` | 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.2` | node-library | node-esm | node | `>=22` | [packages/overlays/topics](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/overlays/topics) | | sdk | `@bsv/sdk` | `3.0.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) | -| 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) | +| sdk | `@bsv/verifast` | `0.3.6` | 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.3` | 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.5` | 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.1` | 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.7` | 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.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 ceb84b350..99b715c63 100644 --- a/governance/package-release-notes.json +++ b/governance/package-release-notes.json @@ -7,8 +7,8 @@ "name": "@bsv/402-pay", "publishedVersion": "0.2.1", "releaseType": "patch", - "summary": "Adds an exact-tarball Vite and esbuild contract for the browser-safe client entry point, including a bundle-size ratchet and an assertion that server exports never leak into browser consumers. Retains the hash-pinned pre-uniformization Open BSV License version 4 grant as a scoped continuity notice. Standardizes first-party author metadata on the current BSV Association name.", - "migration": "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." + "summary": "Adds an exact-tarball Vite and esbuild contract for the browser-safe client entry point, including a bundle-size ratchet and an assertion that server exports never leak into browser consumers. Retains the hash-pinned pre-uniformization Open BSV License version 4 grant as a scoped continuity notice. Standardizes first-party author metadata on the current BSV Association name. Fixes clean installs with SDK 3 by accepting its peer version alongside the existing SDK 2 range.", + "migration": "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. SDK 3 is supported alongside the existing SDK 2 peer range. Applications adopting SDK 3 must follow packages/sdk/docs/overlay-lookup-migration.md for changed lookup failure, deadline and persistence behavior; existing SDK 2 installations remain supported." }, { "name": "@bsv/air-gap", @@ -21,148 +21,148 @@ "name": "@bsv/amountinator", "publishedVersion": "2.1.1", "releaseType": "patch", - "summary": "Adds the strict package and artifact contract, hardens amount formatting, and standardizes first-party author metadata on the current BSV Association name.", - "migration": "No consumer migration is required; this is a backward-compatible patch candidate." + "summary": "Adds the strict package and artifact contract, hardens amount formatting, and standardizes first-party author metadata on the current BSV Association name. Fixes clean installs with SDK 3 by accepting its peer version alongside the existing SDK 2 range.", + "migration": "No consumer migration is required; this is a backward-compatible patch candidate. SDK 3 is supported alongside the existing SDK 2 peer range. Applications adopting SDK 3 must follow packages/sdk/docs/overlay-lookup-migration.md for changed lookup failure, deadline and persistence behavior; existing SDK 2 installations remain supported." }, { "name": "@bsv/auth", "publishedVersion": "0.1.1", "releaseType": "patch", - "summary": "Adopts the governed strict TypeScript profile and repository-wide zero-warning lint and formatting contract. Standardizes first-party author metadata on the current BSV Association name.", - "migration": "No consumer migration is required; authentication APIs, wire behavior, and runtime defaults are unchanged." + "summary": "Adopts the governed strict TypeScript profile and repository-wide zero-warning lint and formatting contract. Standardizes first-party author metadata on the current BSV Association name. Fixes clean installs with SDK 3 by accepting its peer version alongside the existing SDK 2 range.", + "migration": "No consumer migration is required; authentication APIs, wire behavior, and runtime defaults are unchanged. SDK 3 is supported alongside the existing SDK 2 peer range. Applications adopting SDK 3 must follow packages/sdk/docs/overlay-lookup-migration.md for changed lookup failure, deadline and persistence behavior; existing SDK 2 installations remain supported." }, { "name": "@bsv/auth-express-middleware", "publishedVersion": "2.2.0", "releaseType": "patch", - "summary": "Contains late BRC-104 peer-processing failures after the HTTP response or connection has already settled, and preserves BRC-100 byte fields in handshake and buffered JSON responses across number-array, Uint8Array, and historical numeric-key JSON runtimes. Retains the hash-pinned pre-uniformization Open BSV License version 4 grant as a scoped continuity notice. Standardizes first-party author metadata on the current BSV Association name.", - "migration": "No API migration is required. Upgrade to @bsv/sdk 2.4.1 or later for the shared byte-boundary contract. Generic signed application-body canonicalization remains unchanged so old and new peers verify identical bytes. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/ with the package." + "summary": "Contains late BRC-104 peer-processing failures after the HTTP response or connection has already settled, and preserves BRC-100 byte fields in handshake and buffered JSON responses across number-array, Uint8Array, and historical numeric-key JSON runtimes. Retains the hash-pinned pre-uniformization Open BSV License version 4 grant as a scoped continuity notice. Standardizes first-party author metadata on the current BSV Association name. Fixes clean installs with SDK 3 by accepting its peer version alongside the existing SDK 2 range.", + "migration": "No API migration is required. Upgrade to @bsv/sdk 2.4.1 or later for the shared byte-boundary contract. Generic signed application-body canonicalization remains unchanged so old and new peers verify identical bytes. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/ with the package. SDK 3 is supported alongside the existing SDK 2 peer range. Applications adopting SDK 3 must follow packages/sdk/docs/overlay-lookup-migration.md for changed lookup failure, deadline and persistence behavior; existing SDK 2 installations remain supported." }, { "name": "@bsv/authsocket", "publishedVersion": "2.1.1", "releaseType": "patch", - "summary": "Contains authentication and application callback failures, caps per-socket authentication concurrency, serializes real typed arrays portably, and preserves arbitrary signed event JSON exactly. Retains the hash-pinned pre-uniformization Open BSV License version 4 grant as a scoped continuity notice. Standardizes first-party author metadata on the current BSV Association name.", - "migration": "No API migration is required. Existing event data, including numeric-key objects under byte-like names, is unchanged; typed payment protocols recover historical byte objects at their explicit fields. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/ with the package." + "summary": "Contains authentication and application callback failures, caps per-socket authentication concurrency, serializes real typed arrays portably, and preserves arbitrary signed event JSON exactly. Retains the hash-pinned pre-uniformization Open BSV License version 4 grant as a scoped continuity notice. Standardizes first-party author metadata on the current BSV Association name. Fixes clean installs with SDK 3 by accepting its peer version alongside the existing SDK 2 range.", + "migration": "No API migration is required. Existing event data, including numeric-key objects under byte-like names, is unchanged; typed payment protocols recover historical byte objects at their explicit fields. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/ with the package. SDK 3 is supported alongside the existing SDK 2 peer range. Applications adopting SDK 3 must follow packages/sdk/docs/overlay-lookup-migration.md for changed lookup failure, deadline and persistence behavior; existing SDK 2 installations remain supported." }, { "name": "@bsv/authsocket-client", "publishedVersion": "2.1.1", "releaseType": "patch", - "summary": "Contains authentication and application callback failures, caps authentication concurrency, serializes real typed arrays portably, preserves arbitrary signed event JSON exactly, and ships the complete SDK incorporated-material notice archive with a retained UMD notice banner. Standardizes first-party author metadata on the current BSV Association name.", - "migration": "No API migration is required. Existing event data, including numeric-key objects under byte-like names, is unchanged; typed payment protocols recover historical byte objects at their explicit fields. Distributors who copy the UMD file must keep THIRD_PARTY_NOTICES.md and LICENSES/ with it." + "summary": "Contains authentication and application callback failures, caps authentication concurrency, serializes real typed arrays portably, preserves arbitrary signed event JSON exactly, and ships the complete SDK incorporated-material notice archive with a retained UMD notice banner. Standardizes first-party author metadata on the current BSV Association name. Fixes clean installs with SDK 3 by accepting its peer version alongside the existing SDK 2 range.", + "migration": "No API migration is required. Existing event data, including numeric-key objects under byte-like names, is unchanged; typed payment protocols recover historical byte objects at their explicit fields. Distributors who copy the UMD file must keep THIRD_PARTY_NOTICES.md and LICENSES/ with it. SDK 3 is supported alongside the existing SDK 2 peer range. Applications adopting SDK 3 must follow packages/sdk/docs/overlay-lookup-migration.md for changed lookup failure, deadline and persistence behavior; existing SDK 2 installations remain supported." }, { "name": "@bsv/btms", "publishedVersion": "1.1.1", "releaseType": "minor", - "summary": "Adds TerraTestNet and preserves token settlement and refund transaction bytes across number-array, Uint8Array, and historical numeric-key JSON wallet runtimes. Standardizes first-party author metadata on the current BSV Association name.", - "migration": "Existing local, mainnet, testnet, and number-array behavior is unchanged. TTN consumers select networkPreset teratestnet; all consumers should upgrade to @bsv/sdk 2.4.1 or later for byte-boundary compatibility." + "summary": "Adds TerraTestNet and preserves token settlement and refund transaction bytes across number-array, Uint8Array, and historical numeric-key JSON wallet runtimes. Standardizes first-party author metadata on the current BSV Association name. Fixes clean installs with SDK 3 by accepting its peer version alongside the existing SDK 2 range.", + "migration": "Existing local, mainnet, testnet, and number-array behavior is unchanged. TTN consumers select networkPreset teratestnet; all consumers should upgrade to @bsv/sdk 2.4.1 or later for byte-boundary compatibility. SDK 3 is supported alongside the existing SDK 2 peer range. Applications adopting SDK 3 must follow packages/sdk/docs/overlay-lookup-migration.md for changed lookup failure, deadline and persistence behavior; existing SDK 2 installations remain supported." }, { "name": "@bsv/btms-permission-module", "publishedVersion": "1.1.1", "releaseType": "patch", - "summary": "Adopts the governed strict TypeScript profile and repository-wide zero-warning lint and formatting contract. Standardizes first-party author metadata on the current BSV Association name.", - "migration": "No consumer migration is required; permission-module APIs and token semantics are unchanged." + "summary": "Adopts the governed strict TypeScript profile and repository-wide zero-warning lint and formatting contract. Standardizes first-party author metadata on the current BSV Association name. Fixes clean installs with SDK 3 by accepting its peer version alongside the existing SDK 2 range.", + "migration": "No consumer migration is required; permission-module APIs and token semantics are unchanged. SDK 3 is supported alongside the existing SDK 2 peer range. Applications adopting SDK 3 must follow packages/sdk/docs/overlay-lookup-migration.md for changed lookup failure, deadline and persistence behavior; existing SDK 2 installations remain supported." }, { "name": "@bsv/ecpm-permission-module", "publishedVersion": "0.0.0", "releaseType": "minor", - "summary": "Introduces the BRC-98 p ecpm semantic module, applying or removing a wallet-derived scalar from validated secp256k1 points through the existing BRC-100 getPublicKey surface, including reason-scoped privileged authorization and privileged-key-provider hooks.", - "migration": "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." + "summary": "Introduces the BRC-98 p ecpm semantic module, applying or removing a wallet-derived scalar from validated secp256k1 points through the existing BRC-100 getPublicKey surface, including reason-scoped privileged authorization and privileged-key-provider hooks. Fixes clean installs with SDK 3 by accepting its peer version alongside the existing SDK 2 range.", + "migration": "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. SDK 3 is supported alongside the existing SDK 2 peer range. Applications adopting SDK 3 must follow packages/sdk/docs/overlay-lookup-migration.md for changed lookup failure, deadline and persistence behavior; existing SDK 2 installations remain supported." }, { "name": "@bsv/chirp", "publishedVersion": "0.0.0", "releaseType": "minor", - "summary": "Introduces the BRC-167 CHIRP reference implementation: canonical Merkle codecs and vectors, progressive and resumable multi-host publication, bounded interleaved and range-aware resolution, a verified-object cache, browser and Node byte-source adapters, closure validation, and publication/retrieval/verification CLI commands. Standardizes first-party author metadata on the current BSV Association name.", - "migration": "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." + "summary": "Introduces the BRC-167 CHIRP reference implementation: canonical Merkle codecs and vectors, progressive and resumable multi-host publication, bounded interleaved and range-aware resolution, a verified-object cache, browser and Node byte-source adapters, closure validation, and publication/retrieval/verification CLI commands. Standardizes first-party author metadata on the current BSV Association name. Fixes clean installs with SDK 3 by accepting its peer version alongside the existing SDK 2 range.", + "migration": "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. SDK 3 is supported alongside the existing SDK 2 peer range. Applications adopting SDK 3 must follow packages/sdk/docs/overlay-lookup-migration.md for changed lookup failure, deadline and persistence behavior; existing SDK 2 installations remain supported." }, { "name": "@bsv/lch", "publishedVersion": "0.0.0", "releaseType": "minor", - "summary": "Introduces the published BRC-170 Licensed Content Header reference implementation: deterministic CBOR and framing, segmented authenticated encryption, signed acquisition objects, deterministic-CBOR HTTP client/server bindings, independently routed and replay-safe Payee receipts, signed readiness and pending settlement, receipt-complete and offline-capable authorized-output settlement profiles, authenticated late Delivery retrieval, an explicit recovery-safe multipay buyer workflow with exact License, fulfillment, and key-grant binding, commit-aware IndexedDB persistence, UHRP and CHIRP content adapters with resolved-host failover, authority revocation, bounded DAG composition, deterministic malformed-input handling, and URL-canonical SSRF protection including IPv4-mapped IPv6 literals.", - "migration": "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." + "summary": "Introduces the published BRC-170 Licensed Content Header reference implementation: deterministic CBOR and framing, segmented authenticated encryption, signed acquisition objects, deterministic-CBOR HTTP client/server bindings, independently routed and replay-safe Payee receipts, signed readiness and pending settlement, receipt-complete and offline-capable authorized-output settlement profiles, authenticated late Delivery retrieval, an explicit recovery-safe multipay buyer workflow with exact License, fulfillment, and key-grant binding, commit-aware IndexedDB persistence, UHRP and CHIRP content adapters with resolved-host failover, authority revocation, bounded DAG composition, deterministic malformed-input handling, and URL-canonical SSRF protection including IPv4-mapped IPv6 literals. Fixes clean installs with SDK 3 by accepting its peer version alongside the existing SDK 2 range.", + "migration": "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. SDK 3 is supported alongside the existing SDK 2 peer range. Applications adopting SDK 3 must follow packages/sdk/docs/overlay-lookup-migration.md for changed lookup failure, deadline and persistence behavior; existing SDK 2 installations remain supported." }, { "name": "@bsv/did", "publishedVersion": "0.2.1", "releaseType": "patch", - "summary": "Adds an exact-tarball Vite and esbuild browser-consumer contract with public-export, source-map, server-dependency, and bundle-size ratchets. Standardizes first-party author metadata on the current BSV Association name.", - "migration": "No consumer migration is required; DID APIs, encodings, credential behavior, and supported import forms are unchanged." + "summary": "Adds an exact-tarball Vite and esbuild browser-consumer contract with public-export, source-map, server-dependency, and bundle-size ratchets. Standardizes first-party author metadata on the current BSV Association name. Fixes clean installs with SDK 3 by accepting its peer version alongside the existing SDK 2 range.", + "migration": "No consumer migration is required; DID APIs, encodings, credential behavior, and supported import forms are unchanged. SDK 3 is supported alongside the existing SDK 2 peer range. Applications adopting SDK 3 must follow packages/sdk/docs/overlay-lookup-migration.md for changed lookup failure, deadline and persistence behavior; existing SDK 2 installations remain supported." }, { "name": "@bsv/did-client", "publishedVersion": "1.2.1", "releaseType": "minor", - "summary": "Adds TerraTestNet as an explicit DID overlay-network preset, retains the governed package-quality contract, ships the complete SDK incorporated-material notice archive with a retained UMD notice banner, and standardizes first-party author metadata on the current BSV Association name.", - "migration": "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." + "summary": "Adds TerraTestNet as an explicit DID overlay-network preset, retains the governed package-quality contract, ships the complete SDK incorporated-material notice archive with a retained UMD notice banner, and standardizes first-party author metadata on the current BSV Association name. Fixes clean installs with SDK 3 by accepting its peer version alongside the existing SDK 2 range.", + "migration": "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. SDK 3 is supported alongside the existing SDK 2 peer range. Applications adopting SDK 3 must follow packages/sdk/docs/overlay-lookup-migration.md for changed lookup failure, deadline and persistence behavior; existing SDK 2 installations remain supported." }, { "name": "@bsv/fund-wallet", "publishedVersion": "1.4.1", "releaseType": "patch", - "summary": "Adopts the governed strict TypeScript profile and repository-wide zero-warning lint and formatting contract, and retains the imported fund-metanet source's hash-pinned ISC provenance and notice from its exact source and npm publication. Standardizes first-party author metadata on the current BSV Association name.", - "migration": "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." + "summary": "Adopts the governed strict TypeScript profile and repository-wide zero-warning lint and formatting contract, and retains the imported fund-metanet source's hash-pinned ISC provenance and notice from its exact source and npm publication. Standardizes first-party author metadata on the current BSV Association name. Fixes clean installs with SDK 3 by accepting its peer version alongside the existing SDK 2 range.", + "migration": "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. SDK 3 is supported alongside the existing SDK 2 peer range. Applications adopting SDK 3 must follow packages/sdk/docs/overlay-lookup-migration.md for changed lookup failure, deadline and persistence behavior; existing SDK 2 installations remain supported." }, { "name": "@bsv/gasp", "publishedVersion": "1.3.1", "releaseType": "patch", - "summary": "Preserves the positional GASP constructor contract while improving declaration metadata and production maintainability, and adds an exact-tarball browser-consumer and bundle-size contract. Retains the hash-pinned pre-uniformization Open BSV License version 4 grant as a scoped continuity notice. Standardizes first-party author metadata on the current BSV Association name.", - "migration": "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." + "summary": "Preserves the positional GASP constructor contract while improving declaration metadata and production maintainability, and adds an exact-tarball browser-consumer and bundle-size contract. Retains the hash-pinned pre-uniformization Open BSV License version 4 grant as a scoped continuity notice. Standardizes first-party author metadata on the current BSV Association name. Fixes clean installs with SDK 3 by accepting its peer version alongside the existing SDK 2 range.", + "migration": "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. SDK 3 is supported alongside the existing SDK 2 peer range. Applications adopting SDK 3 must follow packages/sdk/docs/overlay-lookup-migration.md for changed lookup failure, deadline and persistence behavior; existing SDK 2 installations remain supported." }, { "name": "@bsv/message-box-client", "publishedVersion": "2.4.0", "releaseType": "patch", - "summary": "Preserves BRC-29 payments, paid messages, remittances, and peer tokens across binary Wallet Wire results and historical numeric-key JSON payloads, rejects sparse or invalid byte records, and ships the complete SDK incorporated-material notice archive with a retained UMD notice banner. Standardizes first-party author metadata on the current BSV Association name.", - "migration": "No API migration is required. Upgrade @bsv/sdk and @bsv/message-box-client together; historical number-array wallets, current Uint8Array substrates, and already-pending numeric-key messages interoperate through the same portable transaction form. Distributors who copy the UMD file must keep THIRD_PARTY_NOTICES.md and LICENSES/ with it." + "summary": "Preserves BRC-29 payments, paid messages, remittances, and peer tokens across binary Wallet Wire results and historical numeric-key JSON payloads, rejects sparse or invalid byte records, and ships the complete SDK incorporated-material notice archive with a retained UMD notice banner. Standardizes first-party author metadata on the current BSV Association name. Fixes clean installs with SDK 3 by accepting its peer version alongside the existing SDK 2 range.", + "migration": "No API migration is required. Upgrade @bsv/sdk and @bsv/message-box-client together; historical number-array wallets, current Uint8Array substrates, and already-pending numeric-key messages interoperate through the same portable transaction form. Distributors who copy the UMD file must keep THIRD_PARTY_NOTICES.md and LICENSES/ with it. SDK 3 is supported alongside the existing SDK 2 peer range. Applications adopting SDK 3 must follow packages/sdk/docs/overlay-lookup-migration.md for changed lookup failure, deadline and persistence behavior; existing SDK 2 installations remain supported." }, { "name": "@bsv/overlay", "publishedVersion": "2.2.1", "releaseType": "minor", - "summary": "Adds an engine lookup-result cardinality ceiling before transaction/proof hydration and an optional TopicManager abort hook for provisional admission state when strict transaction broadcast fails. Retains the hash-pinned pre-uniformization Open BSV License version 4 grant as a scoped continuity notice.", - "migration": "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." + "summary": "Adds an engine lookup-result cardinality ceiling before transaction/proof hydration and an optional TopicManager abort hook for provisional admission state when strict transaction broadcast fails. Retains the hash-pinned pre-uniformization Open BSV License version 4 grant as a scoped continuity notice. Fixes clean installs with SDK 3 by accepting its peer version alongside the existing SDK 2 range.", + "migration": "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. SDK 3 is supported alongside the existing SDK 2 peer range. Applications adopting SDK 3 must follow packages/sdk/docs/overlay-lookup-migration.md for changed lookup failure, deadline and persistence behavior; existing SDK 2 installations remain supported." }, { "name": "@bsv/overlay-discovery-services", "publishedVersion": "2.1.1", "releaseType": "minor", - "summary": "Extends WalletAdvertiser to TerraTestNet with chain-isolated SHIP/SLAP resolution and TTN wallet services. Retains the hash-pinned pre-uniformization Open BSV License version 4 grant as a scoped continuity notice.", - "migration": "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." + "summary": "Extends WalletAdvertiser to TerraTestNet with chain-isolated SHIP/SLAP resolution and TTN wallet services. Retains the hash-pinned pre-uniformization Open BSV License version 4 grant as a scoped continuity notice. Fixes clean installs with SDK 3 by accepting its peer version alongside the existing SDK 2 range.", + "migration": "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. SDK 3 is supported alongside the existing SDK 2 peer range. Applications adopting SDK 3 must follow packages/sdk/docs/overlay-lookup-migration.md for changed lookup failure, deadline and persistence behavior; existing SDK 2 installations remain supported." }, { "name": "@bsv/overlay-express", "publishedVersion": "2.5.0", "releaseType": "minor", - "summary": "Adds a TerraTestNet runtime mode with TTN discovery roots, wallet advertising, and explicit ChainTracks requirements while retaining Arcade-first propagation. Retains the hash-pinned pre-uniformization Open BSV License version 4 grant as a scoped continuity notice.", - "migration": "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." + "summary": "Adds a TerraTestNet runtime mode with TTN discovery roots, wallet advertising, and explicit ChainTracks requirements while retaining Arcade-first propagation. Retains the hash-pinned pre-uniformization Open BSV License version 4 grant as a scoped continuity notice. Fixes clean installs with SDK 3 by accepting its peer version alongside the existing SDK 2 range.", + "migration": "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. SDK 3 is supported alongside the existing SDK 2 peer range. Applications adopting SDK 3 must follow packages/sdk/docs/overlay-lookup-migration.md for changed lookup failure, deadline and persistence behavior; existing SDK 2 installations remain supported." }, { "name": "@bsv/overlay-topics", "publishedVersion": "1.6.10", "releaseType": "minor", - "summary": "Adds persistent first-writer reservations for UMP presentation and recovery hashes, aborts provisional claims after strict broadcast failure, keeps confirmed owners protected until successor indexing, retries transient initialization, marks one-time legacy bootstrap, and returns the newest bounded legacy candidates for verified lineage or an operator pin. It also retains the collection-index resilience and opt-in repair controls from the prior candidate. Standardizes first-party author metadata on the current BSV Association name.", - "migration": "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." + "summary": "Adds persistent first-writer reservations for UMP presentation and recovery hashes, aborts provisional claims after strict broadcast failure, keeps confirmed owners protected until successor indexing, retries transient initialization, marks one-time legacy bootstrap, and returns the newest bounded legacy candidates for verified lineage or an operator pin. It also retains the collection-index resilience and opt-in repair controls from the prior candidate. Standardizes first-party author metadata on the current BSV Association name. Fixes clean installs with SDK 3 by accepting its peer version alongside the existing SDK 2 range.", + "migration": "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. SDK 3 is supported alongside the existing SDK 2 peer range. Applications adopting SDK 3 must follow packages/sdk/docs/overlay-lookup-migration.md for changed lookup failure, deadline and persistence behavior; existing SDK 2 installations remain supported." }, { "name": "@bsv/paymail", "publishedVersion": "2.4.2", "releaseType": "patch", - "summary": "Modernizes the package contract, hardens DNS, capability discovery, and browser-compatible Paymail behavior, and shares the host application's Express runtime and types for server routers. Removes local copies of externally authored specification documents whose upstream repositories do not assert reusable terms, replacing them with authoritative links. Retains the hash-pinned pre-uniformization Open BSV License version 4 grant as a scoped continuity notice. Standardizes first-party author metadata on the current BSV Association name.", - "migration": "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." + "summary": "Modernizes the package contract, hardens DNS, capability discovery, and browser-compatible Paymail behavior, and shares the host application's Express runtime and types for server routers. Removes local copies of externally authored specification documents whose upstream repositories do not assert reusable terms, replacing them with authoritative links. Retains the hash-pinned pre-uniformization Open BSV License version 4 grant as a scoped continuity notice. Standardizes first-party author metadata on the current BSV Association name. Fixes clean installs with SDK 3 by accepting its peer version alongside the existing SDK 2 range.", + "migration": "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. SDK 3 is supported alongside the existing SDK 2 peer range. Applications adopting SDK 3 must follow packages/sdk/docs/overlay-lookup-migration.md for changed lookup failure, deadline and persistence behavior; existing SDK 2 installations remain supported." }, { "name": "@bsv/payment-express-middleware", "publishedVersion": "2.1.1", "releaseType": "patch", - "summary": "Standardizes package quality, strengthens payment middleware validation, edge policy, and failure handling, and shares the host application's Express runtime and types. Retains the hash-pinned pre-uniformization Open BSV License version 4 grant as a scoped continuity notice. Standardizes first-party author metadata on the current BSV Association name.", - "migration": "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." + "summary": "Standardizes package quality, strengthens payment middleware validation, edge policy, and failure handling, and shares the host application's Express runtime and types. Retains the hash-pinned pre-uniformization Open BSV License version 4 grant as a scoped continuity notice. Standardizes first-party author metadata on the current BSV Association name. Fixes clean installs with SDK 3 by accepting its peer version alongside the existing SDK 2 range.", + "migration": "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. SDK 3 is supported alongside the existing SDK 2 peer range. Applications adopting SDK 3 must follow packages/sdk/docs/overlay-lookup-migration.md for changed lookup failure, deadline and persistence behavior; existing SDK 2 installations remain supported." }, { "name": "@bsv/sdk", @@ -175,64 +175,64 @@ "name": "@bsv/simple", "publishedVersion": "0.4.1", "releaseType": "minor", - "summary": "Adds TerraTestNet, preserves Message Box, token send/receive, and server-adapter transaction bytes across wallet runtimes, and restores the exact scoped MIT notice for the pre-monorepo source alongside Open BSV licensing for later first-party changes. Standardizes first-party author metadata on the current BSV Association name.", - "migration": "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. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/." + "summary": "Adds TerraTestNet, preserves Message Box, token send/receive, and server-adapter transaction bytes across wallet runtimes, and restores the exact scoped MIT notice for the pre-monorepo source alongside Open BSV licensing for later first-party changes. Standardizes first-party author metadata on the current BSV Association name. Fixes clean installs with SDK 3 by accepting its peer version alongside the existing SDK 2 range.", + "migration": "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. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/. SDK 3 is supported alongside the existing SDK 2 peer range. Applications adopting SDK 3 must follow packages/sdk/docs/overlay-lookup-migration.md for changed lookup failure, deadline and persistence behavior; existing SDK 2 installations remain supported." }, { "name": "@bsv/templates", "publishedVersion": "1.9.1", "releaseType": "minor", - "summary": "Adds the R1K1Wallet ScriptTemplate for salted P-256 hardware signing with an independent secp256k1 recovery path, including a checksummed static Runar artifact and browser-safe lazy decompression. Also consolidates MultiPushDrop script construction without changing its output, adds an exact-tarball browser-consumer and bundle-size contract, and points contributors to the canonical root policy. Retains the hash-pinned pre-uniformization Open BSV License version 4 grant as a scoped continuity notice.", - "migration": "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." + "summary": "Adds the R1K1Wallet ScriptTemplate for salted P-256 hardware signing with an independent secp256k1 recovery path, including a checksummed static Runar artifact and browser-safe lazy decompression. Also consolidates MultiPushDrop script construction without changing its output, adds an exact-tarball browser-consumer and bundle-size contract, and points contributors to the canonical root policy. Retains the hash-pinned pre-uniformization Open BSV License version 4 grant as a scoped continuity notice. Fixes clean installs with SDK 3 by accepting its peer version alongside the existing SDK 2 range.", + "migration": "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. SDK 3 is supported alongside the existing SDK 2 peer range. Applications adopting SDK 3 must follow packages/sdk/docs/overlay-lookup-migration.md for changed lookup failure, deadline and persistence behavior; existing SDK 2 installations remain supported." }, { "name": "@bsv/teranode-listener", "publishedVersion": "1.1.1", "releaseType": "patch", - "summary": "Refreshes the compatible libp2p transport, discovery, identification, DHT, peer-ID, ping, and private-network dependency set, and retains the imported ts-p2p source's hash-pinned MIT provenance and notice from its exact source and npm publication. Standardizes first-party author metadata on the current BSV Association name.", - "migration": "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." + "summary": "Refreshes the compatible libp2p transport, discovery, identification, DHT, peer-ID, ping, and private-network dependency set, and retains the imported ts-p2p source's hash-pinned MIT provenance and notice from its exact source and npm publication. Standardizes first-party author metadata on the current BSV Association name. Fixes clean installs with SDK 3 by accepting its peer version alongside the existing SDK 2 range.", + "migration": "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. SDK 3 is supported alongside the existing SDK 2 peer range. Applications adopting SDK 3 must follow packages/sdk/docs/overlay-lookup-migration.md for changed lookup failure, deadline and persistence behavior; existing SDK 2 installations remain supported." }, { "name": "@bsv/verifast", "publishedVersion": "0.3.0", "releaseType": "patch", - "summary": "Adds exact-tarball browser, WASM, worker, strict-CSP, streaming-fallback, UMD source-map, and bundle contracts, and now ships hash-pinned BDK, bitcoin-sv, libsecp256k1, Emscripten, musl, LLVM, and Boost notices with a retained UMD banner and SBOM component records. Standardizes first-party author metadata on the current BSV Association name.", - "migration": "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." + "summary": "Adds exact-tarball browser, WASM, worker, strict-CSP, streaming-fallback, UMD source-map, and bundle contracts, and now ships hash-pinned BDK, bitcoin-sv, libsecp256k1, Emscripten, musl, LLVM, and Boost notices with a retained UMD banner and SBOM component records. Standardizes first-party author metadata on the current BSV Association name. Fixes clean installs with SDK 3 by accepting its peer version alongside the existing SDK 2 range.", + "migration": "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. SDK 3 is supported alongside the existing SDK 2 peer range. Applications adopting SDK 3 must follow packages/sdk/docs/overlay-lookup-migration.md for changed lookup failure, deadline and persistence behavior; existing SDK 2 installations remain supported." }, { "name": "@bsv/wallet-helper", "publishedVersion": "0.1.1", "releaseType": "patch", - "summary": "Adds strict package contracts and hardens transaction-builder and OP_RETURN validation behavior. Retains the hash-pinned pre-uniformization Open BSV License Version 6 grant as a scoped continuity notice. Standardizes first-party author metadata on the current BSV Association name.", - "migration": "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." + "summary": "Adds strict package contracts and hardens transaction-builder and OP_RETURN validation behavior. Retains the hash-pinned pre-uniformization Open BSV License Version 6 grant as a scoped continuity notice. Standardizes first-party author metadata on the current BSV Association name. Fixes clean installs with SDK 3 by accepting its peer version alongside the existing SDK 2 range.", + "migration": "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. SDK 3 is supported alongside the existing SDK 2 peer range. Applications adopting SDK 3 must follow packages/sdk/docs/overlay-lookup-migration.md for changed lookup failure, deadline and persistence behavior; existing SDK 2 installations remain supported." }, { "name": "@bsv/wallet-relay", "publishedVersion": "0.2.2", "releaseType": "minor", - "summary": "Adds the strict package contract and preserves wallet RPC, encrypted relay payloads, signatures, and transaction bytes across number-array, Uint8Array, Buffer, and historical numeric-key JSON runtimes. Standardizes first-party author metadata on the current BSV Association name.", - "migration": "No wallet RPC migration is required; upgrade to @bsv/sdk 2.4.1 or later. Existing relay sessions and number arrays remain valid, and host applications continue to provide their matching Express runtime and type graph." + "summary": "Adds the strict package contract and preserves wallet RPC, encrypted relay payloads, signatures, and transaction bytes across number-array, Uint8Array, Buffer, and historical numeric-key JSON runtimes. Standardizes first-party author metadata on the current BSV Association name. Fixes clean installs with SDK 3 by accepting its peer version alongside the existing SDK 2 range.", + "migration": "No wallet RPC migration is required; upgrade to @bsv/sdk 2.4.1 or later. Existing relay sessions and number arrays remain valid, and host applications continue to provide their matching Express runtime and type graph. SDK 3 is supported alongside the existing SDK 2 peer range. Applications adopting SDK 3 must follow packages/sdk/docs/overlay-lookup-migration.md for changed lookup failure, deadline and persistence behavior; existing SDK 2 installations remain supported." }, { "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, 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, and permission modules require no migration. To use BRC-177, migrate every active Knex store before serving requests and run the default Wallet Toolbox monitor; IndexedDB upgrades automatically to schema version 5. Upgrade signer, active storage service, and remote monitor together to 2.11.0 or later; older remote storage is rejected before prefunding. 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. 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, retains BRC-95/BRC-100 compatibility and stable bounded pagination, removes the obsolete JSight application bundle, and preserves the earlier Open BSV grant. Fixes clean installs with SDK 3 by accepting its peer version alongside the existing SDK 2 range.", + "migration": "Existing actions, ordinary noSend calls, and permission modules require no migration. To use BRC-177, migrate every active Knex store before serving requests and run the default Wallet Toolbox monitor; IndexedDB upgrades automatically to schema version 5. Upgrade signer, active storage service, and remote monitor together to 2.11.0 or later; older remote storage is rejected before prefunding. 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. 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/. SDK 3 is supported alongside the existing SDK 2 peer range. Applications adopting SDK 3 must follow packages/sdk/docs/overlay-lookup-migration.md for changed lookup failure, deadline and persistence behavior; existing SDK 2 installations remain supported." }, { "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, and the optional semantic handleRequest hook. 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 and permission modules require no migration; IndexedDB upgrades automatically. To use BRC-177 with remote storage, upgrade the active storage service and its default monitor to Wallet Toolbox 2.11.0 or later before upgrading clients; an older server is rejected before prefunding. 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. 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, and the optional semantic handleRequest hook. 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. Fixes clean installs with SDK 3 by accepting its peer version alongside the existing SDK 2 range.", + "migration": "Existing browser actions and permission modules require no migration; IndexedDB upgrades automatically. To use BRC-177 with remote storage, upgrade the active storage service and its default monitor to Wallet Toolbox 2.11.0 or later before upgrading clients; an older server is rejected before prefunding. 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. 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/. SDK 3 is supported alongside the existing SDK 2 peer range. Applications adopting SDK 3 must follow packages/sdk/docs/overlay-lookup-migration.md for changed lookup failure, deadline and persistence behavior; existing SDK 2 installations remain supported." }, { "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, and the optional semantic handleRequest hook. 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 and permission modules require no migration. To use BRC-177, migrate and upgrade the active remote storage service and its default monitor to Wallet Toolbox 2.11.0 or later before upgrading clients; an older server is rejected before prefunding. 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. 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, and the optional semantic handleRequest hook. 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. Fixes clean installs with SDK 3 by accepting its peer version alongside the existing SDK 2 range.", + "migration": "Existing mobile actions and permission modules require no migration. To use BRC-177, migrate and upgrade the active remote storage service and its default monitor to Wallet Toolbox 2.11.0 or later before upgrading clients; an older server is rejected before prefunding. 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. 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/. SDK 3 is supported alongside the existing SDK 2 peer range. Applications adopting SDK 3 must follow packages/sdk/docs/overlay-lookup-migration.md for changed lookup failure, deadline and persistence behavior; existing SDK 2 installations remain supported." }, { "name": "create-bsv-app", diff --git a/governance/repository-health/baselines.json b/governance/repository-health/baselines.json index aa6428bb0..f3172ed21 100644 --- a/governance/repository-health/baselines.json +++ b/governance/repository-health/baselines.json @@ -297,39 +297,39 @@ ] }, "publicPackageVersions": { - "@bsv/chirp": "0.1.1", - "@bsv/lch": "0.1.0", + "@bsv/chirp": "0.1.2", + "@bsv/lch": "0.1.1", "@bsv/air-gap": "0.1.2", - "@bsv/amountinator": "2.1.5", - "@bsv/wallet-helper": "0.1.7", + "@bsv/amountinator": "2.1.6", + "@bsv/wallet-helper": "0.1.8", "create-bsv-app": "1.1.1", - "@bsv/did": "0.2.5", - "@bsv/did-client": "1.3.1", - "@bsv/fund-wallet": "1.4.4", - "@bsv/simple": "0.5.2", - "@bsv/templates": "1.10.1", - "@bsv/authsocket": "2.1.7", - "@bsv/authsocket-client": "2.1.6", - "@bsv/message-box-client": "2.4.2", - "@bsv/paymail": "2.4.7", - "@bsv/402-pay": "0.2.5", - "@bsv/auth": "0.1.4", - "@bsv/auth-express-middleware": "2.2.3", - "@bsv/payment-express-middleware": "2.1.6", - "@bsv/teranode-listener": "1.1.5", - "@bsv/gasp": "1.3.6", - "@bsv/overlay": "2.3.1", - "@bsv/overlay-discovery-services": "2.2.1", - "@bsv/overlay-express": "2.6.1", - "@bsv/overlay-topics": "1.7.1", + "@bsv/did": "0.2.6", + "@bsv/did-client": "1.3.2", + "@bsv/fund-wallet": "1.4.5", + "@bsv/simple": "0.5.3", + "@bsv/templates": "1.10.2", + "@bsv/authsocket": "2.1.8", + "@bsv/authsocket-client": "2.1.7", + "@bsv/message-box-client": "2.4.3", + "@bsv/paymail": "2.4.8", + "@bsv/402-pay": "0.2.6", + "@bsv/auth": "0.1.5", + "@bsv/auth-express-middleware": "2.2.4", + "@bsv/payment-express-middleware": "2.1.7", + "@bsv/teranode-listener": "1.1.6", + "@bsv/gasp": "1.3.7", + "@bsv/overlay": "2.3.2", + "@bsv/overlay-discovery-services": "2.2.2", + "@bsv/overlay-express": "2.6.2", + "@bsv/overlay-topics": "1.7.2", "@bsv/sdk": "3.0.0", - "@bsv/verifast": "0.3.5", - "@bsv/btms": "1.2.2", - "@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/verifast": "0.3.6", + "@bsv/btms": "1.2.3", + "@bsv/btms-permission-module": "1.1.5", + "@bsv/ecpm-permission-module": "0.1.1", + "@bsv/wallet-relay": "0.3.7", + "@bsv/wallet-toolbox-client": "2.11.1", + "@bsv/wallet-toolbox-mobile": "2.11.1", + "@bsv/wallet-toolbox": "2.11.1" } } diff --git a/packages/content/lch/package.json b/packages/content/lch/package.json index a7e54e023..30ba757e6 100644 --- a/packages/content/lch/package.json +++ b/packages/content/lch/package.json @@ -1,6 +1,6 @@ { "name": "@bsv/lch", - "version": "0.1.0", + "version": "0.1.1", "description": "BRC-170 Licensed Content Header reference implementation", "author": "BSV Association", "license": "SEE LICENSE IN LICENSE.txt", @@ -46,7 +46,7 @@ }, "peerDependencies": { "@bsv/chirp": "^0.1.0", - "@bsv/sdk": "^2.4.1" + "@bsv/sdk": "^3.0.0 || ^2.4.1" }, "peerDependenciesMeta": { "@bsv/chirp": { diff --git a/packages/helpers/amountinator/package.json b/packages/helpers/amountinator/package.json index d4887f2d1..af25601b6 100644 --- a/packages/helpers/amountinator/package.json +++ b/packages/helpers/amountinator/package.json @@ -1,6 +1,6 @@ { "name": "@bsv/amountinator", - "version": "2.1.5", + "version": "2.1.6", "sideEffects": false, "engines": { "node": ">=22" @@ -79,7 +79,7 @@ "wallet" ], "peerDependencies": { - "@bsv/sdk": "^2.1.6" + "@bsv/sdk": "^3.0.0 || ^2.1.6" }, "peerDependenciesMeta": { "@bsv/sdk": { diff --git a/packages/helpers/bsv-wallet-helper/package.json b/packages/helpers/bsv-wallet-helper/package.json index 1ca6f0d9c..1db702bf9 100644 --- a/packages/helpers/bsv-wallet-helper/package.json +++ b/packages/helpers/bsv-wallet-helper/package.json @@ -1,6 +1,6 @@ { "name": "@bsv/wallet-helper", - "version": "0.1.7", + "version": "0.1.8", "sideEffects": false, "engines": { "node": ">=22" @@ -78,7 +78,7 @@ }, "homepage": "https://github.com/bsv-blockchain/ts-stack/tree/main/packages/helpers/bsv-wallet-helper#readme", "peerDependencies": { - "@bsv/sdk": "^2.1.6" + "@bsv/sdk": "^3.0.0 || ^2.1.6" }, "peerDependenciesMeta": { "@bsv/sdk": { diff --git a/packages/helpers/did-client/package.json b/packages/helpers/did-client/package.json index 59cdf6549..b6c78f778 100644 --- a/packages/helpers/did-client/package.json +++ b/packages/helpers/did-client/package.json @@ -1,6 +1,6 @@ { "name": "@bsv/did-client", - "version": "1.3.1", + "version": "1.3.2", "sideEffects": false, "engines": { "node": ">=22" @@ -97,7 +97,7 @@ "decentralized-identifier" ], "peerDependencies": { - "@bsv/sdk": "^2.4.0" + "@bsv/sdk": "^3.0.0 || ^2.4.0" }, "peerDependenciesMeta": { "@bsv/sdk": { diff --git a/packages/helpers/did/package.json b/packages/helpers/did/package.json index 6693f6df3..084384222 100644 --- a/packages/helpers/did/package.json +++ b/packages/helpers/did/package.json @@ -1,6 +1,6 @@ { "name": "@bsv/did", - "version": "0.2.5", + "version": "0.2.6", "sideEffects": false, "engines": { "node": ">=22" @@ -89,7 +89,7 @@ }, "homepage": "https://github.com/bsv-blockchain/ts-stack/tree/main/packages/helpers/did#readme", "peerDependencies": { - "@bsv/sdk": "^2.1.6" + "@bsv/sdk": "^3.0.0 || ^2.1.6" }, "peerDependenciesMeta": { "@bsv/sdk": { diff --git a/packages/helpers/fund-wallet/package.json b/packages/helpers/fund-wallet/package.json index 5bae554a0..c53d084b0 100644 --- a/packages/helpers/fund-wallet/package.json +++ b/packages/helpers/fund-wallet/package.json @@ -1,7 +1,7 @@ { "name": "@bsv/fund-wallet", "private": false, - "version": "1.4.4", + "version": "1.4.5", "sideEffects": false, "engines": { "node": ">=22" @@ -69,7 +69,7 @@ }, "homepage": "https://github.com/bsv-blockchain/ts-stack/tree/main/packages/helpers/fund-wallet#readme", "peerDependencies": { - "@bsv/sdk": "^2.1.6" + "@bsv/sdk": "^3.0.0 || ^2.1.6" }, "peerDependenciesMeta": { "@bsv/sdk": { diff --git a/packages/helpers/simple/package.json b/packages/helpers/simple/package.json index 9dd24ee6b..9b64538ba 100644 --- a/packages/helpers/simple/package.json +++ b/packages/helpers/simple/package.json @@ -1,6 +1,6 @@ { "name": "@bsv/simple", - "version": "0.5.2", + "version": "0.5.3", "sideEffects": false, "engines": { "node": ">=22" @@ -117,7 +117,7 @@ }, "homepage": "https://github.com/bsv-blockchain/ts-stack/tree/main/packages/helpers/simple#readme", "peerDependencies": { - "@bsv/sdk": "^2.4.1" + "@bsv/sdk": "^3.0.0 || ^2.4.1" }, "peerDependenciesMeta": { "@bsv/sdk": { diff --git a/packages/helpers/ts-templates/CHANGELOG.md b/packages/helpers/ts-templates/CHANGELOG.md index 86d7f0691..022631f6f 100644 --- a/packages/helpers/ts-templates/CHANGELOG.md +++ b/packages/helpers/ts-templates/CHANGELOG.md @@ -10,6 +10,10 @@ All notable changes to this project will be documented in this file. The format ## [Unreleased] +- Accept SDK 3 alongside the existing SDK 2 peer range. See the shared + [SDK 3 migration guide](https://github.com/bsv-blockchain/ts-stack/blob/main/packages/sdk/docs/overlay-lookup-migration.md) + when upgrading the application SDK. + ### Added - `R1K1Wallet`, a static Runar contract template with a salted P-256 hardware diff --git a/packages/helpers/ts-templates/package.json b/packages/helpers/ts-templates/package.json index 862ce7823..1f56b30e4 100644 --- a/packages/helpers/ts-templates/package.json +++ b/packages/helpers/ts-templates/package.json @@ -1,6 +1,6 @@ { "name": "@bsv/templates", - "version": "1.10.1", + "version": "1.10.2", "sideEffects": false, "engines": { "node": ">=22" @@ -89,7 +89,7 @@ "typescript": "npm:@typescript/typescript6@6.0.2" }, "peerDependencies": { - "@bsv/sdk": "^2.1.6" + "@bsv/sdk": "^3.0.0 || ^2.1.6" }, "peerDependenciesMeta": { "@bsv/sdk": { diff --git a/packages/messaging/authsocket-client/package.json b/packages/messaging/authsocket-client/package.json index b3a8b66b2..b99aa70b3 100644 --- a/packages/messaging/authsocket-client/package.json +++ b/packages/messaging/authsocket-client/package.json @@ -1,6 +1,6 @@ { "name": "@bsv/authsocket-client", - "version": "2.1.6", + "version": "2.1.7", "sideEffects": false, "engines": { "node": ">=22" @@ -87,7 +87,7 @@ "webpack-cli": "^7.2.2" }, "peerDependencies": { - "@bsv/sdk": "^2.4.1" + "@bsv/sdk": "^3.0.0 || ^2.4.1" }, "peerDependenciesMeta": { "@bsv/sdk": { diff --git a/packages/messaging/authsocket/package.json b/packages/messaging/authsocket/package.json index 140f66c32..da4f9aff6 100644 --- a/packages/messaging/authsocket/package.json +++ b/packages/messaging/authsocket/package.json @@ -1,6 +1,6 @@ { "name": "@bsv/authsocket", - "version": "2.1.7", + "version": "2.1.8", "sideEffects": false, "engines": { "node": ">=22" @@ -83,7 +83,7 @@ "typescript": "npm:@typescript/typescript6@6.0.2" }, "peerDependencies": { - "@bsv/sdk": "^2.4.1" + "@bsv/sdk": "^3.0.0 || ^2.4.1" }, "peerDependenciesMeta": { "@bsv/sdk": { diff --git a/packages/messaging/message-box-client/CHANGELOG.md b/packages/messaging/message-box-client/CHANGELOG.md index ddf8763fb..b95df64db 100644 --- a/packages/messaging/message-box-client/CHANGELOG.md +++ b/packages/messaging/message-box-client/CHANGELOG.md @@ -13,6 +13,10 @@ All notable changes to this project will be documented in this file. The format ## [Unreleased] +- Accept SDK 3 alongside the existing SDK 2 peer range. See the shared + [SDK 3 migration guide](https://github.com/bsv-blockchain/ts-stack/blob/main/packages/sdk/docs/overlay-lookup-migration.md) + when upgrading the application SDK. + ### Added - Added the `teratestnet` overlay preset. TTN clients must provide an explicit diff --git a/packages/messaging/message-box-client/package.json b/packages/messaging/message-box-client/package.json index 26a436c15..350538c8f 100644 --- a/packages/messaging/message-box-client/package.json +++ b/packages/messaging/message-box-client/package.json @@ -1,6 +1,6 @@ { "name": "@bsv/message-box-client", - "version": "2.4.2", + "version": "2.4.3", "sideEffects": false, "engines": { "node": ">=22" @@ -90,7 +90,7 @@ "@bsv/authsocket-client": "workspace:^" }, "peerDependencies": { - "@bsv/sdk": "^2.4.1" + "@bsv/sdk": "^3.0.0 || ^2.4.1" }, "peerDependenciesMeta": { "@bsv/sdk": { diff --git a/packages/messaging/ts-paymail/CHANGELOG.md b/packages/messaging/ts-paymail/CHANGELOG.md index 87da07229..8eaf63a7b 100644 --- a/packages/messaging/ts-paymail/CHANGELOG.md +++ b/packages/messaging/ts-paymail/CHANGELOG.md @@ -11,23 +11,33 @@ All notable changes to this project will be documented in this file. The format ## [Unreleased] +- Accept SDK 3 alongside the existing SDK 2 peer range. See the shared + [SDK 3 migration guide](https://github.com/bsv-blockchain/ts-stack/blob/main/packages/sdk/docs/overlay-lookup-migration.md) + when upgrading the application SDK. + ### Added + - (Include new features or significant user-visible enhancements here.) ### Changed + - Ship the Express declaration dependency needed by strict TypeScript consumers of the Paymail router API. ### Deprecated + - (List features that are in the process of being phased out or replaced.) ### Removed + - (Indicate features or capabilities that were taken out of the project.) ### Fixed + - (Document bugs that were fixed since the last release.) ### Security + - (Notify of any improvements related to security vulnerabilities or potential risks.) --- @@ -35,6 +45,7 @@ All notable changes to this project will be documented in this file. The format ## [1.0.1] - 2024-08-05 ### Fixed + - Allow 'note' parameter in paymail responses to be null as well as '' and undefined within Joi. --- @@ -42,20 +53,23 @@ All notable changes to this project will be documented in this file. The format ## [1.0.0] - 2024-07-22 ### Added + - (Include new features or significant user-visible enhancements here.) ### Changed + - Paymail signatures in both Client and Server modules - such that it conforms to existing paymail implementations. They use BS< over the txid not just the txid itself as the msg. -ts-paymail previously used a compact signature over sha256(txid) -go-paymail implementation uses a compact signature over sha256d(Bitcoin Signed Message:\n${txid}) -ts-paymail will now conform to go-paymail as this is in line with original documentation hosted by a third party at paymail's launch. -Few people enforce these signatures so no one has noticed until now. + ts-paymail previously used a compact signature over sha256(txid) + go-paymail implementation uses a compact signature over sha256d(Bitcoin Signed Message:\n${txid}) + ts-paymail will now conform to go-paymail as this is in line with original documentation hosted by a third party at paymail's launch. + Few people enforce these signatures so no one has noticed until now. --- ## [0.3.0] - YYYY-MM-DD ### Added + - Initial release --- @@ -68,22 +82,22 @@ Replace `X.X.X` with the new version number and `YYYY-MM-DD` with the release da ## [X.X.X] - YYYY-MM-DD ### Added -- +- ### Changed -- +- ### Deprecated -- +- ### Removed -- +- ### Fixed -- +- ### Security -- +- ``` Use this template as the starting point for each new version. Always update the "Unreleased" section with changes as they're implemented, and then move them under the new version header when that version is released. diff --git a/packages/messaging/ts-paymail/package.json b/packages/messaging/ts-paymail/package.json index aec8d55e9..39f02270f 100644 --- a/packages/messaging/ts-paymail/package.json +++ b/packages/messaging/ts-paymail/package.json @@ -1,6 +1,6 @@ { "name": "@bsv/paymail", - "version": "2.4.7", + "version": "2.4.8", "sideEffects": false, "engines": { "node": ">=22" @@ -210,7 +210,7 @@ "typescript": "npm:@typescript/typescript6@6.0.2" }, "peerDependencies": { - "@bsv/sdk": "^2.1.6", + "@bsv/sdk": "^3.0.0 || ^2.1.6", "@types/express": ">=4.17.0 <6", "express": ">=4.18.0 <6" }, diff --git a/packages/middleware/402-pay/CHANGELOG.md b/packages/middleware/402-pay/CHANGELOG.md index b33436906..a979b4146 100644 --- a/packages/middleware/402-pay/CHANGELOG.md +++ b/packages/middleware/402-pay/CHANGELOG.md @@ -1,8 +1,15 @@ # Changelog +## 0.2.6 + +- Accept SDK 3 alongside the existing SDK 2 peer range. See the shared + [SDK 3 migration guide](https://github.com/bsv-blockchain/ts-stack/blob/main/packages/sdk/docs/overlay-lookup-migration.md) + when upgrading the application SDK. + ## [0.1.0] - 2026-04-04 ### Added + - `create402Fetch` client wrapper — automatically handles 402 responses, constructs BRC-29 payments, and retries with `x-bsv-nonce`/`x-bsv-time` headers - `createPaymentMiddleware` Express-compatible server middleware — validates payment headers, enforces 30s freshness window, internalizes payment via wallet - `validatePayment` and `send402` server primitives for framework-agnostic use diff --git a/packages/middleware/402-pay/README.md b/packages/middleware/402-pay/README.md index 953e3cbb9..976c1cf1a 100644 --- a/packages/middleware/402-pay/README.md +++ b/packages/middleware/402-pay/README.md @@ -8,7 +8,7 @@ npm install @bsv/402-pay ``` -Peer dependency: `@bsv/sdk ^2.1.6`. The package supports Node.js 22+ and browser +Peer dependency: `@bsv/sdk ^3.0.0 || ^2.1.6`. The package supports Node.js 22+ and browser consumers, with matching ESM and CommonJS entry points and declarations for the package root, `/server`, and `/client`. @@ -22,13 +22,16 @@ import { createPaymentMiddleware } from '@bsv/402-pay/server' const app = express() -app.use('/articles/:slug', createPaymentMiddleware({ - wallet, // WalletInterface from @bsv/sdk - calculatePrice: (path) => { - // Return price in satoshis, or undefined to skip payment - return 100 - } -})) +app.use( + '/articles/:slug', + createPaymentMiddleware({ + wallet, // WalletInterface from @bsv/sdk + calculatePrice: path => { + // Return price in satoshis, or undefined to skip payment + return 100 + } + }) +) app.get('/articles/:slug', (req, res) => { // req.payment is set if payment was accepted @@ -82,20 +85,20 @@ always sent to the service. ### Server → Client -| Header | Description | -|---|---| -| `x-bsv-sats` | Required satoshi amount | +| Header | Description | +| -------------- | -------------------------- | +| `x-bsv-sats` | Required satoshi amount | | `x-bsv-server` | Server identity public key | ### Client → Server -| Header | Description | -|---|---| -| `x-bsv-beef` | Base64-encoded BEEF transaction | -| `x-bsv-sender` | Client identity public key | -| `x-bsv-nonce` | Base64-encoded derivation prefix | -| `x-bsv-time` | Unix millisecond timestamp | -| `x-bsv-vout` | Payment output index | +| Header | Description | +| -------------- | -------------------------------- | +| `x-bsv-beef` | Base64-encoded BEEF transaction | +| `x-bsv-sender` | Client identity public key | +| `x-bsv-nonce` | Base64-encoded derivation prefix | +| `x-bsv-time` | Unix millisecond timestamp | +| `x-bsv-vout` | Payment output index | ## Security and Replay Protection diff --git a/packages/middleware/402-pay/package.json b/packages/middleware/402-pay/package.json index e5409a83d..ab73c470b 100644 --- a/packages/middleware/402-pay/package.json +++ b/packages/middleware/402-pay/package.json @@ -1,6 +1,6 @@ { "name": "@bsv/402-pay", - "version": "0.2.5", + "version": "0.2.6", "sideEffects": false, "engines": { "node": ">=22" @@ -91,7 +91,7 @@ "author": "BSV Association", "license": "SEE LICENSE IN LICENSE.txt", "peerDependencies": { - "@bsv/sdk": "^2.1.6" + "@bsv/sdk": "^3.0.0 || ^2.1.6" }, "devDependencies": { "@bsv/sdk": "workspace:^", diff --git a/packages/middleware/auth-express-middleware/package.json b/packages/middleware/auth-express-middleware/package.json index e855f72d9..f90c1f5e9 100644 --- a/packages/middleware/auth-express-middleware/package.json +++ b/packages/middleware/auth-express-middleware/package.json @@ -1,6 +1,6 @@ { "name": "@bsv/auth-express-middleware", - "version": "2.2.3", + "version": "2.2.4", "sideEffects": false, "engines": { "node": ">=22" @@ -86,7 +86,7 @@ "mime-types": "^3.0.2" }, "peerDependencies": { - "@bsv/sdk": "^2.4.1", + "@bsv/sdk": "^3.0.0 || ^2.4.1", "@types/express": ">=4.17.0 <6", "express": ">=4.18.0 <6" }, diff --git a/packages/middleware/auth/package.json b/packages/middleware/auth/package.json index 8c7a04a7f..52dd3e9ab 100644 --- a/packages/middleware/auth/package.json +++ b/packages/middleware/auth/package.json @@ -1,6 +1,6 @@ { "name": "@bsv/auth", - "version": "0.1.4", + "version": "0.1.5", "sideEffects": false, "engines": { "node": ">=22" @@ -74,7 +74,7 @@ }, "homepage": "https://github.com/bsv-blockchain/ts-stack/tree/main/packages/middleware/auth#readme", "peerDependencies": { - "@bsv/sdk": "^2.1.6" + "@bsv/sdk": "^3.0.0 || ^2.1.6" }, "peerDependenciesMeta": { "@bsv/sdk": { diff --git a/packages/middleware/payment-express-middleware/package.json b/packages/middleware/payment-express-middleware/package.json index 24de10ec9..9ab4e7cc9 100644 --- a/packages/middleware/payment-express-middleware/package.json +++ b/packages/middleware/payment-express-middleware/package.json @@ -1,6 +1,6 @@ { "name": "@bsv/payment-express-middleware", - "version": "2.1.6", + "version": "2.1.7", "sideEffects": false, "engines": { "node": ">=22" @@ -83,7 +83,7 @@ }, "homepage": "https://github.com/bsv-blockchain/ts-stack/tree/main/packages/middleware/payment-express-middleware#readme", "peerDependencies": { - "@bsv/sdk": "^2.1.6", + "@bsv/sdk": "^3.0.0 || ^2.1.6", "@types/express": ">=4.17.0 <6", "express": ">=4.18.0 <6" }, diff --git a/packages/network/chirp/package.json b/packages/network/chirp/package.json index 8fb8517af..a9ca70488 100644 --- a/packages/network/chirp/package.json +++ b/packages/network/chirp/package.json @@ -1,6 +1,6 @@ { "name": "@bsv/chirp", - "version": "0.1.1", + "version": "0.1.2", "description": "BRC-167 Chunked, Hashed, Interleaved Resolution Protocol reference implementation", "author": "BSV Association", "license": "SEE LICENSE IN LICENSE.txt", @@ -55,7 +55,7 @@ "prepublishOnly": "pnpm build" }, "peerDependencies": { - "@bsv/sdk": "^2.4.1" + "@bsv/sdk": "^3.0.0 || ^2.4.1" }, "devDependencies": { "@bsv/sdk": "workspace:^", diff --git a/packages/network/ts-p2p/CHANGELOG.md b/packages/network/ts-p2p/CHANGELOG.md index e838152c1..dbd22aa52 100644 --- a/packages/network/ts-p2p/CHANGELOG.md +++ b/packages/network/ts-p2p/CHANGELOG.md @@ -9,23 +9,33 @@ All notable changes to this project will be documented in this file. The format ## [Unreleased] +- Accept SDK 3 alongside the existing SDK 2 peer range. See the shared + [SDK 3 migration guide](https://github.com/bsv-blockchain/ts-stack/blob/main/packages/sdk/docs/overlay-lookup-migration.md) + when upgrading the application SDK. + ### Added + - Optional typed decoder for the two-layer JSON wire format. New `decodeMessage()` / `tryDecodeMessage()` helpers and topic payload interfaces (`MessageEnvelope`, `BlockMessage`, `SubtreeMessage`, `RejectedTxMessage`, `NodeStatusMessage`, `FeePolicy`). Set `decodeMessages: true` on the listener to receive a typed `DecodedMessage` instead of raw `Uint8Array`. Backward compatible (defaults to off). ### Changed + - Refresh the compatible libp2p transport, discovery, identification, DHT, peer-ID, ping, and private-network maintenance releases. ### Deprecated + - (List features that are in the process of being phased out or replaced.) ### Removed + - (Indicate features or capabilities that were taken out of the project.) ### Fixed + - (Document bugs that were fixed since the last release.) ### Security + - (Notify of any improvements related to security vulnerabilities or potential risks.) --- @@ -33,6 +43,7 @@ All notable changes to this project will be documented in this file. The format ## [1.0.0] - YYYY-MM-DD ### Added + - Initial release --- @@ -45,22 +56,22 @@ Replace `X.X.X` with the new version number and `YYYY-MM-DD` with the release da ## [X.X.X] - YYYY-MM-DD ### Added -- +- ### Changed -- +- ### Deprecated -- +- ### Removed -- +- ### Fixed -- +- ### Security -- +- ``` Use this template as the starting point for each new version. Always update the "Unreleased" section with changes as they're implemented, and then move them under the new version header when that version is released. diff --git a/packages/network/ts-p2p/package.json b/packages/network/ts-p2p/package.json index 773c98481..7f04bed1a 100644 --- a/packages/network/ts-p2p/package.json +++ b/packages/network/ts-p2p/package.json @@ -1,6 +1,6 @@ { "name": "@bsv/teranode-listener", - "version": "1.1.5", + "version": "1.1.6", "sideEffects": false, "publishConfig": { "access": "public" @@ -90,7 +90,7 @@ "url": "https://github.com/bsv-blockchain/ts-stack/issues" }, "peerDependencies": { - "@bsv/sdk": "^2.1.6" + "@bsv/sdk": "^3.0.0 || ^2.1.6" }, "peerDependenciesMeta": { "@bsv/sdk": { diff --git a/packages/overlays/gasp-core/package.json b/packages/overlays/gasp-core/package.json index c6aee77f2..2ddd9bd5e 100644 --- a/packages/overlays/gasp-core/package.json +++ b/packages/overlays/gasp-core/package.json @@ -1,6 +1,6 @@ { "name": "@bsv/gasp", - "version": "1.3.6", + "version": "1.3.7", "sideEffects": false, "engines": { "node": ">=22" @@ -90,7 +90,7 @@ "typescript": "npm:@typescript/typescript6@6.0.2" }, "peerDependencies": { - "@bsv/sdk": "^2.1.6" + "@bsv/sdk": "^3.0.0 || ^2.1.6" }, "peerDependenciesMeta": { "@bsv/sdk": { diff --git a/packages/overlays/overlay-discovery-services/CHANGELOG.md b/packages/overlays/overlay-discovery-services/CHANGELOG.md index eb62a7421..0331338ac 100644 --- a/packages/overlays/overlay-discovery-services/CHANGELOG.md +++ b/packages/overlays/overlay-discovery-services/CHANGELOG.md @@ -9,26 +9,35 @@ All notable changes to this project will be documented in this file. The format ## [Unreleased] +- Accept SDK 3 alongside the existing SDK 2 peer range. See the shared + [SDK 3 migration guide](https://github.com/bsv-blockchain/ts-stack/blob/main/packages/sdk/docs/overlay-lookup-migration.md) + when upgrading the application SDK. + ### Added - Support `ttn` WalletAdvertiser instances with chain-isolated TerraTestNet lookup routing and TTN wallet services. ### Changed + - Preserve the historical plain `Error` contract for malformed lookup queries while validating every optional topics-array element. - Share SHIP/SLAP admittance logging without changing emitted messages. ### Deprecated + - (List features that are in the process of being phased out or replaced.) ### Removed + - (Indicate features or capabilities that were taken out of the project.) ### Fixed + - Describe the accepted zero lookup limit accurately. ### Security + - (Notify of any improvements related to security vulnerabilities or potential risks.) --- @@ -36,6 +45,7 @@ All notable changes to this project will be documented in this file. The format ## [1.6.1] - 2026-02-05 ### Changed + - Updated dependencies --- diff --git a/packages/overlays/overlay-discovery-services/package.json b/packages/overlays/overlay-discovery-services/package.json index f03df3f92..473920dfa 100644 --- a/packages/overlays/overlay-discovery-services/package.json +++ b/packages/overlays/overlay-discovery-services/package.json @@ -1,6 +1,6 @@ { "name": "@bsv/overlay-discovery-services", - "version": "2.2.1", + "version": "2.2.2", "sideEffects": false, "engines": { "node": ">=22" @@ -96,7 +96,7 @@ "mongodb": "^7.5.0" }, "peerDependencies": { - "@bsv/sdk": "^2.4.0" + "@bsv/sdk": "^3.0.0 || ^2.4.0" }, "peerDependenciesMeta": { "@bsv/sdk": { diff --git a/packages/overlays/overlay-express/CHANGELOG.md b/packages/overlays/overlay-express/CHANGELOG.md index fc6b08174..d16638248 100644 --- a/packages/overlays/overlay-express/CHANGELOG.md +++ b/packages/overlays/overlay-express/CHANGELOG.md @@ -11,6 +11,10 @@ All notable changes to this project will be documented in this file. The format ## [Unreleased] +- Accept SDK 3 alongside the existing SDK 2 peer range. See the shared + [SDK 3 migration guide](https://github.com/bsv-blockchain/ts-stack/blob/main/packages/sdk/docs/overlay-lookup-migration.md) + when upgrading the application SDK. + ### Added - Added the `ttn` runtime network, TTN SHIP/SLAP roots, and explicit diff --git a/packages/overlays/overlay-express/package.json b/packages/overlays/overlay-express/package.json index 9b45f02cc..6d02d64c1 100644 --- a/packages/overlays/overlay-express/package.json +++ b/packages/overlays/overlay-express/package.json @@ -1,6 +1,6 @@ { "name": "@bsv/overlay-express", - "version": "2.6.1", + "version": "2.6.2", "sideEffects": false, "engines": { "node": ">=22" @@ -107,7 +107,7 @@ "uuid": "^14.0.1" }, "peerDependencies": { - "@bsv/sdk": "^2.4.0" + "@bsv/sdk": "^3.0.0 || ^2.4.0" }, "peerDependenciesMeta": { "@bsv/sdk": { diff --git a/packages/overlays/overlay/CHANGELOG.md b/packages/overlays/overlay/CHANGELOG.md index 8bdf170c4..328ba0c4b 100644 --- a/packages/overlays/overlay/CHANGELOG.md +++ b/packages/overlays/overlay/CHANGELOG.md @@ -20,22 +20,32 @@ All notable changes to this project will be documented in this file. The format ## [Unreleased] +- Accept SDK 3 alongside the existing SDK 2 peer range. See the shared + [SDK 3 migration guide](https://github.com/bsv-blockchain/ts-stack/blob/main/packages/sdk/docs/overlay-lookup-migration.md) + when upgrading the application SDK. + ### Added + - (Include new features or significant user-visible enhancements here.) ### Changed + - (Detail modifications that are non-breaking but relevant to the end-users.) ### Deprecated + - (List features that are in the process of being phased out or replaced.) ### Removed + - (Indicate features or capabilities that were taken out of the project.) ### Fixed + - (Document bugs that were fixed since the last release.) ### Security + - (Notify of any improvements related to security vulnerabilities or potential risks.) --- @@ -43,12 +53,14 @@ All notable changes to this project will be documented in this file. The format ## [2.1.1] - 2026-06-27 ### Added + - Added BASM unproven transaction maintenance helpers that refresh proofs before evicting rows that remain unproven past the configured block threshold. - Added applied-transaction eviction support for provider-confirmed terminal invalidation such as double spends. ### Changed + - Reorg and unproven lifecycle documentation now describes refresh-before-evict maintenance and provider invalidation behavior. @@ -60,10 +72,10 @@ All notable changes to this project will be documented in this file. The format - Added a slot based rate limiting mechanism to prevent excessive API calls and excessive database connections and insertions. This improves system stability under high load. - ## [0.5.4] - 2025-12-16 ### Changed + - Check if transaction is already on-chain before attempting broadcast. --- @@ -71,6 +83,7 @@ All notable changes to this project will be documented in this file. The format ## [0.5.4] - 2025-12-12 ### Added + - Documentation for developers using the overlay system. --- @@ -78,6 +91,7 @@ All notable changes to this project will be documented in this file. The format ## [0.5.3] - 2025-11-11 ### Changed + - Moved broadcast before storage engine data updates in-case of broadcast failures. - DB index to improve performance on UTXO lookups. @@ -86,6 +100,7 @@ All notable changes to this project will be documented in this file. The format ## [0.5.0] - 2025-10-21 ### Changed + - Improved performance of BEEF hydration - Added check for invalid input index and new deps in history traversal. - Upgrade ts-sdk deps @@ -94,6 +109,7 @@ All notable changes to this project will be documented in this file. The format ## [0.4.5] - 2025-07-30 ### Added + - Support suppressing ship/slap advertisements. --- @@ -116,6 +132,7 @@ All notable changes to this project will be documented in this file. The format ## [0.0.1] - YYYY-MM-DD ### Added + - Initial release of the BSV Blockchain Overlay Services Engine. --- @@ -128,22 +145,22 @@ Replace `X.X.X` with the new version number and `YYYY-MM-DD` with the release da ## [X.X.X] - YYYY-MM-DD ### Added -- +- ### Changed -- +- ### Deprecated -- +- ### Removed -- +- ### Fixed -- +- ### Security -- +- ``` Use this template as the starting point for each new version. Always update the "Unreleased" section with changes as they're implemented, and then move them under the new version header when that version is released. diff --git a/packages/overlays/overlay/package.json b/packages/overlays/overlay/package.json index 7f518ae4a..30022f8fe 100644 --- a/packages/overlays/overlay/package.json +++ b/packages/overlays/overlay/package.json @@ -1,6 +1,6 @@ { "name": "@bsv/overlay", - "version": "2.3.1", + "version": "2.3.2", "sideEffects": false, "engines": { "node": ">=22" @@ -124,7 +124,7 @@ "knex": "^3.3.0" }, "peerDependencies": { - "@bsv/sdk": "^2.1.6" + "@bsv/sdk": "^3.0.0 || ^2.1.6" }, "peerDependenciesMeta": { "@bsv/sdk": { diff --git a/packages/overlays/topics/CHANGELOG.md b/packages/overlays/topics/CHANGELOG.md index b47721adb..3cef97d3a 100644 --- a/packages/overlays/topics/CHANGELOG.md +++ b/packages/overlays/topics/CHANGELOG.md @@ -9,6 +9,10 @@ All notable changes to this project will be documented in this file. The format ## [Unreleased] +- Accept SDK 3 alongside the existing SDK 2 peer range. See the shared + [SDK 3 migration guide](https://github.com/bsv-blockchain/ts-stack/blob/main/packages/sdk/docs/overlay-lookup-migration.md) + when upgrading the application SDK. + ### Added - `tm_uora_dpp` / `ls_uora_dpp`: admission and lookup for UORA attestation diff --git a/packages/overlays/topics/package.json b/packages/overlays/topics/package.json index 0b202c4d8..da83f0685 100644 --- a/packages/overlays/topics/package.json +++ b/packages/overlays/topics/package.json @@ -1,6 +1,6 @@ { "name": "@bsv/overlay-topics", - "version": "1.7.1", + "version": "1.7.2", "sideEffects": false, "engines": { "node": ">=22" @@ -74,7 +74,7 @@ "blockchain" ], "peerDependencies": { - "@bsv/sdk": "^2.1.6" + "@bsv/sdk": "^3.0.0 || ^2.1.6" }, "peerDependenciesMeta": { "@bsv/sdk": { diff --git a/packages/sdk/CHANGELOG.md b/packages/sdk/CHANGELOG.md index a42e01220..2b10fea1b 100644 --- a/packages/sdk/CHANGELOG.md +++ b/packages/sdk/CHANGELOG.md @@ -214,12 +214,11 @@ All notable changes to this project will be documented in this file. The format ## [Unreleased] -- Draft 3.0.0: apply advisory scoped reputation, fresh bounded discovery, +- SDK 3.0.0: apply advisory scoped reputation, fresh bounded discovery, request cancellation, response limits and incomplete/unavailable evidence to every standard overlay resolver method. Incomplete empty `query()` calls now throw a retryable error. Add optional verified GlobalKVStore observations and write reconciliation. See `docs/overlay-lookup-migration.md`. - See `docs/overlay-lookup-migration.md` for the SDK 3 migration. ### Added diff --git a/packages/sdk/docs/globalkv-reliability-validation.md b/packages/sdk/docs/globalkv-reliability-validation.md index 1b61f4d26..5f5d5b7ef 100644 --- a/packages/sdk/docs/globalkv-reliability-validation.md +++ b/packages/sdk/docs/globalkv-reliability-validation.md @@ -89,6 +89,23 @@ explicit authority/protocol model. Durable pending-write recovery across reloads cross-device uniqueness, verified KV history and production UI adoption are separate extensions; see [the design](globalkv-reliability-draft.md). +## Coordinated SDK peer compatibility + +The first clean-install CI run exposed SDK 2-only peer declarations in first-party +packages. The 31 affected packages now accept SDK 3 alongside their existing SDK 2 +ranges, with patch versions and synchronized release documentation. The complete +workspace `pack:check` run passes clean installs, declarations, exports and package +payloads. The full workspace browser matrix also passes with unchanged budgets, +including the wallet browser consumer and packed Chromium recovery fixture. +No force-install or peer-validation bypass is used. Source versions and +ranges are listed in the generated package API/migration table. + +Workspace tests also exercise the SDK 3 graph. Three wallet suites initially +encountered missing middleware output while package checks rebuilt those files; +after builds completed, all three suites / 46 tests passed. The other 219 wallet +suites / 2,103 tests passed in the first run. CI performs builds before tests, +so this local scheduling mistake does not require a source workaround. + ## Tooling dependency remediation The audit identified vulnerable `fast-uri`, `qs` and `toml` versions in the build diff --git a/packages/sdk/docs/overlay-lookup-migration.md b/packages/sdk/docs/overlay-lookup-migration.md index bbbdb8bbb..07beda932 100644 --- a/packages/sdk/docs/overlay-lookup-migration.md +++ b/packages/sdk/docs/overlay-lookup-migration.md @@ -103,8 +103,9 @@ arbitrary application state. KV's validator/reconciler is one implementation. Review the changed SDK contract and inventory each consumer's use of empty results, custom timeouts, response sizes, discovery cache settings and custom facilitators. -Coordinate SDK 3 peer ranges and dependent package releases only after maintainer -approval; this change does not change their public ranges or deployed pins. Repack +First-party packages accept SDK 3 alongside their existing SDK 2 peer ranges, +with patch versions for the compatible manifest correction. Publish the coordinated +package set after maintainer approval; deployed consumer pins remain unchanged. Repack and test each approved consumer, then adopt service-specific validators and UI failure handling where needed. KV-specific write and authority guarantees have additional unresolved requirements described in the companion design. diff --git a/packages/verifast/package.json b/packages/verifast/package.json index e07d3ec44..2ba478782 100644 --- a/packages/verifast/package.json +++ b/packages/verifast/package.json @@ -1,6 +1,6 @@ { "name": "@bsv/verifast", - "version": "0.3.5", + "version": "0.3.6", "sideEffects": false, "publishConfig": { "access": "public" @@ -104,7 +104,7 @@ "vite": "^8.1.5" }, "peerDependencies": { - "@bsv/sdk": "^2.1.8" + "@bsv/sdk": "^3.0.0 || ^2.1.8" }, "peerDependenciesMeta": { "@bsv/sdk": { diff --git a/packages/wallet/btms-permission-module/package.json b/packages/wallet/btms-permission-module/package.json index 0f906203c..d4a83d20b 100644 --- a/packages/wallet/btms-permission-module/package.json +++ b/packages/wallet/btms-permission-module/package.json @@ -1,6 +1,6 @@ { "name": "@bsv/btms-permission-module", - "version": "1.1.4", + "version": "1.1.5", "sideEffects": false, "engines": { "node": ">=22" @@ -50,7 +50,7 @@ "license": "SEE LICENSE IN LICENSE.txt", "peerDependencies": { "@bsv/btms": "^1.1.0", - "@bsv/sdk": "^2.1.6", + "@bsv/sdk": "^3.0.0 || ^2.1.6", "@bsv/wallet-toolbox-client": "^2.3.3" }, "devDependencies": { diff --git a/packages/wallet/btms/package.json b/packages/wallet/btms/package.json index ccaf2e957..a90146dc6 100644 --- a/packages/wallet/btms/package.json +++ b/packages/wallet/btms/package.json @@ -1,6 +1,6 @@ { "name": "@bsv/btms", - "version": "1.2.2", + "version": "1.2.3", "sideEffects": false, "engines": { "node": ">=22" @@ -73,7 +73,7 @@ "typescript": "npm:@typescript/typescript6@6.0.2" }, "peerDependencies": { - "@bsv/sdk": "^2.4.1" + "@bsv/sdk": "^3.0.0 || ^2.4.1" }, "bugs": { "url": "https://github.com/bsv-blockchain/ts-stack/issues" diff --git a/packages/wallet/ecpm-permission-module/package.json b/packages/wallet/ecpm-permission-module/package.json index d1108badd..c73183211 100644 --- a/packages/wallet/ecpm-permission-module/package.json +++ b/packages/wallet/ecpm-permission-module/package.json @@ -1,6 +1,6 @@ { "name": "@bsv/ecpm-permission-module", - "version": "0.1.0", + "version": "0.1.1", "sideEffects": false, "engines": { "node": ">=22" @@ -49,7 +49,7 @@ "author": "BSV Association", "license": "SEE LICENSE IN LICENSE.txt", "peerDependencies": { - "@bsv/sdk": "^2.4.1", + "@bsv/sdk": "^3.0.0 || ^2.4.1", "@bsv/wallet-toolbox-client": "^2.11.0" }, "devDependencies": { diff --git a/packages/wallet/ts-wallet-relay/package.json b/packages/wallet/ts-wallet-relay/package.json index a2dc6d2c7..1bd98490b 100644 --- a/packages/wallet/ts-wallet-relay/package.json +++ b/packages/wallet/ts-wallet-relay/package.json @@ -1,6 +1,6 @@ { "name": "@bsv/wallet-relay", - "version": "0.3.6", + "version": "0.3.7", "sideEffects": false, "engines": { "node": ">=22" @@ -78,7 +78,7 @@ "prepublishOnly": "pnpm build" }, "peerDependencies": { - "@bsv/sdk": "^2.4.1", + "@bsv/sdk": "^3.0.0 || ^2.4.1", "@types/express": ">=4.17.0 <6", "express": ">=4.18.0 <6", "qrcode": ">=1.5.0", diff --git a/packages/wallet/wallet-toolbox/CHANGELOG.md b/packages/wallet/wallet-toolbox/CHANGELOG.md index 583a6eb89..68bd84d8a 100644 --- a/packages/wallet/wallet-toolbox/CHANGELOG.md +++ b/packages/wallet/wallet-toolbox/CHANGELOG.md @@ -6,6 +6,10 @@ attention to changes that materially alter behavior or extend functionality. ## wallet-toolbox (unreleased) +- Accept SDK 3 alongside the existing SDK 2 peer range. See the shared + [SDK 3 migration guide](https://github.com/bsv-blockchain/ts-stack/blob/main/packages/sdk/docs/overlay-lookup-migration.md) + when upgrading the application SDK. + - Extend the BRC-98/99/111 permission-module interface with an optional semantic `handleRequest` hook. A module can now return a conforming BRC-100 result directly or invoke the underlying wallet operation at most once, while @@ -61,10 +65,10 @@ attention to changes that materially alter behavior or extend functionality. no longer discards the total it already computed. Full pages, first pages, and empty result sets are unchanged, so no consumer migration is required. `balanceAndUtxos` now terminates from page progress instead of relying on the - former collapsing total, preventing a zero-progress loop after the final page. - The reviewed Vite raw-size ceiling advances by 500 bytes to 1,607,500, - covering the hosted Linux measurement of 1,607,015 bytes; the Vite compressed - and esbuild ceilings remain unchanged. + former collapsing total, preventing a zero-progress loop after the final page. + The reviewed Vite raw-size ceiling advances by 500 bytes to 1,607,500, + covering the hosted Linux measurement of 1,607,015 bytes; the Vite compressed + and esbuild ceilings remain unchanged. - Serialize typed AtomicBEEF and competing BEEF in wallet review errors as portable JSON arrays, keeping HTTP and relay error recovery compatible with diff --git a/packages/wallet/wallet-toolbox/client/package.json b/packages/wallet/wallet-toolbox/client/package.json index e838cbc2e..ad3662e3a 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": { @@ -71,7 +71,7 @@ "idb": "^8.0.3" }, "peerDependencies": { - "@bsv/sdk": "^2.4.1" + "@bsv/sdk": "^3.0.0 || ^2.4.1" }, "peerDependenciesMeta": { "@bsv/sdk": { diff --git a/packages/wallet/wallet-toolbox/mobile/package.json b/packages/wallet/wallet-toolbox/mobile/package.json index 108bd2da3..0e4f7f7df 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": { @@ -72,7 +72,7 @@ "idb": "^8.0.3" }, "peerDependencies": { - "@bsv/sdk": "^2.4.1" + "@bsv/sdk": "^3.0.0 || ^2.4.1" }, "peerDependenciesMeta": { "@bsv/sdk": { diff --git a/packages/wallet/wallet-toolbox/package.json b/packages/wallet/wallet-toolbox/package.json index 5383f4b40..610fb4a85 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": { @@ -116,7 +116,7 @@ "typescript": "npm:@typescript/typescript6@6.0.2" }, "peerDependencies": { - "@bsv/sdk": "^2.4.1" + "@bsv/sdk": "^3.0.0 || ^2.4.1" }, "peerDependenciesMeta": { "@bsv/sdk": { diff --git a/scripts/brc100-byte-boundary.test.mjs b/scripts/brc100-byte-boundary.test.mjs index 1cec7271b..c0fd2b029 100644 --- a/scripts/brc100-byte-boundary.test.mjs +++ b/scripts/brc100-byte-boundary.test.mjs @@ -118,6 +118,6 @@ for (const file of dependentPackages) { test(`${file} cannot resolve an SDK without the shared byte contract`, async () => { const pkg = JSON.parse(await readFile(file, 'utf8')) const sdkRange = pkg.dependencies?.['@bsv/sdk'] ?? pkg.peerDependencies?.['@bsv/sdk'] - assert.equal(sdkRange, '^2.4.1') + assert.equal(sdkRange, '^3.0.0 || ^2.4.1') }) } From 8422efcac0aed628f38bb279e5eccd72df8324c7 Mon Sep 17 00:00:00 2001 From: Brayden Langley Date: Fri, 4 Sep 2026 15:48:07 -0700 Subject: [PATCH 05/11] Make browser lookup reputation transactional and close CI regressions --- docs/reference/package-api-migrations.md | 2 +- governance/package-release-notes.json | 2 +- .../overlay-discovery-services/CHANGELOG.md | 3 + .../utils/__tests/isAdvertisableURI.test.ts | 12 + .../src/utils/isAdvertisableURI.ts | 17 +- .../tsconfig.base.json | 3 +- packages/sdk/CHANGELOG.md | 3 + packages/sdk/browser/lookup-recovery.mjs | 45 ++- .../sdk/docs/globalkv-reliability-draft.md | 8 +- .../docs/globalkv-reliability-validation.md | 39 ++- packages/sdk/docs/overlay-lookup-migration.md | 7 +- packages/sdk/package.json | 5 +- .../IndexedDBReputationStorage.ts | 58 ++++ .../sdk/src/overlay-tools/LookupResolver.ts | 265 ++++++++---------- .../overlay-tools/ReliableHostReputation.ts | 31 +- .../sdk/src/overlay-tools/ReliableLookup.ts | 69 +++-- .../IndexedDBReputationStorage.test.ts | 94 +++++++ .../__tests/LookupResolver.additional.test.ts | 12 +- .../__tests/LookupResolver.resiliency.test.ts | 2 +- .../LookupResolver.shared-reliability.test.ts | 16 ++ pnpm-lock.yaml | 3 + 21 files changed, 475 insertions(+), 221 deletions(-) create mode 100644 packages/sdk/src/overlay-tools/IndexedDBReputationStorage.ts create mode 100644 packages/sdk/src/overlay-tools/__tests/IndexedDBReputationStorage.test.ts diff --git a/docs/reference/package-api-migrations.md b/docs/reference/package-api-migrations.md index 5fad1f29e..86c115ee8 100644 --- a/docs/reference/package-api-migrations.md +++ b/docs/reference/package-api-migrations.md @@ -292,7 +292,7 @@ CLI entry points: `{"lch":"./dist/cli.js"}`. - Package documentation: [docs/packages/overlays/overlay-discovery-services.md](../packages/overlays/overlay-discovery-services.md) - Source: [packages/overlays/overlay-discovery-services](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/overlays/overlay-discovery-services) -- Release note: Extends WalletAdvertiser to TerraTestNet with chain-isolated SHIP/SLAP resolution and TTN wallet services. Retains the hash-pinned pre-uniformization Open BSV License version 4 grant as a scoped continuity notice. Fixes clean installs with SDK 3 by accepting its peer version alongside the existing SDK 2 range. +- Release note: Extends WalletAdvertiser to TerraTestNet with chain-isolated SHIP/SLAP resolution and TTN wallet services. Retains the hash-pinned pre-uniformization Open BSV License version 4 grant as a scoped continuity notice. Fixes clean installs with SDK 3 by accepting its peer version alongside the existing SDK 2 range. Rejects invalid punycode advertisement hosts consistently across Node/ICU versions while retaining valid internationalized names. - Migration: 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. SDK 3 is supported alongside the existing SDK 2 peer range. Applications adopting SDK 3 must follow packages/sdk/docs/overlay-lookup-migration.md for changed lookup failure, deadline and persistence behavior; existing SDK 2 installations remain supported. | Public subpath | Runtime target(s) | Declaration target(s) | diff --git a/governance/package-release-notes.json b/governance/package-release-notes.json index 99b715c63..1d00567e6 100644 --- a/governance/package-release-notes.json +++ b/governance/package-release-notes.json @@ -133,7 +133,7 @@ "name": "@bsv/overlay-discovery-services", "publishedVersion": "2.1.1", "releaseType": "minor", - "summary": "Extends WalletAdvertiser to TerraTestNet with chain-isolated SHIP/SLAP resolution and TTN wallet services. Retains the hash-pinned pre-uniformization Open BSV License version 4 grant as a scoped continuity notice. Fixes clean installs with SDK 3 by accepting its peer version alongside the existing SDK 2 range.", + "summary": "Extends WalletAdvertiser to TerraTestNet with chain-isolated SHIP/SLAP resolution and TTN wallet services. Retains the hash-pinned pre-uniformization Open BSV License version 4 grant as a scoped continuity notice. Fixes clean installs with SDK 3 by accepting its peer version alongside the existing SDK 2 range. Rejects invalid punycode advertisement hosts consistently across Node/ICU versions while retaining valid internationalized names.", "migration": "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. SDK 3 is supported alongside the existing SDK 2 peer range. Applications adopting SDK 3 must follow packages/sdk/docs/overlay-lookup-migration.md for changed lookup failure, deadline and persistence behavior; existing SDK 2 installations remain supported." }, { diff --git a/packages/overlays/overlay-discovery-services/CHANGELOG.md b/packages/overlays/overlay-discovery-services/CHANGELOG.md index 0331338ac..eb91d4fec 100644 --- a/packages/overlays/overlay-discovery-services/CHANGELOG.md +++ b/packages/overlays/overlay-discovery-services/CHANGELOG.md @@ -34,6 +34,9 @@ All notable changes to this project will be documented in this file. The format ### Fixed +- Reject invalid punycode advertisement hosts consistently across Node/ICU versions, + while retaining valid internationalized names for HTTPS and WSS schemes. + - Describe the accepted zero lookup limit accurately. ### Security diff --git a/packages/overlays/overlay-discovery-services/src/utils/__tests/isAdvertisableURI.test.ts b/packages/overlays/overlay-discovery-services/src/utils/__tests/isAdvertisableURI.test.ts index fe5fc6c2e..5ad86702b 100644 --- a/packages/overlays/overlay-discovery-services/src/utils/__tests/isAdvertisableURI.test.ts +++ b/packages/overlays/overlay-discovery-services/src/utils/__tests/isAdvertisableURI.test.ts @@ -87,3 +87,15 @@ describe('isAdvertisableURI', () => { expect(isAdvertisableURI('mailto:user@example.com')).toBe(false) }) }) + +// Keep rejection stable when URL's native IDNA behavior changes between Node releases. +it.each(['https://', 'https+bsvauth://', 'wss://'])( + 'validates internationalized hostname labels for %s', + prefix => { + expect(isAdvertisableURI(`${prefix}xn--bcher-kva.org`)).toBe(true) + expect(isAdvertisableURI(`${prefix}bücher.org`)).toBe(true) + for (const label of ['xn--0', 'xn--', 'xn--a']) { + expect(isAdvertisableURI(`${prefix}${label}.org`)).toBe(false) + } + } +) diff --git a/packages/overlays/overlay-discovery-services/src/utils/isAdvertisableURI.ts b/packages/overlays/overlay-discovery-services/src/utils/isAdvertisableURI.ts index 79a2d9fb1..a15066b3f 100644 --- a/packages/overlays/overlay-discovery-services/src/utils/isAdvertisableURI.ts +++ b/packages/overlays/overlay-discovery-services/src/utils/isAdvertisableURI.ts @@ -1,3 +1,5 @@ +import { domainToUnicode } from 'node:url' + /** * Checks if the provided URI is advertisable, with a recognized URI prefix. * Applies scheme-specific validation rules as defined by the BRC-101 overlay advertisement spec. @@ -30,11 +32,22 @@ const parsePositiveMeasurement = (value: string): number | undefined => { return Number.isFinite(parsed) && parsed > 0 ? parsed : undefined } +// Node/ICU versions differ in whether URL rejects invalid ASCII IDNA labels. +// A valid xn-- label must decode; preserving it verbatim is not validation. +const isValidHostname = (hostname: string): boolean => { + if (hostname.toLowerCase() === 'localhost') return false + return hostname.split('.').every(label => { + if (!label.startsWith('xn--')) return true + const decoded = domainToUnicode(label) + return decoded !== '' && decoded !== label + }) +} + const validateCustomHttpsURI = (uri: string, prefix: string): boolean => { try { const modifiedURI = uri.replace(prefix, 'https://') const parsed = new URL(modifiedURI) - if (parsed.hostname.toLowerCase() === 'localhost') return false + if (!isValidHostname(parsed.hostname)) return false if (parsed.pathname !== '/') return false return true } catch { @@ -45,7 +58,7 @@ const validateCustomHttpsURI = (uri: string, prefix: string): boolean => { const validateWssURI = (uri: string): boolean => { try { const parsed = new URL(uri) - if (parsed.hostname.toLowerCase() === 'localhost') return false + if (!isValidHostname(parsed.hostname)) return false return true } catch { return false diff --git a/packages/overlays/overlay-discovery-services/tsconfig.base.json b/packages/overlays/overlay-discovery-services/tsconfig.base.json index 62b3da13a..7f0197022 100644 --- a/packages/overlays/overlay-discovery-services/tsconfig.base.json +++ b/packages/overlays/overlay-discovery-services/tsconfig.base.json @@ -17,7 +17,8 @@ "emitDecoratorMetadata": true, "experimentalDecorators": true, "resolveJsonModule": true, - "esModuleInterop": true + "esModuleInterop": true, + "types": ["node"] }, "files": ["mod.ts"], "include": ["src"], diff --git a/packages/sdk/CHANGELOG.md b/packages/sdk/CHANGELOG.md index 2b10fea1b..c73caa890 100644 --- a/packages/sdk/CHANGELOG.md +++ b/packages/sdk/CHANGELOG.md @@ -214,6 +214,9 @@ All notable changes to this project will be documented in this file. The format ## [Unreleased] +- Persist browser overlay health with IndexedDB transactions to prevent cross-tab + lost updates from stale localStorage reads. Bound advisory health refresh at + 50 ms so unavailable storage cannot block a recovered host. - SDK 3.0.0: apply advisory scoped reputation, fresh bounded discovery, request cancellation, response limits and incomplete/unavailable evidence to every standard overlay resolver method. Incomplete empty `query()` calls now diff --git a/packages/sdk/browser/lookup-recovery.mjs b/packages/sdk/browser/lookup-recovery.mjs index ca7372dcc..2e64e7b80 100644 --- a/packages/sdk/browser/lookup-recovery.mjs +++ b/packages/sdk/browser/lookup-recovery.mjs @@ -35,9 +35,26 @@ async function chromePath() { async function preparePage(page, origin) { await page.goto(origin) await page.waitForFunction(() => globalThis.bsv?.LookupResolver !== undefined) - assert.equal(await page.evaluate(() => typeof navigator.locks?.request), 'function') + assert.equal(await page.evaluate(() => typeof indexedDB?.open), 'function') await page.evaluate( ({ services, host }) => { + globalThis.readHealth = key => + new Promise((resolve, reject) => { + const open = indexedDB.open(key, 1) + open.onupgradeneeded = () => open.result.createObjectStore('state') + open.onerror = () => reject(open.error) + open.onsuccess = () => { + const db = open.result + const tx = db.transaction('state') + const read = tx.objectStore('state').get(key) + read.onsuccess = () => resolve(JSON.parse(read.result ?? '{}')) + tx.oncomplete = () => db.close() + tx.onabort = () => { + db.close() + reject(tx.error) + } + } + }) const output = { beef: new bsv.Transaction( 1, @@ -70,6 +87,22 @@ async function preparePage(page, origin) { ) } +async function waitForHealth(page, pair, recovered) { + await page.waitForFunction( + ({ key, pair, host, recovered }) => { + return readHealth(key).then(state => { + const entries = state.entries ?? {} + return pair.every(service => { + const entry = entries[JSON.stringify(['mainnet', service, host])] + return entry !== undefined && (recovered ? entry.penalty === 0 : entry.penalty > 0) + }) + }) + }, + { polling: 25, timeout: 5000 }, + { key: currentKey, pair, host, recovered } + ) +} + async function query(page, service, up) { return await page.evaluate( async ({ service, up }) => { @@ -130,6 +163,8 @@ try { poison }) + // Health persistence is advisory and asynchronous. Observe the eventual public + // transactional state rather than assuming cross-renderer requests have arrived. for (let cycle = 0; cycle < 5; cycle++) { // Independent tabs write different services concurrently: neither may lose the other's update. for (let offset = 0; offset < services.length; offset += 2) { @@ -141,9 +176,9 @@ try { failed, pair.map(() => ({ unavailable: true, probes: 1 })) ) - await pages[0].evaluate(key => navigator.locks.request(key, () => {}), currentKey) + await waitForHealth(pages[0], pair, false) const entries = await pages[0].evaluate( - key => JSON.parse(localStorage.getItem(key)).entries, + key => readHealth(key).then(state => state.entries), currentKey ) for (const service of pair) @@ -157,9 +192,9 @@ try { recovered, pair.map(() => ({ outputs: 1, probes: 1 })) ) - await pages[0].evaluate(key => navigator.locks.request(key, () => {}), currentKey) + await waitForHealth(pages[0], pair, true) const healed = await pages[0].evaluate( - key => JSON.parse(localStorage.getItem(key)).entries, + key => readHealth(key).then(state => state.entries), currentKey ) for (const service of pair) diff --git a/packages/sdk/docs/globalkv-reliability-draft.md b/packages/sdk/docs/globalkv-reliability-draft.md index c567f904b..f2e2b2161 100644 --- a/packages/sdk/docs/globalkv-reliability-draft.md +++ b/packages/sdk/docs/globalkv-reliability-draft.md @@ -97,9 +97,11 @@ and bounded parallel work over suppressing every probe during an outage. - v1–v3 are ignored, not copied or deleted. Rolling back still sees legacy state. - At most 256 entries and 1 MiB read input. No arbitrary server error text, query, user identity, value, or BEEF is persisted or logged. -- Browser read/modify/write is serialized using Web Locks. Without atomic - storage/locks, the new path uses memory only. Custom adapters must provide - equivalent serialization; the tests model two independent tabs sharing it. +- Browser read/modify/write uses IndexedDB transactions, including the read + inside each write transaction. LocalStorage plus Web Locks allowed stale + cross-tab reads to overwrite newer updates in the repeated browser fixture. + Health refresh is capped at 50 ms; unavailable storage cannot hold up host + recovery. Custom adapters must provide coherent, serialized updates. - At most 32 candidate hosts and 32 trackers per operation. Truncation marks discovery incomplete. This is a resource bound, not a guarantee that an arbitrary 33rd healthy host will be contacted; scalable candidate scheduling diff --git a/packages/sdk/docs/globalkv-reliability-validation.md b/packages/sdk/docs/globalkv-reliability-validation.md index 5f5d5b7ef..49e5eed36 100644 --- a/packages/sdk/docs/globalkv-reliability-validation.md +++ b/packages/sdk/docs/globalkv-reliability-validation.md @@ -19,7 +19,7 @@ processes. Source comparison starts at ts-stack `98734b07cf` (2026-09-04). | Outage becomes an apparently empty collection | Incomplete empty aggregates throw retryable `LookupUnavailableError`; detailed/progressive APIs retain completion evidence. | | Bad proof or stale ancestor wins by latency | KV tests validate BEEF, signatures, selectors and fixture-root SPV before reconciliation; incomparable tips produce conflict. | | Accepted transaction is not yet indexed | Optional KV write tests retain the same transaction identity, report unconfirmed status and reconcile without creating another transaction. | -| Persisted penalties recur across tabs/reloads | `browser/lookup-recovery.mjs` uses the exact packed UMD in Chromium, real localStorage and Web Locks, two concurrent tabs, four services and five outage/recovery cycles. | +| Persisted penalties recur across tabs/reloads | `browser/lookup-recovery.mjs` uses the exact packed UMD in Chromium, real IndexedDB transactions, two concurrent tabs, four services and five outage/recovery cycles. | The legacy cooldown behavior can be reproduced at `ff36b55`; the standard-export recovery assertions are in `LookupResolver.shared-reliability.test.ts`. Services @@ -44,6 +44,7 @@ pnpm --filter @bsv/sdk test:coverage --runInBand pnpm --filter @bsv/sdk test:property pnpm --filter @bsv/sdk pack:check pnpm --filter @bsv/sdk test:browser +pnpm --filter @bsv/wallet-toolbox-mobile test:mobile pnpm --filter docs-site test pnpm --filter docs-site build pnpm conformance @@ -52,12 +53,13 @@ pnpm audit:security The browser command requires Chrome or Chromium in a standard system location, uses a disposable browser profile, and removes its package extraction afterwards. -No browser account, production endpoint or existing storage is used. +No browser account, production endpoint or existing storage is used. Ten +consecutive runs of the transactional fixture passed (200 reloads total). -The final local SDK coverage suite passed 164 suites / 5,998 tests and -one snapshot, with 94.34% statement, 86.48% branch, 95.16% function and 95.26% line +The final local SDK coverage suite passed 165 suites / 6,004 tests and +one snapshot, with 94.37% statement, 86.51% branch, 95.29% function and 95.29% line coverage. Packed ESM/CJS exports, strict declarations, source maps, publint, -Vite, esbuild and UMD contracts passed. UMD is 554,779 bytes within the unchanged 555,000-byte budget. +Vite, esbuild and UMD contracts passed. UMD is 554,955 bytes within the unchanged 555,000-byte budget. The PR records the final exact-head results; historical measurements are not a substitute for its current hosted merge gate. @@ -116,3 +118,30 @@ The current frontmatter plugin still requires toml 3, so a parent-scoped toml MDX TOML/YAML integration, parser security regressions and the full documentation build check its compatibility. No advisory dismissal or quality/bundle threshold was added or relaxed. + +## Cross-runtime CI follow-up + +Repeated Chromium runs exposed a stale localStorage read overwriting a newer +update in another tab despite Web Locks. Browser persistence now reads and writes +inside one IndexedDB transaction. The fixture waits for committed state, reloads +both tabs with active penalties and preserves legacy localStorage unchanged. +Independent-cache, same-key concurrency, abort and unavailable-database tests +cover the storage boundary. Refresh is capped at 50 ms and remains advisory. + +The Hermes profile exposed bytecode growth from redundant async wrappers in the +shared resolver. Promise-returning adapters and synchronous structural validators +avoid unnecessary transpiled state machines. Mobile, browser, cancellation and +deadline checks validate the same resolver with the original bundle budgets. The +packed mobile fixture measures 1,718,536 bytes for Metro and 3,493,469 bytes for +Hermes, below the unchanged 1,720,000 and 3,495,000 byte limits. + +Node 24.20 accepts some invalid punycode labels that earlier URL parsers rejected. +Discovery advertisement validation explicitly requires those labels to decode, +while preserving valid internationalized names. All 100 discovery tests pass on +Node 24.20, including the unchanged property regression that exposed this change. +The governed advertisement mutation profile passes at 86.83% (145 detected of +167 valid mutations). + +The IndexedDB transaction tests use the existing workspace `fake-indexeddb` +6.2.5 version as an SDK development dependency. It is excluded from runtime +bundles; real Chromium independently verifies browser persistence. diff --git a/packages/sdk/docs/overlay-lookup-migration.md b/packages/sdk/docs/overlay-lookup-migration.md index 07beda932..6148fba77 100644 --- a/packages/sdk/docs/overlay-lookup-migration.md +++ b/packages/sdk/docs/overlay-lookup-migration.md @@ -37,9 +37,10 @@ service validator through `queryReliable` where those checks are needed. caller's deadline, but arbitrary synchronous parsing/validator code cannot be preempted on the same JavaScript thread. Worker isolation remains unresolved. - v4 reputation is scoped by network/service/normalized host, bounded and decays. - v1–v3 records are ignored without deletion. Web Locks protect browser updates; - custom persistence requires `reliableReputationStorage` with a lock. Unsupported - storage environments and legacy get/set-only adapters use in-memory health. + v1–v3 records are ignored without deletion. IndexedDB transactions protect + browser updates; refresh is capped at 50 ms and fails open. Custom + `reliableReputationStorage` adapters must provide coherent serialized updates. + Unsupported environments and legacy get/set-only adapters use in-memory health. - HTTP semantic rejection and valid freeform responses remain neutral for standard availability reputation. HTTP 408/425/429 and 5xx are availability failures. The explicit validator API can apply reason-specific rejection and diff --git a/packages/sdk/package.json b/packages/sdk/package.json index 5160eb184..c7640641b 100644 --- a/packages/sdk/package.json +++ b/packages/sdk/package.json @@ -794,16 +794,17 @@ "@types/jest": "^30.0.0", "@types/node": "^26.1.2", "@typescript/native": "npm:typescript@7.0.2", + "fake-indexeddb": "^6.2.5", "fast-check": "^4.9.0", "jest": "^30.4.2", "jest-environment-jsdom": "^30.4.1", "oxlint": "^1.76.0", + "puppeteer-core": "25.4.0", "ts-jest": "^29.4.12", "ts-loader": "^9.6.2", "ts2md": "^0.2.8", "tsconfig-to-dual-package": "^1.2.0", - "typescript": "npm:@typescript/typescript6@6.0.2", - "puppeteer-core": "25.4.0" + "typescript": "npm:@typescript/typescript6@6.0.2" }, "typesVersions": { "*": { diff --git a/packages/sdk/src/overlay-tools/IndexedDBReputationStorage.ts b/packages/sdk/src/overlay-tools/IndexedDBReputationStorage.ts new file mode 100644 index 000000000..deabbb27b --- /dev/null +++ b/packages/sdk/src/overlay-tools/IndexedDBReputationStorage.ts @@ -0,0 +1,58 @@ +import type { ReliableReputationStorage } from './ReliableHostReputation.js' + +/** Transactional browser health storage. LocalStorage caches are not coherent across tabs. */ +export function indexedDBReputationStorage(factory: IDBFactory): ReliableReputationStorage { + let value: string | null = null + const run = (key: string, action?: () => Promise): Promise => + new Promise((resolve, reject) => { + const request = factory.open('bsvsdk_overlay_host_reputation_v4') + request.onerror = () => reject(request.error) + request.onupgradeneeded = () => request.result.createObjectStore('state') + request.onsuccess = () => { + const database = request.result + let transaction: IDBTransaction + try { + transaction = database.transaction( + 'state', + action === undefined ? 'readonly' : 'readwrite' + ) + } catch (error) { + database.close() + reject(error) + return + } + const state = transaction.objectStore('state') + let result: T + const finish = (event: Event): void => { + database.close() + if (event.type === 'complete') resolve(result) + else reject(transaction.error ?? new Error('Reputation transaction aborted')) + } + transaction.oncomplete = finish + transaction.onabort = finish + const read = state.get(key) + read.onsuccess = () => { + value = read.result ?? null + if (action !== undefined) { + // The health update is synchronous; its resolved promise stays inside + // this transaction's microtask checkpoint, before automatic commit. + Promise.resolve() + .then(action) + .then(output => { + result = output + state.put(value, key) + }) + .catch(() => transaction.abort()) + } + } + } + }) + return { + get: () => value, + set: (_key, next) => { + value = next + }, + load: key => run(key), + lock: (key, action) => run(key, action) + } +} diff --git a/packages/sdk/src/overlay-tools/LookupResolver.ts b/packages/sdk/src/overlay-tools/LookupResolver.ts index b1741a2f6..ed26db72a 100644 --- a/packages/sdk/src/overlay-tools/LookupResolver.ts +++ b/packages/sdk/src/overlay-tools/LookupResolver.ts @@ -357,27 +357,27 @@ export class HTTPSOverlayLookupFacilitator implements OverlayLookupFacilitator { this.allowHTTP = allowHTTP } - async lookup( + lookup( url: string, question: LookupQuestion, timeout: number = 2000, signal?: AbortSignal ): Promise { if (!url.startsWith('https:') && !this.allowHTTP) { - throw new Error('HTTPS facilitator can only use URLs that start with "https:"') + return Promise.reject( + new Error('HTTPS facilitator can only use URLs that start with "https:"') + ) } - try { - return await withinDeadline( - async child => await this.performLookupRequest(url, question, child), - timeout, - signal - ) - } catch (error) { + return withinDeadline( + child => this.performLookupRequest(url, question, child), + timeout, + signal + ).catch(error => { if ((error as { name?: string })?.name === 'AbortError') throw new Error('Request timed out') if (error instanceof Error) throw error throw new Error(Utils.toSafeString(error, 'Unknown error')) - } + }) } protected async performLookupRequest( @@ -429,23 +429,26 @@ 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)]) - const nOutpoints = r.readVarIntNum() - if (nOutpoints > 256) throw new LookupValidationError('malformed') - const outpoints: Array<{ txid: string; outputIndex: number; context?: number[] }> = [] - for (let i = 0; i < nOutpoints; i++) { - const txid = Utils.toHex(r.read(32)) - const outputIndex = r.readVarIntNum() - const contextLength = r.readVarIntNum() - const context = contextLength > 0 ? r.read(contextLength) : undefined - outpoints.push({ txid, outputIndex, context }) - } - const beef = r.read() - const beefObj = Beef.fromBinary(beef) - const outputs = await this.extractAtomicOutputs(outpoints, beefObj) - return { type: 'output-list', outputs } + private parseOctetStreamLookup(response: Response): Promise { + return response.arrayBuffer().then(payload => { + const r = new Utils.Reader([...new Uint8Array(payload)]) + const nOutpoints = r.readVarIntNum() + if (nOutpoints > 256) throw new LookupValidationError('malformed') + const outpoints: Array<{ txid: string; outputIndex: number; context?: number[] }> = [] + for (let i = 0; i < nOutpoints; i++) { + const txid = Utils.toHex(r.read(32)) + const outputIndex = r.readVarIntNum() + const contextLength = r.readVarIntNum() + const context = contextLength > 0 ? r.read(contextLength) : undefined + outpoints.push({ txid, outputIndex, context }) + } + const beef = r.read() + const beefObj = Beef.fromBinary(beef) + return this.extractAtomicOutputs(outpoints, beefObj).then(outputs => ({ + type: 'output-list', + outputs + })) + }) } /** Memoize per-txid atomic BEEF extraction, yielding to the event loop between outputs. */ @@ -519,7 +522,7 @@ class LookupQuerySession { private readonly txIds: string[] = [] private readonly queue: LookupQueryEvent[] = [] private waiter: (() => void) | null = null - private firstResponseAt: number | null = null + private answered = false private graceTimer: ReturnType | null = null private softTimer: ReturnType | null = null private graceFired = false @@ -616,8 +619,8 @@ class LookupQuerySession { private handleAnswer(answer: LookupAnswer): LookupAnswerProgress | null { const added = this.mergeAnswer(answer) - if (this.firstResponseAt === null) { - this.firstResponseAt = Date.now() + if (!this.answered) { + this.answered = true if (!this.graceFired && this.graceMs > 0) { this.graceTimer = setTimeout(() => { this.graceFired = true @@ -640,44 +643,33 @@ class LookupQuerySession { return this.snapshot(false) } - private handleSoft(): { - snapshot: LookupAnswerProgress | null - stop: boolean - } { + private handleSoft(): LookupAnswerProgress | null { let snapshot: LookupAnswerProgress | null = null if (!this.emittedOnce) { this.graceFired = true this.emittedOnce = true snapshot = this.snapshot(false) } - return { - snapshot, - stop: false - } + return snapshot } - private async nextEvent(): Promise { - if (this.queue.length === 0) { - await new Promise(resolve => { - this.waiter = resolve - }) - } - return this.queue.shift() as LookupQueryEvent + private nextEvent(): Promise { + if (this.queue.length > 0) return Promise.resolve(this.queue.shift() as LookupQueryEvent) + return new Promise(resolve => { + this.waiter = resolve + }).then(() => this.queue.shift() as LookupQueryEvent) } - private processEvent(event: LookupQueryEvent): { - snapshot: LookupAnswerProgress | null - stop: boolean - } { + private processEvent(event: LookupQueryEvent): LookupAnswerProgress | null { switch (event.kind) { case 'answer': - return { snapshot: this.handleAnswer(event.answer), stop: false } + return this.handleAnswer(event.answer) case 'grace': - return { snapshot: this.handleGrace(), stop: false } + return this.handleGrace() case 'soft': return this.handleSoft() case 'done': - return { snapshot: null, stop: false } + return null } } @@ -686,12 +678,10 @@ class LookupQuerySession { this.softTimer = setTimeout(() => this.push({ kind: 'soft' }), this.softTimeoutMs) } try { - let stop = false - while ((this.completedHosts < this.hostCount || this.queue.length > 0) && !stop) { + while (this.completedHosts < this.hostCount || this.queue.length > 0) { const event = await this.nextEvent() const outcome = this.processEvent(event) - if (outcome.snapshot != null) yield outcome.snapshot - stop = outcome.stop + if (outcome != null) yield outcome } const finalSnapshot = this.snapshot(true) this.emittedFinal = true @@ -722,7 +712,7 @@ export default class LookupResolver { protected readonly hostOverrides: Record protected readonly additionalHosts: Record protected readonly networkPreset: LookupNetworkPreset - private readonly hostReputation: ReliableHostReputation + private readonly reputation: ReliableHostReputation private readonly telemetry: Telemetry private readonly txMemo: Map @@ -734,7 +724,7 @@ export default class LookupResolver { * notification consumers know which tracker has a stale advertisement. */ private readonly advertisedBy: Map - private readonly lastUnreachableNotificationAt: Map + private readonly notificationTimes: Map constructor(config: LookupResolverConfig = {}) { this.networkPreset = config.networkPreset ?? 'mainnet' @@ -750,7 +740,7 @@ export default class LookupResolver { // A legacy get/set-only store cannot safely perform cross-tab read/modify/write. // Keep it memory-only unless the caller supplies an atomic v4 store. - this.hostReputation = new ReliableHostReputation( + this.reputation = new ReliableHostReputation( config.reliableReputationStorage ?? (typeof config.reputationStorage === 'object' ? null : undefined) ) @@ -758,7 +748,7 @@ export default class LookupResolver { this.txMemo = new Map() this.advertisedBy = new Map() - this.lastUnreachableNotificationAt = new Map() + this.notificationTimes = new Map() } private defaultSlapTrackers(): string[] { @@ -781,15 +771,16 @@ export default class LookupResolver { * Optional `options.softTimeoutMs` resolves the query early with whatever has arrived once any host has * answered (or with a retryable error if no host has answered by `softTimeoutMs`). */ - async query( + query( question: LookupQuestion, timeout?: number, options?: LookupQueryOptions ): Promise { - const resolution = await this.queryDetailed(question, timeout, options) - if (resolution.answer.outputs.length === 0 && resolution.progress.status !== 'complete') - throw new LookupUnavailableError(resolution.progress) - return resolution.answer + return this.queryDetailed(question, timeout, options).then(resolution => { + if (resolution.answer.outputs.length === 0 && resolution.progress.status !== 'complete') + throw new LookupUnavailableError(resolution.progress) + return resolution.answer + }) } /** @@ -860,15 +851,15 @@ export default class LookupResolver { const trackers = normalizeHosts(this.slapTrackers, false) if (trackers.length === 0 || trackers.length > 32) complete = false const answers = await Promise.all( - trackers.slice(0, 32).map(async tracker => { - const result = await requestReliableHost( + trackers.slice(0, 32).map(tracker => + requestReliableHost( this.facilitator, - { reputation: this.hostReputation, network: this.networkPreset }, + { reputation: this.reputation, network: this.networkPreset }, tracker, { service: 'ls_slap', query: { service: question.service } }, { hostTimeoutMs: MAX_TRACKER_WAIT_TIME, - validate: async answer => { + validate: answer => { if (!isOutputListAnswer(answer)) throw new LookupValidationError('malformed') const hosts = this.extractHostsFromAnswer(answer, question.service) if (hosts.length !== answer.outputs.length) complete = false @@ -877,17 +868,18 @@ export default class LookupResolver { }, remaining(), signal - ) - if (result.kind !== 'answer') { - complete = false - return [] - } - for (const host of result.values) { - if (this.advertisedBy.size >= 512) this.evictOldest(this.advertisedBy) - this.advertisedBy.set(host, tracker) - } - return result.values - }) + ).then(result => { + if (result.kind !== 'answer') { + complete = false + return [] + } + for (const host of result.values) { + if (this.advertisedBy.size >= 512) this.evictOldest(this.advertisedBy) + this.advertisedBy.set(host, tracker) + } + return result.values + }) + ) ) candidates = answers.flat() } @@ -898,9 +890,13 @@ export default class LookupResolver { complete = false const hosts = normalizeHosts(candidates, this.networkPreset === 'local') if (hosts.length === 0 || hosts.length > 32) complete = false + // Storage is advisory: a blocked browser database must not delay host recovery. + await withinDeadline(() => this.reputation.refresh(), Math.min(50, remaining()), signal).catch( + () => {} + ) // Select before ranking: persisted health must not exclude a candidate at the cap. return { - hosts: this.hostReputation.rank(this.networkPreset, question.service, hosts.slice(0, 32)), + hosts: this.reputation.rank(this.networkPreset, question.service, hosts.slice(0, 32)), complete } } @@ -918,31 +914,31 @@ export default class LookupResolver { let discoveryComplete = false try { await withinDeadline( - async signal => { - const discovery = await this.resolveHosts(question, signal, remaining) - discoveryComplete = discovery.complete - await Promise.all( - discovery.hosts.map(async host => { - hosts.push( - await requestReliableHost( + signal => + this.resolveHosts(question, signal, remaining).then(discovery => { + discoveryComplete = discovery.complete + return Promise.all( + discovery.hosts.map(host => + requestReliableHost( this.facilitator, - { reputation: this.hostReputation, network: this.networkPreset }, + { reputation: this.reputation, network: this.networkPreset }, host, question, { ...options, - validate: async (answer, child) => { + validate: (answer, child) => { if (!isOutputListAnswer(answer)) throw new LookupValidationError('malformed') - return await options.validate(answer, child) + return options.validate(answer, child) } }, remaining(), signal - ) + ).then(outcome => { + hosts.push(outcome) + }) ) - }) - ) - }, + ) + }), deadlineMs, options.signal ) @@ -952,7 +948,7 @@ export default class LookupResolver { return { hosts: hosts.slice(), discoveryComplete, durationMs: monotonicNow() - start } } - private unreachableNotificationCooldown(options: LookupQueryOptions | undefined): number { + private notificationCooldown(options: LookupQueryOptions | undefined): number { const requested = options?.unreachableHostNotificationCooldownMs return typeof requested === 'number' && Number.isFinite(requested) && requested >= 0 ? requested @@ -970,12 +966,12 @@ export default class LookupResolver { const notificationKey = `${service}\u0000${host}` const now = Date.now() const lastNotificationAt = - this.lastUnreachableNotificationAt.get(notificationKey) ?? Number.NEGATIVE_INFINITY + this.notificationTimes.get(notificationKey) ?? Number.NEGATIVE_INFINITY if (now - lastNotificationAt < cooldownMs) return - if (this.lastUnreachableNotificationAt.size >= MAX_NOTIFICATION_DEDUP_ENTRIES) { - this.evictOldest(this.lastUnreachableNotificationAt) + if (this.notificationTimes.size >= MAX_NOTIFICATION_DEDUP_ENTRIES) { + this.evictOldest(this.notificationTimes) } - this.lastUnreachableNotificationAt.set(notificationKey, now) + this.notificationTimes.set(notificationKey, now) try { const result = callback({ host, @@ -991,7 +987,7 @@ export default class LookupResolver { } } - private recordLookupHostAnswer( + private recordAnswer( session: LookupQuerySession, service: string, host: string, @@ -999,22 +995,15 @@ export default class LookupResolver { hostStartedAt: number, correlationId: string | undefined ): void { + let outcome: 'empty' | 'success' | 'freeform' = 'freeform' if (isOutputListAnswer(answer)) { session.recordOutputAnswer(answer) - this.captureHostTelemetry( - service, - host, - answer.outputs.length === 0 ? 'empty' : 'success', - Date.now() - hostStartedAt, - correlationId - ) - return - } - session.recordFreeformAnswer() - this.captureHostTelemetry(service, host, 'freeform', Date.now() - hostStartedAt, correlationId) + outcome = answer.outputs.length === 0 ? 'empty' : 'success' + } else session.recordFreeformAnswer() + this.captureHost(service, host, outcome, Date.now() - hostStartedAt, correlationId) } - private recordLookupHostFailure(context: LookupHostFailureContext, error: unknown): void { + private recordFailure(context: LookupHostFailureContext, error: unknown): void { const { session, service, @@ -1027,7 +1016,7 @@ export default class LookupResolver { const semanticRejection = isSemanticLookupRejection(error) if (semanticRejection) session.recordRejection() else session.recordAvailabilityFailure() - this.captureHostTelemetry( + this.captureHost( service, host, semanticRejection ? 'rejected' : 'failed', @@ -1040,7 +1029,7 @@ export default class LookupResolver { } } - private startLookupHostQueries( + private startQueries( hosts: string[], question: LookupQuestion, timeout: number | undefined, @@ -1050,31 +1039,24 @@ export default class LookupResolver { remaining: () => number ): void { const correlationId = session.correlationId - const notificationCooldownMs = this.unreachableNotificationCooldown(options) + const notificationCooldownMs = this.notificationCooldown(options) for (const host of hosts) { const hostStartedAt = Date.now() - void this.lookupHostWithTracking( + void this.lookupHost( host, question, Math.min(timeout ?? DEFAULT_LOOKUP_TIMEOUT, remaining()), signal ) .then(answer => { - this.recordLookupHostAnswer( - session, - question.service, - host, - answer, - hostStartedAt, - correlationId - ) + this.recordAnswer(session, question.service, host, answer, hostStartedAt, correlationId) }) .catch(error => { if (signal.aborted) { session.recordAvailabilityFailure() return } - this.recordLookupHostFailure( + this.recordFailure( { session, service: question.service, @@ -1123,7 +1105,7 @@ export default class LookupResolver { let discovery: { hosts: string[]; complete: boolean } try { discovery = await withinDeadline( - async signal => await this.resolveHosts(question, signal, remaining), + signal => this.resolveHosts(question, signal, remaining), remaining(), controller.signal ) @@ -1157,7 +1139,7 @@ export default class LookupResolver { } }) - this.startLookupHostQueries( + this.startQueries( rankedHosts, question, timeout, @@ -1170,11 +1152,7 @@ export default class LookupResolver { try { for await (const progress of session.progress()) { if (progress.isFinal) { - this.captureLookupCompletedTelemetry( - question.service, - progress, - Date.now() - session.startedAt - ) + this.captureCompletion(question.service, progress, Date.now() - session.startedAt) } yield progress } @@ -1261,16 +1239,16 @@ export default class LookupResolver { } } - private async lookupHostWithTracking( + private lookupHost( host: string, question: LookupQuestion, timeout: number, signal: AbortSignal ): Promise { let failure: unknown - const result = await requestReliableHost( + return requestReliableHost( this.facilitator, - { reputation: this.hostReputation, network: this.networkPreset }, + { reputation: this.reputation, network: this.networkPreset }, host, question, { @@ -1280,7 +1258,7 @@ export default class LookupResolver { }, credit: values => values[0]?.type === 'output-list', penalizeRejections: false, - validate: async answer => { + validate: answer => { if (!isOutputListAnswer(answer) && !isFreeformAnswer(answer)) throw new LookupValidationError('malformed') if ( @@ -1293,13 +1271,14 @@ export default class LookupResolver { }, timeout, signal - ) - if (result.kind === 'answer') return result.values[0] - if (failure !== undefined) throw failure - throw new Error(`Lookup response ${result.kind}`) + ).then(result => { + if (result.kind === 'answer') return result.values[0] + if (failure !== undefined) throw failure + throw new Error(`Lookup response ${result.kind}`) + }) } - private captureHostTelemetry( + private captureHost( service: string, host: string, outcome: 'success' | 'empty' | 'failed' | 'rejected' | 'freeform', @@ -1328,7 +1307,7 @@ export default class LookupResolver { }) } - private captureLookupCompletedTelemetry( + private captureCompletion( service: string, progress: LookupAnswerProgress, durationMs: number diff --git a/packages/sdk/src/overlay-tools/ReliableHostReputation.ts b/packages/sdk/src/overlay-tools/ReliableHostReputation.ts index c8809ecfe..9cd00d947 100644 --- a/packages/sdk/src/overlay-tools/ReliableHostReputation.ts +++ b/packages/sdk/src/overlay-tools/ReliableHostReputation.ts @@ -1,3 +1,5 @@ +import { indexedDBReputationStorage } from './IndexedDBReputationStorage.js' + /** Advisory health state. No entry is ever permission to exclude a host. */ export type HostFailureReason = 'timeout' | 'transport' | 'rejected' | 'malformed' | 'invalid' export interface ReliableReputationEntry { @@ -9,7 +11,9 @@ export interface ReliableReputationEntry { export interface ReliableReputationStorage { get: (key: string) => string | null | undefined set: (key: string, value: string) => void - /** Must serialize read/modify/write across every writer (e.g. Web Locks). */ + /** Refresh a synchronous read cache from transactional storage, when needed. */ + load?: (key: string) => Promise + /** Must serialize coherent read/modify/write across every writer. */ lock: (name: string, action: () => Promise) => Promise } const KEY = 'bsvsdk_overlay_host_reputation_v4' @@ -26,14 +30,8 @@ const reasons = new Set([ function browserStorage(): ReliableReputationStorage | undefined { try { - const storage = globalThis.localStorage - const locks = globalThis.navigator?.locks - if (storage == null || locks == null) return undefined - return { - get: key => storage.getItem(key), - set: (key, value) => storage.setItem(key, value), - lock: async (name, action) => await locks.request(name, action) - } + const factory = globalThis.indexedDB + return factory == null ? undefined : indexedDBReputationStorage(factory) } catch { return undefined } @@ -103,6 +101,11 @@ export class ReliableHostReputation { } } + /** Advisory refresh; callers bound this independently of host work. */ + refresh(): Promise { + return this.storage?.load?.(KEY) ?? Promise.resolve() + } + rank(network: string, service: string, hosts: string[]): string[] { const now = Date.now() this.entries = this.storage === undefined ? this.sanitize(this.entries, now) : this.read(now) @@ -122,13 +125,13 @@ export class ReliableHostReputation { return entry === undefined ? undefined : { ...entry } } - async record( + record( network: string, service: string, host: string, reason?: HostFailureReason ): Promise { - const update = async (): Promise => { + const update = (): void => { const now = Date.now() const entries = this.storage === undefined ? this.sanitize(this.entries, now) : this.read(now) const key = this.scope(network, service, host) @@ -147,10 +150,12 @@ export class ReliableHostReputation { this.storage?.set(KEY, JSON.stringify({ version: 4, entries: this.entries })) } try { - if (this.storage === undefined) await update() - else await this.storage.lock(KEY, update) + if (this.storage !== undefined) + return this.storage.lock(KEY, () => Promise.resolve(update())).catch(() => {}) + update() } catch { /* Advisory persistence must never break lookup. */ } + return Promise.resolve() } } diff --git a/packages/sdk/src/overlay-tools/ReliableLookup.ts b/packages/sdk/src/overlay-tools/ReliableLookup.ts index 5dd85c2bf..d8b7f0896 100644 --- a/packages/sdk/src/overlay-tools/ReliableLookup.ts +++ b/packages/sdk/src/overlay-tools/ReliableLookup.ts @@ -44,7 +44,7 @@ export function boundedMs(value: number | undefined, fallback: number): number { } /** Races even a non-cooperative facilitator; cooperative work also receives abort. */ -export async function withinDeadline( +export function withinDeadline( work: (signal: AbortSignal) => Promise, ms: number, parent?: AbortSignal @@ -61,17 +61,15 @@ export async function withinDeadline( if (parent?.aborted === true) abort() else parent?.addEventListener('abort', abort, { once: true }) }) - try { - const pending = Promise.resolve().then(async () => { - if (controller.signal.aborted) throw new Error('Lookup cancelled') - return await work(controller.signal) - }) - return await Promise.race([pending, deadline]) - } finally { + const pending = Promise.resolve().then(() => { + if (controller.signal.aborted) throw new Error('Lookup cancelled') + return work(controller.signal) + }) + return Promise.race([pending, deadline]).finally(() => { clearTimeout(timer) parent?.removeEventListener('abort', abort) controller.abort() - } + }) } export function normalizeHosts(hosts: string[], allowHTTP: boolean): string[] { @@ -103,13 +101,13 @@ function failureReason(error: unknown): HostFailureReason { return 'transport' } -export async function requestReliableHost( +export function requestReliableHost( facilitator: OverlayLookupFacilitator, context: { reputation: ReliableHostReputation; network: string }, host: string, question: LookupQuestion, options: Omit, 'validate'> & { - validate: (answer: LookupFacilitatorAnswer, signal: AbortSignal) => Promise + validate: (answer: LookupFacilitatorAnswer, signal: AbortSignal) => T[] | Promise credit?: (values: T[]) => boolean onError?: (error: unknown) => void penalizeRejections?: boolean @@ -119,28 +117,29 @@ export async function requestReliableHost( ): Promise> { const { reputation, network } = context const budget = Math.min(boundedMs(options.hostTimeoutMs, 2000), remainingMs) - try { - const values = await withinDeadline( - async signal => { - const answer = await facilitator.lookup(host, question, budget, signal) - return await options.validate(answer, signal) - }, - budget, - parent - ) - if (!parent.aborted && options.credit?.(values) !== false) - void reputation.record(network, question.service, host) - return { host, kind: 'answer', values } - } catch (error) { - options.onError?.(error) - const reason = failureReason(error) - // Cancellation belongs to the operation, not to the host. - if ( - !parent.aborted && - !(error instanceof LookupValidationUnavailableError) && - !(reason === 'rejected' && options.penalizeRejections === false) - ) - void reputation.record(network, question.service, host, reason) - return { host, kind: reason } - } + return withinDeadline( + signal => + Promise.resolve(facilitator.lookup(host, question, budget, signal)).then(answer => + options.validate(answer, signal) + ), + budget, + parent + ) + .then((values): ReliableHostOutcome => { + if (!parent.aborted && options.credit?.(values) !== false) + void reputation.record(network, question.service, host) + return { host, kind: 'answer', values } + }) + .catch((error): ReliableHostOutcome => { + options.onError?.(error) + const reason = failureReason(error) + // Cancellation belongs to the operation, not to the host. + if ( + !parent.aborted && + !(error instanceof LookupValidationUnavailableError) && + !(reason === 'rejected' && options.penalizeRejections === false) + ) + void reputation.record(network, question.service, host, reason) + return { host, kind: reason } + }) } diff --git a/packages/sdk/src/overlay-tools/__tests/IndexedDBReputationStorage.test.ts b/packages/sdk/src/overlay-tools/__tests/IndexedDBReputationStorage.test.ts new file mode 100644 index 000000000..72686c43f --- /dev/null +++ b/packages/sdk/src/overlay-tools/__tests/IndexedDBReputationStorage.test.ts @@ -0,0 +1,94 @@ +import { IDBFactory } from 'fake-indexeddb' +import { indexedDBReputationStorage } from '../IndexedDBReputationStorage' +import { ReliableHostReputation } from '../ReliableHostReputation' + +const KEY = 'bsvsdk_overlay_host_reputation_v4' + +describe('transactional browser reputation', () => { + it('merges concurrent writers with independent stale read caches', async () => { + const factory = new IDBFactory() + const a = indexedDBReputationStorage(factory) + const b = indexedDBReputationStorage(factory) + await Promise.all([a.load!(KEY), b.load!(KEY)]) + expect(a.get(KEY)).toBeNull() + expect(b.get(KEY)).toBeNull() + const trackers = [new ReliableHostReputation(a), new ReliableHostReputation(b)] + await Promise.all( + Array.from({ length: 32 }, (_, i) => + trackers[i % 2].record('mainnet', `ls_service_${i}`, 'https://one.example', 'transport') + ) + ) + const reloaded = indexedDBReputationStorage(factory) + await reloaded.load!(KEY) + const entries = JSON.parse(reloaded.get(KEY)!).entries + expect(Object.keys(entries)).toHaveLength(32) + expect(Object.values(entries).every((entry: any) => entry.penalty === 2)).toBe(true) + }) + + it('serializes updates of the same key and refreshes reloaded ranking', async () => { + const factory = new IDBFactory() + const stores = [indexedDBReputationStorage(factory), indexedDBReputationStorage(factory)] + await Promise.all( + Array.from({ length: 20 }, (_, i) => { + const storage = stores[i % 2] + return storage.lock(KEY, async () => { + storage.set(KEY, String(Number(storage.get(KEY) ?? 0) + 1)) + }) + }) + ) + await stores[0].load!(KEY) + expect(stores[0].get(KEY)).toBe('20') + const tracker = new ReliableHostReputation(stores[0]) + await tracker.record('mainnet', 'ls_service', 'https://one.example', 'invalid') + const reloaded = new ReliableHostReputation(indexedDBReputationStorage(factory)) + await reloaded.refresh() + expect( + reloaded.rank('mainnet', 'ls_service', ['https://one.example', 'https://two.example']) + ).toEqual(['https://two.example', 'https://one.example']) + }) + + it('aborts failed updates without changing committed data', async () => { + const storage = indexedDBReputationStorage(new IDBFactory()) + await storage.lock(KEY, async () => storage.set(KEY, 'committed')) + await expect( + storage.lock(KEY, async () => { + storage.set(KEY, 'uncommitted') + throw new Error('synthetic update failure') + }) + ).rejects.toThrow('Reputation transaction aborted') + await storage.load!(KEY) + expect(storage.get(KEY)).toBe('committed') + }) + + it('rejects a malformed database without an uncaught event-handler error', async () => { + const factory = new IDBFactory() + await new Promise((resolve, reject) => { + const open = factory.open(KEY) + open.onsuccess = () => { + open.result.close() + resolve() + } + open.onerror = () => reject(open.error) + }) + const storage = indexedDBReputationStorage(factory) + await expect(storage.load!(KEY)).rejects.toBeDefined() + await expect( + new ReliableHostReputation(storage).record('mainnet', 'ls_test', 'https://one.example') + ).resolves.toBeUndefined() + }) + + it('fails open when browser database access is unavailable', async () => { + const factory = new IDBFactory() + jest.spyOn(factory, 'open').mockImplementation(() => { + throw new Error('access denied') + }) + const tracker = new ReliableHostReputation(indexedDBReputationStorage(factory)) + await expect(tracker.refresh()).rejects.toThrow('access denied') + await expect( + tracker.record('mainnet', 'ls_test', 'https://one.example', 'timeout') + ).resolves.toBeUndefined() + expect(tracker.rank('mainnet', 'ls_test', ['https://one.example'])).toEqual([ + 'https://one.example' + ]) + }) +}) 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 fa8171e36..724b86a92 100644 --- a/packages/sdk/src/overlay-tools/__tests/LookupResolver.additional.test.ts +++ b/packages/sdk/src/overlay-tools/__tests/LookupResolver.additional.test.ts @@ -343,7 +343,7 @@ describe('LookupResolver – additional coverage', () => { }) // Poison the reputation of the host so it enters backoff - const tracker: ReliableHostReputation = (r as any).hostReputation + const tracker: ReliableHostReputation = (r as any).reputation for (let i = 0; i < 5; i++) { await tracker.record('mainnet', 'ls_backoff_test', 'https://backing.off', 'transport') } @@ -363,7 +363,7 @@ describe('LookupResolver – additional coverage', () => { }) // Put the SLAP tracker into deep backoff - const tracker: ReliableHostReputation = (r as any).hostReputation + const tracker: ReliableHostReputation = (r as any).reputation for (let i = 0; i < 5; i++) { await tracker.record('mainnet', 'ls_slap', 'https://backed.off.slap', 'transport') } @@ -651,10 +651,10 @@ describe('LookupResolver – additional coverage', () => { }) // ----------------------------------------------------------------------- - // lookupHostWithTracking – invalid response tracking + // lookupHost – invalid response tracking // ----------------------------------------------------------------------- - describe('lookupHostWithTracking – non-output-list responses', () => { + describe('lookupHost – non-output-list responses', () => { it('does NOT penalize a host that returns a structurally valid freeform response', async () => { // Many lookup services legitimately return freeform shapes for some // queries (e.g. ls_kvstore for missing keys). A reachable host @@ -680,7 +680,7 @@ describe('LookupResolver – additional coverage', () => { }) } - const tracker: ReliableHostReputation = (r as any).hostReputation + const tracker: ReliableHostReputation = (r as any).reputation expect(tracker.snapshot('mainnet', 'ls_invalid', 'https://weird.host')).toBeUndefined() }) @@ -695,7 +695,7 @@ describe('LookupResolver – additional coverage', () => { const res = await r.queryDetailed({ service: 'ls_bad', query: {} }) expect(res.progress).toMatchObject({ status: 'unavailable', failedHosts: 1 }) - const tracker: ReliableHostReputation = (r as any).hostReputation + const tracker: ReliableHostReputation = (r as any).reputation const snap = tracker.snapshot('mainnet', 'ls_bad', 'https://malformed.host') expect(snap?.reason).toBe('malformed') expect(snap?.penalty).toBe(8) diff --git a/packages/sdk/src/overlay-tools/__tests/LookupResolver.resiliency.test.ts b/packages/sdk/src/overlay-tools/__tests/LookupResolver.resiliency.test.ts index fb13ab3e6..e806cd1a9 100644 --- a/packages/sdk/src/overlay-tools/__tests/LookupResolver.resiliency.test.ts +++ b/packages/sdk/src/overlay-tools/__tests/LookupResolver.resiliency.test.ts @@ -10,7 +10,7 @@ import { Transaction } from '../../transaction/index' import { LockingScript } from '../../script/index' const reputationOf = (resolver: LookupResolver): ReliableHostReputation => - (resolver as unknown as { hostReputation: ReliableHostReputation }).hostReputation + (resolver as unknown as { reputation: ReliableHostReputation }).reputation // -------------------------------------------------------------------------- // Test fixtures: distinct BEEFs representing distinct outputs diff --git a/packages/sdk/src/overlay-tools/__tests/LookupResolver.shared-reliability.test.ts b/packages/sdk/src/overlay-tools/__tests/LookupResolver.shared-reliability.test.ts index fcc4d78fb..e51b9debc 100644 --- a/packages/sdk/src/overlay-tools/__tests/LookupResolver.shared-reliability.test.ts +++ b/packages/sdk/src/overlay-tools/__tests/LookupResolver.shared-reliability.test.ts @@ -170,6 +170,22 @@ describe('standard package resolver reliability for every overlay service', () = }) }) + it('does not let a blocked reputation database hold up host recovery', async () => { + const { storage } = storageFixture() + storage.load = () => new Promise(() => {}) + const lookup = jest.fn(async () => answer) + const resolver = new LookupResolver({ + facilitator: { lookup }, + reliableReputationStorage: storage, + hostOverrides: { ls_identity: [good] } + }) + const pending = resolver.query({ service: 'ls_identity', query: {} }) + await jest.advanceTimersByTimeAsync(50) + await expect(pending).resolves.toEqual(answer) + expect(lookup).toHaveBeenCalledTimes(1) + expect(jest.getTimerCount()).toBe(0) + }) + it('aborts outstanding requests when a progressive consumer closes the iterator', async () => { const signals: AbortSignal[] = [] const resolver = new LookupResolver({ diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6a36f4086..cf449037f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1430,6 +1430,9 @@ importers: '@typescript/native': specifier: npm:typescript@7.0.2 version: typescript@7.0.2 + fake-indexeddb: + specifier: ^6.2.5 + version: 6.2.5 fast-check: specifier: ^4.9.0 version: 4.9.0 From 2124eaefd8fce93613e9f754b7fc43d76ce8bcfe Mon Sep 17 00:00:00 2001 From: Brayden Langley Date: Fri, 4 Sep 2026 15:53:22 -0700 Subject: [PATCH 06/11] Flatten transactional storage callbacks and preserve explicit errors --- packages/sdk/browser/lookup-recovery.mjs | 38 ++++--- .../docs/globalkv-reliability-validation.md | 6 +- .../IndexedDBReputationStorage.ts | 107 ++++++++++-------- .../sdk/src/overlay-tools/LookupResolver.ts | 14 +-- .../overlay-tools/ReliableHostReputation.ts | 8 +- 5 files changed, 94 insertions(+), 79 deletions(-) diff --git a/packages/sdk/browser/lookup-recovery.mjs b/packages/sdk/browser/lookup-recovery.mjs index 2e64e7b80..a75b5a590 100644 --- a/packages/sdk/browser/lookup-recovery.mjs +++ b/packages/sdk/browser/lookup-recovery.mjs @@ -32,29 +32,33 @@ async function chromePath() { throw new Error('Chrome or Chromium is required for lookup recovery tests') } +function installHealthReader() { + globalThis.readHealth = key => + new Promise((resolve, reject) => { + const open = indexedDB.open(key, 1) + open.onupgradeneeded = () => open.result.createObjectStore('state') + open.onerror = () => reject(new Error('Health database unavailable', { cause: open.error })) + open.onsuccess = () => { + const db = open.result + const tx = db.transaction('state') + const read = tx.objectStore('state').get(key) + read.onsuccess = () => resolve(JSON.parse(read.result ?? '{}')) + tx.oncomplete = () => db.close() + tx.onabort = () => { + db.close() + reject(new Error('Health read aborted', { cause: tx.error })) + } + } + }) +} + async function preparePage(page, origin) { await page.goto(origin) await page.waitForFunction(() => globalThis.bsv?.LookupResolver !== undefined) assert.equal(await page.evaluate(() => typeof indexedDB?.open), 'function') + await page.evaluate(installHealthReader) await page.evaluate( ({ services, host }) => { - globalThis.readHealth = key => - new Promise((resolve, reject) => { - const open = indexedDB.open(key, 1) - open.onupgradeneeded = () => open.result.createObjectStore('state') - open.onerror = () => reject(open.error) - open.onsuccess = () => { - const db = open.result - const tx = db.transaction('state') - const read = tx.objectStore('state').get(key) - read.onsuccess = () => resolve(JSON.parse(read.result ?? '{}')) - tx.oncomplete = () => db.close() - tx.onabort = () => { - db.close() - reject(tx.error) - } - } - }) const output = { beef: new bsv.Transaction( 1, diff --git a/packages/sdk/docs/globalkv-reliability-validation.md b/packages/sdk/docs/globalkv-reliability-validation.md index 49e5eed36..f347c3435 100644 --- a/packages/sdk/docs/globalkv-reliability-validation.md +++ b/packages/sdk/docs/globalkv-reliability-validation.md @@ -56,10 +56,10 @@ uses a disposable browser profile, and removes its package extraction afterwards No browser account, production endpoint or existing storage is used. Ten consecutive runs of the transactional fixture passed (200 reloads total). -The final local SDK coverage suite passed 165 suites / 6,004 tests and +The local SDK coverage suite passed 165 suites / 6,004 tests and one snapshot, with 94.37% statement, 86.51% branch, 95.29% function and 95.29% line coverage. Packed ESM/CJS exports, strict declarations, source maps, publint, -Vite, esbuild and UMD contracts passed. UMD is 554,955 bytes within the unchanged 555,000-byte budget. +Vite, esbuild and UMD contracts passed. UMD is 554,989 bytes within the unchanged 555,000-byte budget. The PR records the final exact-head results; historical measurements are not a substitute for its current hosted merge gate. @@ -132,7 +132,7 @@ The Hermes profile exposed bytecode growth from redundant async wrappers in the shared resolver. Promise-returning adapters and synchronous structural validators avoid unnecessary transpiled state machines. Mobile, browser, cancellation and deadline checks validate the same resolver with the original bundle budgets. The -packed mobile fixture measures 1,718,536 bytes for Metro and 3,493,469 bytes for +packed mobile fixture measures 1,718,563 bytes for Metro and 3,493,631 bytes for Hermes, below the unchanged 1,720,000 and 3,495,000 byte limits. Node 24.20 accepts some invalid punycode labels that earlier URL parsers rejected. diff --git a/packages/sdk/src/overlay-tools/IndexedDBReputationStorage.ts b/packages/sdk/src/overlay-tools/IndexedDBReputationStorage.ts index deabbb27b..56405a7d9 100644 --- a/packages/sdk/src/overlay-tools/IndexedDBReputationStorage.ts +++ b/packages/sdk/src/overlay-tools/IndexedDBReputationStorage.ts @@ -1,58 +1,69 @@ import type { ReliableReputationStorage } from './ReliableHostReputation.js' /** Transactional browser health storage. LocalStorage caches are not coherent across tabs. */ -export function indexedDBReputationStorage(factory: IDBFactory): ReliableReputationStorage { - let value: string | null = null - const run = (key: string, action?: () => Promise): Promise => - new Promise((resolve, reject) => { - const request = factory.open('bsvsdk_overlay_host_reputation_v4') - request.onerror = () => reject(request.error) - request.onupgradeneeded = () => request.result.createObjectStore('state') - request.onsuccess = () => { - const database = request.result - let transaction: IDBTransaction - try { - transaction = database.transaction( - 'state', - action === undefined ? 'readonly' : 'readwrite' - ) - } catch (error) { - database.close() - reject(error) - return - } - const state = transaction.objectStore('state') - let result: T - const finish = (event: Event): void => { - database.close() - if (event.type === 'complete') resolve(result) - else reject(transaction.error ?? new Error('Reputation transaction aborted')) - } - transaction.oncomplete = finish - transaction.onabort = finish - const read = state.get(key) - read.onsuccess = () => { - value = read.result ?? null - if (action !== undefined) { - // The health update is synchronous; its resolved promise stays inside - // this transaction's microtask checkpoint, before automatic commit. - Promise.resolve() - .then(action) - .then(output => { - result = output - state.put(value, key) - }) - .catch(() => transaction.abort()) - } +interface ReadCache { + value: string | null +} + +function run( + factory: IDBFactory, + cache: ReadCache, + key: string, + action?: () => Promise +): Promise { + return new Promise((resolve, reject) => { + const request = factory.open(key) + request.onerror = () => + reject(new Error('Reputation database unavailable', { cause: request.error })) + request.onupgradeneeded = () => request.result.createObjectStore('state') + request.onsuccess = () => { + const database = request.result + let transaction: IDBTransaction + try { + transaction = database.transaction('state', action === undefined ? 'readonly' : 'readwrite') + } catch (error) { + database.close() + reject(error) + return + } + const state = transaction.objectStore('state') + let result: T + const finish = (event: Event): void => { + database.close() + if (event.type === 'complete') resolve(result) + else reject(transaction.error ?? new Error('Reputation transaction aborted')) + } + transaction.oncomplete = finish + transaction.onabort = finish + const read = state.get(key) + read.onsuccess = () => { + cache.value = read.result ?? null + if (action !== undefined) { + // The health update is synchronous; its resolved promise stays inside + // this transaction's microtask checkpoint, before automatic commit. + Promise.resolve() + .then(action) + .then(output => { + result = output + state.put(cache.value, key) + }) + .catch(() => transaction.abort()) } } - }) + } + }) +} + +/** Transactional browser health storage; each update reads the committed state. */ +export function indexedDBReputationStorage(factory: IDBFactory): ReliableReputationStorage { + const cache: ReadCache = { value: null } + return { - get: () => value, + get: () => cache.value, set: (_key, next) => { - value = next + cache.value = next }, - load: key => run(key), - lock: (key, action) => run(key, action) + load: key => run(factory, cache, key), + lock: (key, action) => run(factory, cache, key, action) } } diff --git a/packages/sdk/src/overlay-tools/LookupResolver.ts b/packages/sdk/src/overlay-tools/LookupResolver.ts index ed26db72a..6806ba30e 100644 --- a/packages/sdk/src/overlay-tools/LookupResolver.ts +++ b/packages/sdk/src/overlay-tools/LookupResolver.ts @@ -733,7 +733,7 @@ export default class LookupResolver { new HTTPSOverlayLookupFacilitator(undefined, this.networkPreset === 'local') this.slapTrackers = config.slapTrackers ?? this.defaultSlapTrackers() const hostOverrides = config.hostOverrides ?? {} - this.assertValidOverrideServices(hostOverrides) + this.validateOverrides(hostOverrides) this.hostOverrides = hostOverrides this.additionalHosts = config.additionalHosts ?? {} this.telemetry = new Telemetry(config.telemetry) @@ -861,7 +861,7 @@ export default class LookupResolver { hostTimeoutMs: MAX_TRACKER_WAIT_TIME, validate: answer => { if (!isOutputListAnswer(answer)) throw new LookupValidationError('malformed') - const hosts = this.extractHostsFromAnswer(answer, question.service) + const hosts = this.extractHosts(answer, question.service) if (hosts.length !== answer.outputs.length) complete = false return hosts } @@ -1124,7 +1124,7 @@ export default class LookupResolver { softTimeoutMs: options?.softTimeoutMs, waitForAllHosts: options?.waitForAllHosts ?? options?.holdForUnknownHosts ?? false, correlationId, - resolveTxId: (output, now) => this.resolveTxIdForOutput(output, now) + resolveTxId: (output, now) => this.resolveOutputTxId(output, now) }) this.telemetry.capture({ @@ -1180,7 +1180,7 @@ export default class LookupResolver { /** * Extracts competent host domains from a SLAP tracker response. */ - protected extractHostsFromAnswer(answer: LookupAnswer, service: string): string[] { + protected extractHosts(answer: LookupAnswer, service: string): string[] { const hosts: string[] = [] if (answer.type !== 'output-list') return hosts for (const output of answer.outputs) { @@ -1205,7 +1205,7 @@ export default class LookupResolver { * hint only after it matches Transaction.fromBEEF(beef).id('hex'), memoized by * the BEEF byte sequence. Returns null for unparseable BEEF or a mismatched hint. */ - private resolveTxIdForOutput( + private resolveOutputTxId( output: { txid?: string; beef: number[]; outputIndex: number; context?: number[] }, now: number ): string | null { @@ -1231,7 +1231,7 @@ export default class LookupResolver { if (firstKey !== undefined) m.delete(firstKey) } - private assertValidOverrideServices(overrides: Record): void { + private validateOverrides(overrides: Record): void { for (const service of Object.keys(overrides)) { if (!service.startsWith('ls_')) { throw new Error(`Host override service names must start with "ls_": ${service}`) @@ -1263,7 +1263,7 @@ export default class LookupResolver { throw new LookupValidationError('malformed') if ( isOutputListAnswer(answer) && - answer.outputs.some(output => this.resolveTxIdForOutput(output, Date.now()) === null) + answer.outputs.some(output => this.resolveOutputTxId(output, Date.now()) === null) ) throw new LookupValidationError('malformed') return [answer] diff --git a/packages/sdk/src/overlay-tools/ReliableHostReputation.ts b/packages/sdk/src/overlay-tools/ReliableHostReputation.ts index 9cd00d947..fef194ebc 100644 --- a/packages/sdk/src/overlay-tools/ReliableHostReputation.ts +++ b/packages/sdk/src/overlay-tools/ReliableHostReputation.ts @@ -131,7 +131,7 @@ export class ReliableHostReputation { host: string, reason?: HostFailureReason ): Promise { - const update = (): void => { + const update = (): Promise => { const now = Date.now() const entries = this.storage === undefined ? this.sanitize(this.entries, now) : this.read(now) const key = this.scope(network, service, host) @@ -148,11 +148,11 @@ export class ReliableHostReputation { } this.entries = this.sanitize(entries, now) this.storage?.set(KEY, JSON.stringify({ version: 4, entries: this.entries })) + return Promise.resolve() } try { - if (this.storage !== undefined) - return this.storage.lock(KEY, () => Promise.resolve(update())).catch(() => {}) - update() + if (this.storage !== undefined) return this.storage.lock(KEY, update).catch(() => {}) + return update() } catch { /* Advisory persistence must never break lookup. */ } From da0ecb6f6e3e79c58c4b6f2cd1719d763f3d4e8f Mon Sep 17 00:00:00 2001 From: Brayden Langley Date: Fri, 4 Sep 2026 15:56:30 -0700 Subject: [PATCH 07/11] Catch all advisory persistence failures through the promise chain --- packages/sdk/src/overlay-tools/ReliableHostReputation.ts | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/packages/sdk/src/overlay-tools/ReliableHostReputation.ts b/packages/sdk/src/overlay-tools/ReliableHostReputation.ts index fef194ebc..6fd5af4e0 100644 --- a/packages/sdk/src/overlay-tools/ReliableHostReputation.ts +++ b/packages/sdk/src/overlay-tools/ReliableHostReputation.ts @@ -150,12 +150,9 @@ export class ReliableHostReputation { this.storage?.set(KEY, JSON.stringify({ version: 4, entries: this.entries })) return Promise.resolve() } - try { - if (this.storage !== undefined) return this.storage.lock(KEY, update).catch(() => {}) - return update() - } catch { - /* Advisory persistence must never break lookup. */ - } + // Catch synchronous adapter failures and asynchronous transaction rejection. return Promise.resolve() + .then(() => (this.storage === undefined ? update() : this.storage.lock(KEY, update))) + .catch(() => {}) } } From 5ba67a9d8b63f171685b43d8701f8a09bbab512b Mon Sep 17 00:00:00 2001 From: Brayden Langley Date: Fri, 4 Sep 2026 16:28:18 -0700 Subject: [PATCH 08/11] Simplify shared lookup scheduling and atomic persistence --- .../sdk/docs/globalkv-reliability-draft.md | 3 +- .../docs/globalkv-reliability-validation.md | 36 ++-- packages/sdk/docs/overlay-lookup-migration.md | 4 +- .../__tests/GlobalKVStore.reliable.test.ts | 9 +- .../IndexedDBReputationStorage.ts | 50 ++--- .../sdk/src/overlay-tools/LookupResolver.ts | 183 +++++++----------- .../overlay-tools/ReliableHostReputation.ts | 39 ++-- .../sdk/src/overlay-tools/ReliableLookup.ts | 9 +- .../IndexedDBReputationStorage.test.ts | 27 +-- .../LookupResolver.shared-reliability.test.ts | 11 +- .../__tests/ReliableHostReputation.test.ts | 27 ++- 11 files changed, 180 insertions(+), 218 deletions(-) diff --git a/packages/sdk/docs/globalkv-reliability-draft.md b/packages/sdk/docs/globalkv-reliability-draft.md index f2e2b2161..b84b121ed 100644 --- a/packages/sdk/docs/globalkv-reliability-draft.md +++ b/packages/sdk/docs/globalkv-reliability-draft.md @@ -101,7 +101,8 @@ and bounded parallel work over suppressing every probe during an outage. inside each write transaction. LocalStorage plus Web Locks allowed stale cross-tab reads to overwrite newer updates in the repeated browser fixture. Health refresh is capped at 50 ms; unavailable storage cannot hold up host - recovery. Custom adapters must provide coherent, serialized updates. + recovery. Custom adapters use asynchronous reads and an atomic update method + whose synchronous transform runs against the latest committed state. - At most 32 candidate hosts and 32 trackers per operation. Truncation marks discovery incomplete. This is a resource bound, not a guarantee that an arbitrary 33rd healthy host will be contacted; scalable candidate scheduling diff --git a/packages/sdk/docs/globalkv-reliability-validation.md b/packages/sdk/docs/globalkv-reliability-validation.md index f347c3435..4d84874b7 100644 --- a/packages/sdk/docs/globalkv-reliability-validation.md +++ b/packages/sdk/docs/globalkv-reliability-validation.md @@ -44,6 +44,7 @@ pnpm --filter @bsv/sdk test:coverage --runInBand pnpm --filter @bsv/sdk test:property pnpm --filter @bsv/sdk pack:check pnpm --filter @bsv/sdk test:browser +pnpm --filter @bsv/wallet-toolbox-client test:browser pnpm --filter @bsv/wallet-toolbox-mobile test:mobile pnpm --filter docs-site test pnpm --filter docs-site build @@ -56,12 +57,11 @@ uses a disposable browser profile, and removes its package extraction afterwards No browser account, production endpoint or existing storage is used. Ten consecutive runs of the transactional fixture passed (200 reloads total). -The local SDK coverage suite passed 165 suites / 6,004 tests and -one snapshot, with 94.37% statement, 86.51% branch, 95.29% function and 95.29% line -coverage. Packed ESM/CJS exports, strict declarations, source maps, publint, -Vite, esbuild and UMD contracts passed. UMD is 554,989 bytes within the unchanged 555,000-byte budget. -The PR records the final exact-head results; historical measurements are not a -substitute for its current hosted merge gate. +The local SDK coverage suite passed 165 suites / 6,004 tests and one snapshot. +Packed ESM/CJS exports, strict declarations, source maps, publint, Vite, esbuild +and UMD contracts passed. The PR records exact-head coverage and platform sizes; +all original coverage and bundle budgets remain in force. Historical measurements +are not a substitute for the current hosted merge gate. The existing property profile passed three suites / six tests. Conformance parsed 76 files / 6,690 vectors without errors; no new portable protocol is @@ -102,11 +102,9 @@ including the wallet browser consumer and packed Chromium recovery fixture. No force-install or peer-validation bypass is used. Source versions and ranges are listed in the generated package API/migration table. -Workspace tests also exercise the SDK 3 graph. Three wallet suites initially -encountered missing middleware output while package checks rebuilt those files; -after builds completed, all three suites / 46 tests passed. The other 219 wallet -suites / 2,103 tests passed in the first run. CI performs builds before tests, -so this local scheduling mistake does not require a source workaround. +Workspace coverage, package consumer checks and browser/mobile profiles exercise +the SDK 3 dependency graph. Builds must finish before checks that consume their +output; overlapping package rebuilds can temporarily remove required artifacts. ## Tooling dependency remediation @@ -131,9 +129,9 @@ cover the storage boundary. Refresh is capped at 50 ms and remains advisory. The Hermes profile exposed bytecode growth from redundant async wrappers in the shared resolver. Promise-returning adapters and synchronous structural validators avoid unnecessary transpiled state machines. Mobile, browser, cancellation and -deadline checks validate the same resolver with the original bundle budgets. The -packed mobile fixture measures 1,718,563 bytes for Metro and 3,493,631 bytes for -Hermes, below the unchanged 1,720,000 and 3,495,000 byte limits. +deadline checks validate the same resolver with the original bundle budgets. +Metro and Hermes raw, gzip and Brotli measurements must each pass their existing +limits; the PR records the final platform results. Node 24.20 accepts some invalid punycode labels that earlier URL parsers rejected. Discovery advertisement validation explicitly requires those labels to decode, @@ -145,3 +143,13 @@ The governed advertisement mutation profile passes at 86.83% (145 detected of The IndexedDB transaction tests use the existing workspace `fake-indexeddb` 6.2.5 version as an SDK development dependency. It is excluded from runtime bundles; real Chromium independently verifies browser persistence. + +## CI runtime and bundle parity + +Platform checks use Node 24.20, matching CI. Its zlib build produces different +compressed sizes from Node 24.16, so the earlier local compression measurements +are not the release gate. The final PR records the Node 24.20 browser and mobile +results under the original budgets. The resolver drains progress through one +async iterator, groups settlement counters once per query and exposes an explicit +atomic storage update instead of a mutable read cache and lock callback. Tests +retain the same progress, cancellation, recovery and concurrent-write assertions. diff --git a/packages/sdk/docs/overlay-lookup-migration.md b/packages/sdk/docs/overlay-lookup-migration.md index 6148fba77..cf06ddb26 100644 --- a/packages/sdk/docs/overlay-lookup-migration.md +++ b/packages/sdk/docs/overlay-lookup-migration.md @@ -39,7 +39,9 @@ service validator through `queryReliable` where those checks are needed. - v4 reputation is scoped by network/service/normalized host, bounded and decays. v1–v3 records are ignored without deletion. IndexedDB transactions protect browser updates; refresh is capped at 50 ms and fails open. Custom - `reliableReputationStorage` adapters must provide coherent serialized updates. + `reliableReputationStorage` adapters provide asynchronous `get(key)` and + atomic `update(key, transform)` methods. The synchronous transform must receive + the latest committed state inside the storage transaction. Unsupported environments and legacy get/set-only adapters use in-memory health. - HTTP semantic rejection and valid freeform responses remain neutral for standard availability reputation. HTTP 408/425/429 and 5xx are availability diff --git a/packages/sdk/src/kvstore/__tests/GlobalKVStore.reliable.test.ts b/packages/sdk/src/kvstore/__tests/GlobalKVStore.reliable.test.ts index e784d9a78..897f1f3a6 100644 --- a/packages/sdk/src/kvstore/__tests/GlobalKVStore.reliable.test.ts +++ b/packages/sdk/src/kvstore/__tests/GlobalKVStore.reliable.test.ts @@ -170,11 +170,10 @@ describe('verified KV reliability', () => { it('recovered host recorded in v4 cooldown is probed and rehabilitated', async () => { const values = new Map() const storage = { - get: key => values.get(key), - set: (key, value) => { - values.set(key, value) - }, - lock: async (_name, action) => await action() + get: async key => values.get(key), + update: async (key, transform) => { + values.set(key, transform(values.get(key))) + } } const reputation = new ReliableHostReputation(storage) await reputation.record('mainnet', 'ls_kvstore', good, 'invalid') diff --git a/packages/sdk/src/overlay-tools/IndexedDBReputationStorage.ts b/packages/sdk/src/overlay-tools/IndexedDBReputationStorage.ts index 56405a7d9..d8ee4afe3 100644 --- a/packages/sdk/src/overlay-tools/IndexedDBReputationStorage.ts +++ b/packages/sdk/src/overlay-tools/IndexedDBReputationStorage.ts @@ -1,17 +1,11 @@ import type { ReliableReputationStorage } from './ReliableHostReputation.js' -/** Transactional browser health storage. LocalStorage caches are not coherent across tabs. */ -interface ReadCache { - value: string | null -} - -function run( +function run( factory: IDBFactory, - cache: ReadCache, key: string, - action?: () => Promise -): Promise { - return new Promise((resolve, reject) => { + transform?: (current: string | null | undefined) => string +): Promise { + return new Promise((resolve, reject) => { const request = factory.open(key) request.onerror = () => reject(new Error('Reputation database unavailable', { cause: request.error })) @@ -20,14 +14,17 @@ function run( const database = request.result let transaction: IDBTransaction try { - transaction = database.transaction('state', action === undefined ? 'readonly' : 'readwrite') + transaction = database.transaction( + 'state', + transform === undefined ? 'readonly' : 'readwrite' + ) } catch (error) { database.close() reject(error) return } const state = transaction.objectStore('state') - let result: T + let result: string | null = null const finish = (event: Event): void => { database.close() if (event.type === 'complete') resolve(result) @@ -37,17 +34,14 @@ function run( transaction.onabort = finish const read = state.get(key) read.onsuccess = () => { - cache.value = read.result ?? null - if (action !== undefined) { - // The health update is synchronous; its resolved promise stays inside - // this transaction's microtask checkpoint, before automatic commit. - Promise.resolve() - .then(action) - .then(output => { - result = output - state.put(cache.value, key) - }) - .catch(() => transaction.abort()) + result = typeof read.result === 'string' ? read.result : null + try { + if (transform !== undefined) { + result = transform(result) + state.put(result, key) + } + } catch { + transaction.abort() } } } @@ -56,14 +50,8 @@ function run( /** Transactional browser health storage; each update reads the committed state. */ export function indexedDBReputationStorage(factory: IDBFactory): ReliableReputationStorage { - const cache: ReadCache = { value: null } - return { - get: () => cache.value, - set: (_key, next) => { - cache.value = next - }, - load: key => run(factory, cache, key), - lock: (key, action) => run(factory, cache, key, action) + get: key => run(factory, key), + update: (key, transform) => run(factory, key, transform).then(() => {}) } } diff --git a/packages/sdk/src/overlay-tools/LookupResolver.ts b/packages/sdk/src/overlay-tools/LookupResolver.ts index 6806ba30e..4ffce2a02 100644 --- a/packages/sdk/src/overlay-tools/LookupResolver.ts +++ b/packages/sdk/src/overlay-tools/LookupResolver.ts @@ -10,6 +10,7 @@ import { boundedMs, normalizeHosts, requestReliableHost, + isLookupRejection, LookupValidationError, type ReliableLookupOptions, type ReliableLookupResult @@ -399,14 +400,9 @@ export class HTTPSOverlayLookupFacilitator implements OverlayLookupFacilitator { // 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. - const kind: LookupHTTPErrorKind = - response.status < 400 || - response.status === 408 || - response.status === 425 || - response.status === 429 || - response.status >= 500 - ? 'availability' - : 'semantic' + const kind: LookupHTTPErrorKind = isLookupRejection(response.status) + ? 'semantic' + : 'availability' throw new LookupHTTPError(response.status, kind, response.statusText) } response = await boundLookupResponse(response, signal) @@ -500,19 +496,21 @@ interface LookupQuerySessionOptions { class LookupQuerySession { readonly startedAt = Date.now() - readonly hostCount: number - readonly correlationId?: string - completedHosts = 0 - successfulHosts = 0 - emptyHosts = 0 - failedHosts = 0 - rejectedHosts = 0 - freeformHosts = 0 + readonly summary: Pick< + LookupAnswerProgress, + | 'hostCount' + | 'completedHosts' + | 'successfulHosts' + | 'emptyHosts' + | 'failedHosts' + | 'rejectedHosts' + | 'freeformHosts' + | 'discoveryComplete' + | 'correlationId' + > emittedFinal = false - private readonly discoveryComplete: boolean private readonly graceMs: number - private readonly softTimeoutMs?: number private readonly waitForAllHosts: boolean private readonly resolveTxId: LookupQuerySessionOptions['resolveTxId'] private readonly outputsMap = new Map< @@ -529,13 +527,23 @@ class LookupQuerySession { private emittedOnce = false constructor(options: LookupQuerySessionOptions) { - this.hostCount = options.hostCount - this.discoveryComplete = options.discoveryComplete + this.summary = { + hostCount: options.hostCount, + completedHosts: 0, + successfulHosts: 0, + emptyHosts: 0, + failedHosts: 0, + rejectedHosts: 0, + freeformHosts: 0, + discoveryComplete: options.discoveryComplete, + ...(options.correlationId !== undefined ? { correlationId: options.correlationId } : {}) + } this.graceMs = options.graceMs - this.softTimeoutMs = options.softTimeoutMs this.waitForAllHosts = options.waitForAllHosts - this.correlationId = options.correlationId this.resolveTxId = options.resolveTxId + if (typeof options.softTimeoutMs === 'number' && options.softTimeoutMs >= 0) { + this.softTimer = setTimeout(() => this.push({ kind: 'soft' }), options.softTimeoutMs) + } } private push(event: LookupQueryEvent): void { @@ -546,29 +554,19 @@ class LookupQuerySession { waiter() } - recordOutputAnswer(answer: LookupAnswer): void { - this.successfulHosts++ - if (answer.outputs.length === 0) { - this.emptyHosts++ - return + record( + kind: 'successfulHosts' | 'freeformHosts' | 'rejectedHosts' | 'failedHosts', + answer?: LookupAnswer + ): void { + this.summary[kind]++ + if (answer !== undefined) { + if (answer.outputs.length === 0) this.summary.emptyHosts++ + else this.push({ kind: 'answer', answer }) } - this.push({ kind: 'answer', answer }) - } - - recordFreeformAnswer(): void { - this.freeformHosts++ - } - - recordRejection(): void { - this.rejectedHosts++ - } - - recordAvailabilityFailure(): void { - this.failedHosts++ } recordDone(): void { - this.completedHosts++ + this.summary.completedHosts++ this.push({ kind: 'done' }) } @@ -588,11 +586,11 @@ class LookupQuerySession { } private completionStatus(): LookupAnswerProgress['status'] { - if (this.successfulHosts === 0) return 'unavailable' + if (this.summary.successfulHosts === 0) return 'unavailable' if ( - this.discoveryComplete && - this.completedHosts === this.hostCount && - this.successfulHosts === this.hostCount + this.summary.discoveryComplete && + this.summary.completedHosts === this.summary.hostCount && + this.summary.successfulHosts === this.summary.hostCount ) return 'complete' return 'incomplete' @@ -604,16 +602,8 @@ class LookupQuerySession { 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, - status: this.completionStatus(), - ...(this.correlationId !== undefined ? { correlationId: this.correlationId } : {}) + ...this.summary, + status: this.completionStatus() } } @@ -637,59 +627,33 @@ class LookupQuerySession { return null } - private handleGrace(): LookupAnswerProgress | null { - if (this.emittedOnce || this.waitForAllHosts) return null + private processEvent(event: LookupQueryEvent): LookupAnswerProgress | null { + if (event.kind === 'answer') return this.handleAnswer(event.answer) + if (event.kind === 'done' || this.emittedOnce) return null + if (event.kind === 'grace' && this.waitForAllHosts) return null + this.graceFired = true this.emittedOnce = true return this.snapshot(false) } - private handleSoft(): LookupAnswerProgress | null { - let snapshot: LookupAnswerProgress | null = null - if (!this.emittedOnce) { - this.graceFired = true - this.emittedOnce = true - snapshot = this.snapshot(false) + /** Drain queued events without creating a second async iterator. */ + nextProgress(): Promise { + while (this.queue.length > 0) { + const progress = this.processEvent(this.queue.shift() as LookupQueryEvent) + if (progress !== null) return Promise.resolve(progress) + } + if (this.summary.completedHosts === this.summary.hostCount) { + this.emittedFinal = true + return Promise.resolve(this.snapshot(true)) } - return snapshot - } - - private nextEvent(): Promise { - if (this.queue.length > 0) return Promise.resolve(this.queue.shift() as LookupQueryEvent) return new Promise(resolve => { this.waiter = resolve - }).then(() => this.queue.shift() as LookupQueryEvent) - } - - private processEvent(event: LookupQueryEvent): LookupAnswerProgress | null { - switch (event.kind) { - case 'answer': - return this.handleAnswer(event.answer) - case 'grace': - return this.handleGrace() - case 'soft': - return this.handleSoft() - case 'done': - return null - } + }).then(() => this.nextProgress()) } - async *progress(): AsyncIterable { - if (typeof this.softTimeoutMs === 'number' && this.softTimeoutMs >= 0) { - this.softTimer = setTimeout(() => this.push({ kind: 'soft' }), this.softTimeoutMs) - } - try { - while (this.completedHosts < this.hostCount || this.queue.length > 0) { - const event = await this.nextEvent() - const outcome = this.processEvent(event) - if (outcome != null) yield outcome - } - 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) - } + close(): void { + if (this.graceTimer !== null) clearTimeout(this.graceTimer) + if (this.softTimer !== null) clearTimeout(this.softTimer) } } @@ -861,7 +825,7 @@ export default class LookupResolver { hostTimeoutMs: MAX_TRACKER_WAIT_TIME, validate: answer => { if (!isOutputListAnswer(answer)) throw new LookupValidationError('malformed') - const hosts = this.extractHosts(answer, question.service) + const hosts = this.extractHostsFromAnswer(answer, question.service) if (hosts.length !== answer.outputs.length) complete = false return hosts } @@ -997,9 +961,9 @@ export default class LookupResolver { ): void { let outcome: 'empty' | 'success' | 'freeform' = 'freeform' if (isOutputListAnswer(answer)) { - session.recordOutputAnswer(answer) + session.record('successfulHosts', answer) outcome = answer.outputs.length === 0 ? 'empty' : 'success' - } else session.recordFreeformAnswer() + } else session.record('freeformHosts') this.captureHost(service, host, outcome, Date.now() - hostStartedAt, correlationId) } @@ -1014,8 +978,7 @@ export default class LookupResolver { notificationCooldownMs } = context const semanticRejection = isSemanticLookupRejection(error) - if (semanticRejection) session.recordRejection() - else session.recordAvailabilityFailure() + session.record(semanticRejection ? 'rejectedHosts' : 'failedHosts') this.captureHost( service, host, @@ -1038,7 +1001,7 @@ export default class LookupResolver { signal: AbortSignal, remaining: () => number ): void { - const correlationId = session.correlationId + const correlationId = session.summary.correlationId const notificationCooldownMs = this.notificationCooldown(options) for (const host of hosts) { const hostStartedAt = Date.now() @@ -1053,7 +1016,7 @@ export default class LookupResolver { }) .catch(error => { if (signal.aborted) { - session.recordAvailabilityFailure() + session.record('failedHosts') return } this.recordFailure( @@ -1150,13 +1113,15 @@ export default class LookupResolver { ) try { - for await (const progress of session.progress()) { + while (!session.emittedFinal) { + const progress = await session.nextProgress() if (progress.isFinal) { this.captureCompletion(question.service, progress, Date.now() - session.startedAt) } yield progress } } finally { + session.close() clearTimeout(timer) options?.signal?.removeEventListener('abort', abort) controller.abort() @@ -1169,7 +1134,7 @@ export default class LookupResolver { attributes: { service: question.service, hostCount, - completedHosts: session.completedHosts, + completedHosts: session.summary.completedHosts, durationMs: Date.now() - session.startedAt } }) @@ -1180,7 +1145,7 @@ export default class LookupResolver { /** * Extracts competent host domains from a SLAP tracker response. */ - protected extractHosts(answer: LookupAnswer, service: string): string[] { + protected extractHostsFromAnswer(answer: LookupAnswer, service: string): string[] { const hosts: string[] = [] if (answer.type !== 'output-list') return hosts for (const output of answer.outputs) { diff --git a/packages/sdk/src/overlay-tools/ReliableHostReputation.ts b/packages/sdk/src/overlay-tools/ReliableHostReputation.ts index 6fd5af4e0..25a9e4831 100644 --- a/packages/sdk/src/overlay-tools/ReliableHostReputation.ts +++ b/packages/sdk/src/overlay-tools/ReliableHostReputation.ts @@ -9,12 +9,9 @@ export interface ReliableReputationEntry { reason?: HostFailureReason } export interface ReliableReputationStorage { - get: (key: string) => string | null | undefined - set: (key: string, value: string) => void - /** Refresh a synchronous read cache from transactional storage, when needed. */ - load?: (key: string) => Promise - /** Must serialize coherent read/modify/write across every writer. */ - lock: (name: string, action: () => Promise) => Promise + get: (key: string) => Promise + /** Transform committed state atomically; the transform must be synchronous. */ + update: (key: string, transform: (current: string | null | undefined) => string) => Promise } const KEY = 'bsvsdk_overlay_host_reputation_v4' const TTL = 86400000 @@ -90,9 +87,11 @@ export class ReliableHostReputation { ) } - private read(now: number): Record { + private decode( + raw: string | null | undefined, + now: number + ): Record { try { - const raw = this.storage?.get(KEY) if (raw == null || raw.length > 1024 * 1024) return {} const envelope = JSON.parse(raw) return envelope?.version === 4 ? this.sanitize(envelope.entries, now) : {} @@ -103,12 +102,15 @@ export class ReliableHostReputation { /** Advisory refresh; callers bound this independently of host work. */ refresh(): Promise { - return this.storage?.load?.(KEY) ?? Promise.resolve() + if (this.storage === undefined) return Promise.resolve() + return this.storage.get(KEY).then(raw => { + this.entries = this.decode(raw, Date.now()) + }) } rank(network: string, service: string, hosts: string[]): string[] { const now = Date.now() - this.entries = this.storage === undefined ? this.sanitize(this.entries, now) : this.read(now) + this.entries = this.sanitize(this.entries, now) const score = (host: string): number => { const e = this.entries[this.scope(network, service, host)] if (e === undefined) return 0 @@ -131,9 +133,10 @@ export class ReliableHostReputation { host: string, reason?: HostFailureReason ): Promise { - const update = (): Promise => { + const update = (raw?: string | null): void => { const now = Date.now() - const entries = this.storage === undefined ? this.sanitize(this.entries, now) : this.read(now) + const entries = + this.storage === undefined ? this.sanitize(this.entries, now) : this.decode(raw, now) const key = this.scope(network, service, host) const previous = entries[key] const weights = { timeout: 1, transport: 2, rejected: 2, malformed: 8, invalid: 16 } @@ -147,12 +150,18 @@ export class ReliableHostReputation { reason } this.entries = this.sanitize(entries, now) - this.storage?.set(KEY, JSON.stringify({ version: 4, entries: this.entries })) - return Promise.resolve() } // Catch synchronous adapter failures and asynchronous transaction rejection. return Promise.resolve() - .then(() => (this.storage === undefined ? update() : this.storage.lock(KEY, update))) + .then(() => { + if (this.storage !== undefined) { + return this.storage.update(KEY, raw => { + update(raw) + return JSON.stringify({ version: 4, entries: this.entries }) + }) + } + update() + }) .catch(() => {}) } } diff --git a/packages/sdk/src/overlay-tools/ReliableLookup.ts b/packages/sdk/src/overlay-tools/ReliableLookup.ts index d8b7f0896..d7e257292 100644 --- a/packages/sdk/src/overlay-tools/ReliableLookup.ts +++ b/packages/sdk/src/overlay-tools/ReliableLookup.ts @@ -88,15 +88,18 @@ export function normalizeHosts(hosts: string[], allowHTTP: boolean): string[] { return [...normalized] } +/** Non-retryable HTTP rejection, shared by transport and host accounting. */ +export function isLookupRejection(status: number): boolean { + return status >= 400 && status < 500 && ![408, 425, 429].includes(status) +} + function failureReason(error: unknown): HostFailureReason { if (error instanceof LookupValidationError) return error.reason if (error instanceof Error && /deadline|timed out|abort/i.test(error.message)) return 'timeout' if (error instanceof SyntaxError) return 'malformed' if (typeof error === 'object' && error !== null && 'status' in error) { const status = Number(error.status) - return status >= 400 && status < 500 && ![408, 425, 429].includes(status) - ? 'rejected' - : 'transport' + return isLookupRejection(status) ? 'rejected' : 'transport' } return 'transport' } diff --git a/packages/sdk/src/overlay-tools/__tests/IndexedDBReputationStorage.test.ts b/packages/sdk/src/overlay-tools/__tests/IndexedDBReputationStorage.test.ts index 72686c43f..a043c607d 100644 --- a/packages/sdk/src/overlay-tools/__tests/IndexedDBReputationStorage.test.ts +++ b/packages/sdk/src/overlay-tools/__tests/IndexedDBReputationStorage.test.ts @@ -5,13 +5,12 @@ import { ReliableHostReputation } from '../ReliableHostReputation' const KEY = 'bsvsdk_overlay_host_reputation_v4' describe('transactional browser reputation', () => { - it('merges concurrent writers with independent stale read caches', async () => { + it('merges concurrent writers with independent prior reads', async () => { const factory = new IDBFactory() const a = indexedDBReputationStorage(factory) const b = indexedDBReputationStorage(factory) - await Promise.all([a.load!(KEY), b.load!(KEY)]) - expect(a.get(KEY)).toBeNull() - expect(b.get(KEY)).toBeNull() + expect(await a.get(KEY)).toBeNull() + expect(await b.get(KEY)).toBeNull() const trackers = [new ReliableHostReputation(a), new ReliableHostReputation(b)] await Promise.all( Array.from({ length: 32 }, (_, i) => @@ -19,8 +18,7 @@ describe('transactional browser reputation', () => { ) ) const reloaded = indexedDBReputationStorage(factory) - await reloaded.load!(KEY) - const entries = JSON.parse(reloaded.get(KEY)!).entries + const entries = JSON.parse((await reloaded.get(KEY))!).entries expect(Object.keys(entries)).toHaveLength(32) expect(Object.values(entries).every((entry: any) => entry.penalty === 2)).toBe(true) }) @@ -31,13 +29,10 @@ describe('transactional browser reputation', () => { await Promise.all( Array.from({ length: 20 }, (_, i) => { const storage = stores[i % 2] - return storage.lock(KEY, async () => { - storage.set(KEY, String(Number(storage.get(KEY) ?? 0) + 1)) - }) + return storage.update(KEY, current => String(Number(current ?? 0) + 1)) }) ) - await stores[0].load!(KEY) - expect(stores[0].get(KEY)).toBe('20') + expect(await stores[0].get(KEY)).toBe('20') const tracker = new ReliableHostReputation(stores[0]) await tracker.record('mainnet', 'ls_service', 'https://one.example', 'invalid') const reloaded = new ReliableHostReputation(indexedDBReputationStorage(factory)) @@ -49,15 +44,13 @@ describe('transactional browser reputation', () => { it('aborts failed updates without changing committed data', async () => { const storage = indexedDBReputationStorage(new IDBFactory()) - await storage.lock(KEY, async () => storage.set(KEY, 'committed')) + await storage.update(KEY, () => 'committed') await expect( - storage.lock(KEY, async () => { - storage.set(KEY, 'uncommitted') + storage.update(KEY, () => { throw new Error('synthetic update failure') }) ).rejects.toThrow('Reputation transaction aborted') - await storage.load!(KEY) - expect(storage.get(KEY)).toBe('committed') + expect(await storage.get(KEY)).toBe('committed') }) it('rejects a malformed database without an uncaught event-handler error', async () => { @@ -71,7 +64,7 @@ describe('transactional browser reputation', () => { open.onerror = () => reject(open.error) }) const storage = indexedDBReputationStorage(factory) - await expect(storage.load!(KEY)).rejects.toBeDefined() + await expect(storage.get(KEY)).rejects.toBeDefined() await expect( new ReliableHostReputation(storage).record('mainnet', 'ls_test', 'https://one.example') ).resolves.toBeUndefined() diff --git a/packages/sdk/src/overlay-tools/__tests/LookupResolver.shared-reliability.test.ts b/packages/sdk/src/overlay-tools/__tests/LookupResolver.shared-reliability.test.ts index e51b9debc..1bed012ef 100644 --- a/packages/sdk/src/overlay-tools/__tests/LookupResolver.shared-reliability.test.ts +++ b/packages/sdk/src/overlay-tools/__tests/LookupResolver.shared-reliability.test.ts @@ -25,11 +25,10 @@ function storageFixture(): { storage: ReliableReputationStorage; data: Map data.get(key), - set: (key, value) => { - data.set(key, value) - }, - lock: async (_name, action) => await action() + get: async key => data.get(key), + update: async (key, transform) => { + data.set(key, transform(data.get(key))) + } } } } @@ -172,7 +171,7 @@ describe('standard package resolver reliability for every overlay service', () = it('does not let a blocked reputation database hold up host recovery', async () => { const { storage } = storageFixture() - storage.load = () => new Promise(() => {}) + storage.get = () => new Promise(() => {}) const lookup = jest.fn(async () => answer) const resolver = new LookupResolver({ facilitator: { lookup }, diff --git a/packages/sdk/src/overlay-tools/__tests/ReliableHostReputation.test.ts b/packages/sdk/src/overlay-tools/__tests/ReliableHostReputation.test.ts index a6c825a9b..b70b32c4f 100644 --- a/packages/sdk/src/overlay-tools/__tests/ReliableHostReputation.test.ts +++ b/packages/sdk/src/overlay-tools/__tests/ReliableHostReputation.test.ts @@ -7,17 +7,13 @@ function storage() { let pending = Promise.resolve() return { data, - get: (key: string) => data.get(key), - set: (key: string, value: string) => { - data.set(key, value) - }, - lock: async (_name: string, action: () => Promise): Promise => { - const result = pending.then(action) - pending = result.then( - () => {}, - () => {} - ) - return await result + get: async (key: string) => data.get(key), + update: (key: string, transform: (raw: string | null | undefined) => string): Promise => { + const result = pending.then(() => { + data.set(key, transform(data.get(key))) + }) + pending = result.catch(() => {}) + return result } } } @@ -29,13 +25,12 @@ describe('versioned advisory reputation', () => { afterEach(() => jest.useRealTimers()) it.each(['{', 'null', '[]', '{"version":3,"entries":{}}', '{"version":4,"entries":null}'])( 'fails open for corrupt/legacy schema %s', - raw => { + async raw => { const s = storage() s.data.set(KEY, raw) - expect(new ReliableHostReputation(s).rank('mainnet', 'ls_kvstore', [h1, h2])).toEqual([ - h1, - h2 - ]) + const tracker = new ReliableHostReputation(s) + await tracker.refresh() + expect(tracker.rank('mainnet', 'ls_kvstore', [h1, h2])).toEqual([h1, h2]) } ) it('does not import poisoned v1-v3 or mutate legacy browser records', async () => { From 556dd393c291314fcd601f6a0cebbe08dd50f5aa Mon Sep 17 00:00:00 2001 From: Brayden Langley Date: Fri, 4 Sep 2026 16:31:45 -0700 Subject: [PATCH 09/11] Mark the construction-only lookup timer readonly --- 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 4ffce2a02..accf119d2 100644 --- a/packages/sdk/src/overlay-tools/LookupResolver.ts +++ b/packages/sdk/src/overlay-tools/LookupResolver.ts @@ -522,7 +522,7 @@ class LookupQuerySession { private waiter: (() => void) | null = null private answered = false private graceTimer: ReturnType | null = null - private softTimer: ReturnType | null = null + private readonly softTimer: ReturnType | null = null private graceFired = false private emittedOnce = false From 631bdf3bfc7109a984cb3306994607246545bdbc Mon Sep 17 00:00:00 2001 From: Brayden Langley Date: Fri, 4 Sep 2026 16:50:29 -0700 Subject: [PATCH 10/11] Cover reliable KV public contracts and classify the browser fixture as a test --- .../browser/{ => tests}/lookup-recovery.mjs | 4 +- .../docs/globalkv-reliability-validation.md | 10 +- packages/sdk/package.json | 2 +- .../__tests/ReliableKVStore.contracts.test.ts | 303 ++++++++++++++++++ 4 files changed, 314 insertions(+), 5 deletions(-) rename packages/sdk/browser/{ => tests}/lookup-recovery.mjs (98%) create mode 100644 packages/sdk/src/kvstore/__tests/ReliableKVStore.contracts.test.ts diff --git a/packages/sdk/browser/lookup-recovery.mjs b/packages/sdk/browser/tests/lookup-recovery.mjs similarity index 98% rename from packages/sdk/browser/lookup-recovery.mjs rename to packages/sdk/browser/tests/lookup-recovery.mjs index a75b5a590..bee8f5cd4 100644 --- a/packages/sdk/browser/lookup-recovery.mjs +++ b/packages/sdk/browser/tests/lookup-recovery.mjs @@ -7,9 +7,9 @@ import { fileURLToPath } from 'node:url' import puppeteer from 'puppeteer-core' -import { createCommandRunner } from '../../../scripts/lib/command-runner.mjs' +import { createCommandRunner } from '../../../../scripts/lib/command-runner.mjs' -const packageDirectory = fileURLToPath(new URL('../', import.meta.url)) +const packageDirectory = fileURLToPath(new URL('../../', import.meta.url)) const run = createCommandRunner({ timeoutMs: 240_000, maxBufferBytes: 4 * 1024 * 1024 }) const legacyKey = 'bsvsdk_overlay_host_reputation_v3' const currentKey = 'bsvsdk_overlay_host_reputation_v4' diff --git a/packages/sdk/docs/globalkv-reliability-validation.md b/packages/sdk/docs/globalkv-reliability-validation.md index 4d84874b7..174401efe 100644 --- a/packages/sdk/docs/globalkv-reliability-validation.md +++ b/packages/sdk/docs/globalkv-reliability-validation.md @@ -19,7 +19,7 @@ processes. Source comparison starts at ts-stack `98734b07cf` (2026-09-04). | Outage becomes an apparently empty collection | Incomplete empty aggregates throw retryable `LookupUnavailableError`; detailed/progressive APIs retain completion evidence. | | Bad proof or stale ancestor wins by latency | KV tests validate BEEF, signatures, selectors and fixture-root SPV before reconciliation; incomparable tips produce conflict. | | Accepted transaction is not yet indexed | Optional KV write tests retain the same transaction identity, report unconfirmed status and reconcile without creating another transaction. | -| Persisted penalties recur across tabs/reloads | `browser/lookup-recovery.mjs` uses the exact packed UMD in Chromium, real IndexedDB transactions, two concurrent tabs, four services and five outage/recovery cycles. | +| Persisted penalties recur across tabs/reloads | `browser/tests/lookup-recovery.mjs` uses the exact packed UMD in Chromium, real IndexedDB transactions, two concurrent tabs, four services and five outage/recovery cycles. | The legacy cooldown behavior can be reproduced at `ff36b55`; the standard-export recovery assertions are in `LookupResolver.shared-reliability.test.ts`. Services @@ -57,12 +57,18 @@ uses a disposable browser profile, and removes its package extraction afterwards No browser account, production endpoint or existing storage is used. Ten consecutive runs of the transactional fixture passed (200 reloads total). -The local SDK coverage suite passed 165 suites / 6,004 tests and one snapshot. +The local SDK coverage suite passed 166 suites / 6,027 tests and one snapshot. Packed ESM/CJS exports, strict declarations, source maps, publint, Vite, esbuild and UMD contracts passed. The PR records exact-head coverage and platform sizes; all original coverage and bundle budgets remain in force. Historical measurements are not a substitute for the current hosted merge gate. +Public adapter contract tests cover return shapes, incomplete-read errors, pending +write reconciliation, removal confirmation and retained-state transitions. The +repository patch-coverage calculation passes at 91.92% (1,229/1,337 changed +line/branch points) against the unchanged 90% requirement. The standalone browser +fixture lives under `browser/tests/` and remains part of `test:browser`. + The existing property profile passed three suites / six tests. Conformance parsed 76 files / 6,690 vectors without errors; no new portable protocol is introduced. Four targeted fault mutations were independently detected by the diff --git a/packages/sdk/package.json b/packages/sdk/package.json index c7640641b..213f28a38 100644 --- a/packages/sdk/package.json +++ b/packages/sdk/package.json @@ -758,7 +758,7 @@ "lint:ci": "pnpm lint", "lint": "oxlint mod.ts src benchmarks browser jest.config.js rspack.config.js --deny-warnings", "pack:check": "pnpm build && node ../../scripts/check-package-artifact.mjs . --exports PrivateKey,PublicKey,Transaction,Script,WalletClient,ProtoWallet,AuthFetch,IdentityClient,LookupResolver,RemittanceManager --esm-only-entrypoints ./umd", - "test:browser": "pnpm build && node ../../scripts/check-browser-package.mjs . && node browser/lookup-recovery.mjs", + "test:browser": "pnpm build && node ../../scripts/check-browser-package.mjs . && node browser/tests/lookup-recovery.mjs", "typecheck": "tsc --build --pretty false", "build": "pnpm build:ts && pnpm build:umd", "build:ts": "tsc -b && tsconfig-to-dual-package tsconfig.cjs.json", diff --git a/packages/sdk/src/kvstore/__tests/ReliableKVStore.contracts.test.ts b/packages/sdk/src/kvstore/__tests/ReliableKVStore.contracts.test.ts new file mode 100644 index 000000000..41a4cc5fd --- /dev/null +++ b/packages/sdk/src/kvstore/__tests/ReliableKVStore.contracts.test.ts @@ -0,0 +1,303 @@ +import BaseKVStore from '../GlobalKVStore' +import { + GlobalKVStore, + LookupResolver, + KVStoreReadSession, + KVStoreReadState, + KVStoreUnavailableError, + KVStoreWriteError, + validateKVAnswer, + confirmKVWrite, + type KVStoreReadResult +} from '../reliable' +import { ReliableTopicBroadcaster } from '../../overlay-tools/ReliableTopicBroadcaster' +import Transaction from '../../transaction/Transaction' +import { LockingScript } from '../../script/index' +import { author, chainTracker, fixture, protocol } from './fixtures/reliableKV' + +const host = 'https://contracts.example' +const evidence = { completedHosts: 1, failedHosts: 0, discoveryComplete: true, durationMs: 1 } +const absent: KVStoreReadResult = { kind: 'absent', authority: 'configured-hosts', evidence } +const unavailable: KVStoreReadResult = { kind: 'unavailable', retryable: true, evidence } +const conflict: KVStoreReadResult = { kind: 'conflict', retryable: true, evidence } + +describe('reliable KV public contracts', () => { + let f: Awaited> + let data: Extract + beforeAll(async () => { + f = await fixture('contract value') + const values = await validateKVAnswer( + { type: 'output-list', outputs: [f.output] }, + f.query, + chainTracker, + new AbortController().signal + ) + data = { + kind: 'data', + entries: values.map(v => v.entry), + completeness: 'complete', + freshness: 'observed', + evidence + } + }) + afterEach(() => { + jest.restoreAllMocks() + jest.useRealTimers() + }) + function store() { + return new GlobalKVStore({ + wallet: author as any, + protocolID: protocol, + lookupResolver: new LookupResolver({ + hostOverrides: { ls_kvstore: [host] }, + facilitator: { lookup: async () => ({ type: 'output-list', outputs: [f.output] }) } + }), + reliability: { chainTracker, authoritativeHosts: [host] } + }) + } + + it('preserves get return shapes and only includes tokens when requested', async () => { + const client = store() + expect(await client.get(f.query)).toMatchObject({ value: 'contract value' }) + expect(await client.get(f.query)).not.toHaveProperty('token') + expect(await client.get(f.query, { includeToken: true })).toMatchObject({ + token: { txid: f.tx.id('hex') } + }) + expect(await client.get({ protocolID: protocol })).toEqual([ + expect.objectContaining({ value: 'contract value' }) + ]) + jest.spyOn(client, 'getResult').mockResolvedValue(absent) + expect(await client.get(f.query)).toBeUndefined() + expect(await client.get({ protocolID: protocol })).toEqual([]) + }) + + it.each([unavailable, conflict, { kind: 'data', completeness: 'partial' }])( + 'get rejects incomplete or conflicting evidence: %j', + async outcome => { + const client = store() + jest + .spyOn(client, 'getResult') + .mockResolvedValue({ ...data, ...outcome } as KVStoreReadResult) + await expect(client.get(f.query)).rejects.toBeInstanceOf(KVStoreUnavailableError) + } + ) + + it('requires selectors and a separate policy for verified history', async () => { + const client = store() + await expect(client.getResult({})).rejects.toThrow('selector') + await expect(client.getResult({ tags: [] })).rejects.toThrow('selector') + await expect(client.getResult(f.query, { history: true })).rejects.toThrow('history validation') + }) + + it('applies the configured service and an explicit service override', async () => { + const lookup = jest.fn(async () => ({ type: 'output-list' as const, outputs: [] })) + const client = new GlobalKVStore({ + serviceName: 'ls_configured', + lookupResolver: new LookupResolver({ + hostOverrides: { ls_configured: [host], ls_override: [host] }, + facilitator: { lookup } + }), + reliability: { chainTracker, authoritativeHosts: [host] } + }) + expect((await client.getResult({ tags: ['selected'] })).kind).toBe('absent') + expect((await client.getResult(f.query, { serviceName: 'ls_override' })).kind).toBe('absent') + expect(lookup.mock.calls.map(call => (call as any)[1].service)).toEqual([ + 'ls_configured', + 'ls_override' + ]) + }) + + it('builds a default resolver without requiring a custom facilitator', () => { + const client = new GlobalKVStore({ + networkPreset: 'local', + hostOverrides: { ls_kvstore: [host] }, + reliability: { chainTracker } + }) + expect(client).toBeInstanceOf(BaseKVStore) + expect( + () => + new GlobalKVStore({ + reliability: { chainTracker: { isValidRootForHeight: async () => true } } + } as any) + ).toThrow('chain tracker') + }) + + it('confirms set and remove through the same read contract', async () => { + const client = store() + const set = jest.spyOn(BaseKVStore.prototype, 'set').mockResolvedValue(`${f.tx.id('hex')}.0`) + const remove = jest.spyOn(BaseKVStore.prototype, 'remove').mockResolvedValue(f.tx.id('hex')) + const read = jest.spyOn(client, 'getResult').mockResolvedValue(data) + expect(await client.set('confirmed-set', 'value', { protocolID: protocol })).toBe( + `${f.tx.id('hex')}.0` + ) + expect(set).toHaveBeenCalledTimes(1) + expect(read).toHaveBeenLastCalledWith( + expect.objectContaining({ key: 'confirmed-set', protocolID: protocol }), + {}, + expect.any(AbortSignal) + ) + read.mockResolvedValue(absent) + expect(await client.remove('confirmed-remove')).toBe(f.tx.id('hex')) + expect(remove).toHaveBeenCalledTimes(1) + expect(await client.reconcilePendingWrite('confirmed-remove')).toBe(true) + }) + + it.each(['set', 'remove'] as const)( + 'retains an unconfirmed %s and reconciles without creating another transaction', + async operation => { + jest.useFakeTimers() + const client = store() + const txid = f.tx.id('hex') + const create = jest + .spyOn(BaseKVStore.prototype, operation) + .mockResolvedValue(operation === 'set' ? `${txid}.0` : txid) + const read = jest.spyOn(client, 'getResult').mockResolvedValue(unavailable) + const key = `unconfirmed-${operation}` + const write = () => + operation === 'set' + ? client.set(key, 'value') + : client.remove(key, [], { protocolID: protocol }) + const rejection = expect(write()).rejects.toMatchObject({ outcome: 'unconfirmed', txid }) + await jest.advanceTimersByTimeAsync(5100) + await rejection + await expect(write()).rejects.toBeInstanceOf(KVStoreWriteError) + expect(create).toHaveBeenCalledTimes(1) + read.mockResolvedValue(operation === 'set' ? data : absent) + expect(await client.reconcilePendingWrite(key)).toBe(true) + expect(create).toHaveBeenCalledTimes(1) + } + ) + + it('does not turn a wallet error into a pending overlay transaction', async () => { + const client = store() + const failure = new Error('synthetic signing rejection') + jest.spyOn(BaseKVStore.prototype, 'set').mockRejectedValue(failure) + await expect(client.set('wallet-error', 'value')).rejects.toBe(failure) + expect(await client.reconcilePendingWrite('wallet-error')).toBe(true) + }) + + it.each(['rejected', 'unconfirmed'] as const)( + 'classifies broadcaster %s and preserves the transaction identity', + async outcome => { + const broadcast = jest.spyOn(ReliableTopicBroadcaster.prototype, 'broadcast') + if (outcome === 'rejected') + broadcast.mockResolvedValue({ + status: 'error', + code: 'ERR_REJECTED', + description: 'synthetic rejection' + }) + else broadcast.mockRejectedValue(new Error('synthetic disconnect')) + const client = store() + await expect((client as any).submitToOverlay(f.tx)).rejects.toMatchObject({ + outcome, + txid: f.tx.id('hex') + }) + } + ) + + it('accepts a submitted removal transaction without a replacement token', async () => { + jest + .spyOn(ReliableTopicBroadcaster.prototype, 'broadcast') + .mockResolvedValue({ status: 'success', txid: f.tx.id('hex'), message: 'synthetic' }) + const client = store() + const removal = new Transaction( + 1, + [], + [{ lockingScript: LockingScript.fromHex('51'), satoshis: 1 }], + 0 + ) + const read = jest.spyOn(client, 'getResult') + await expect((client as any).submitToOverlay(removal)).resolves.toMatchObject({ + status: 'success' + }) + expect(read).not.toHaveBeenCalled() + }) + + it('keeps prior records during partial refresh and clears them after authoritative absence', () => { + const state = new KVStoreReadState() + state.apply(data) + const partial = { + ...data, + completeness: 'partial' as const, + entries: [{ ...data.entries[0], key: 'second', token: undefined }] + } + expect(state.apply(partial)).toMatchObject({ + kind: 'stale', + entries: [data.entries[0], partial.entries[0]] + }) + expect(state.apply(absent)).toEqual(absent) + expect(state.apply(unavailable)).toEqual(unavailable) + }) + + it('rejects incomparable refreshes and corrupted cached proofs without replacing known data', async () => { + const competitor = await fixture('independent tip') + const values = await validateKVAnswer( + { type: 'output-list', outputs: [competitor.output] }, + competitor.query, + chainTracker, + new AbortController().signal + ) + const competing = { ...data, entries: values.map(v => v.entry) } + const state = new KVStoreReadState() + state.apply(data) + expect(state.apply(competing).kind).toBe('conflict') + const corrupt = { + ...data, + entries: [ + { + ...data.entries[0], + token: { ...data.entries[0].token!, beef: { toBinary: () => [1, 2] } as any } + } + ] + } + state.apply(corrupt) + expect(state.apply(competing).kind).toBe('conflict') + expect(state.apply(unavailable)).toMatchObject({ kind: 'stale', entries: corrupt.entries }) + }) + + it('a complete successor refresh advances retained state', async () => { + const successor = await fixture('successor', f.tx) + const values = await validateKVAnswer( + { type: 'output-list', outputs: [successor.output] }, + successor.query, + chainTracker, + new AbortController().signal + ) + const updated = { ...data, entries: values.map(v => v.entry) } + const state = new KVStoreReadState() + state.apply(data) + expect(state.apply(updated)).toEqual(updated) + }) + + it.each([0, 99, Infinity, NaN])('rejects an unsafe UI retry interval %s', delay => { + expect( + () => + new KVStoreReadSession( + async () => absent, + () => {}, + delay + ) + ).toThrow(RangeError) + }) + + it('stopping a session is permanent and observer failures do not interrupt recovery', async () => { + const read = jest.fn(async () => absent) + const session = new KVStoreReadSession(read, () => { + throw new Error('synthetic observer error') + }) + await expect(session.refresh()).resolves.toBeUndefined() + session.stop() + await session.refresh() + expect(read).toHaveBeenCalledTimes(1) + }) + + it('stops write confirmation immediately on conflict or a failed read', async () => { + await expect(confirmKVWrite(async () => conflict, 'tx.0')).resolves.toBe(false) + await expect( + confirmKVWrite(async () => { + throw new Error('offline') + }, 'tx.0') + ).resolves.toBe(false) + await expect(confirmKVWrite(async () => absent, undefined)).resolves.toBe(true) + }) +}) From 1cd0182f7b941613507bb5855d3fee028039cd4c Mon Sep 17 00:00:00 2001 From: Brayden Langley Date: Fri, 4 Sep 2026 17:11:34 -0700 Subject: [PATCH 11/11] Exercise malformed reputation and KV validation boundaries --- .../docs/globalkv-reliability-validation.md | 10 +++- .../__tests/ReliableKVStore.contracts.test.ts | 55 +++++++++++++++++++ .../__tests/ReliableHostReputation.test.ts | 35 ++++++++++++ 3 files changed, 97 insertions(+), 3 deletions(-) diff --git a/packages/sdk/docs/globalkv-reliability-validation.md b/packages/sdk/docs/globalkv-reliability-validation.md index 174401efe..e384f3402 100644 --- a/packages/sdk/docs/globalkv-reliability-validation.md +++ b/packages/sdk/docs/globalkv-reliability-validation.md @@ -57,7 +57,7 @@ uses a disposable browser profile, and removes its package extraction afterwards No browser account, production endpoint or existing storage is used. Ten consecutive runs of the transactional fixture passed (200 reloads total). -The local SDK coverage suite passed 166 suites / 6,027 tests and one snapshot. +The local SDK coverage suite passed 166 suites / 6,051 tests and one snapshot. Packed ESM/CJS exports, strict declarations, source maps, publint, Vite, esbuild and UMD contracts passed. The PR records exact-head coverage and platform sizes; all original coverage and bundle budgets remain in force. Historical measurements @@ -65,8 +65,12 @@ are not a substitute for the current hosted merge gate. Public adapter contract tests cover return shapes, incomplete-read errors, pending write reconciliation, removal confirmation and retained-state transitions. The -repository patch-coverage calculation passes at 91.92% (1,229/1,337 changed -line/branch points) against the unchanged 90% requirement. The standalone browser +repository patch-coverage calculation passes at 93.64% (1,252/1,337 changed +line/branch points) against the unchanged 90% requirement. Fully covered changed +lines, counting a partially covered branch as incomplete, measure 91.43% +(747/817), matching the separate Codecov patch calculation. Negative cases cover +corrupt reputation, signed outputs outside the query and malformed wire values. +The standalone browser fixture lives under `browser/tests/` and remains part of `test:browser`. The existing property profile passed three suites / six tests. Conformance diff --git a/packages/sdk/src/kvstore/__tests/ReliableKVStore.contracts.test.ts b/packages/sdk/src/kvstore/__tests/ReliableKVStore.contracts.test.ts index 41a4cc5fd..33c96c977 100644 --- a/packages/sdk/src/kvstore/__tests/ReliableKVStore.contracts.test.ts +++ b/packages/sdk/src/kvstore/__tests/ReliableKVStore.contracts.test.ts @@ -89,6 +89,61 @@ describe('reliable KV public contracts', () => { await expect(client.getResult(f.query, { history: true })).rejects.toThrow('history validation') }) + it.each([ + { controller: '02' + '00'.repeat(32) }, + { protocolID: [1, 'another protocol'] }, + { tags: ['missing'] }, + { tags: ['missing'], tagQueryMode: 'any' } + ])('rejects a valid signed output outside selector %j', async query => { + await expect( + validateKVAnswer( + { type: 'output-list', outputs: [f.output] }, + query as any, + chainTracker, + new AbortController().signal + ) + ).rejects.toMatchObject({ reason: 'invalid' }) + }) + + it.each([ + { beef: [], outputIndex: 0 }, + { beef: [256], outputIndex: 0 }, + { beef: [-1], outputIndex: 0 }, + { beef: [0.5], outputIndex: 0 }, + { beef: [0], outputIndex: -1 }, + { beef: [0], outputIndex: 0.5 } + ])('rejects malformed wire values before transaction parsing: %j', async output => { + await expect( + validateKVAnswer( + { type: 'output-list', outputs: [output] }, + f.query, + chainTracker, + new AbortController().signal + ) + ).rejects.toMatchObject({ reason: 'malformed' }) + }) + + it('bounds output counts and honors cancellation before validating a proof', async () => { + await expect( + validateKVAnswer( + { type: 'output-list', outputs: Array.from({ length: 257 }, () => f.output) }, + f.query, + chainTracker, + new AbortController().signal + ) + ).rejects.toMatchObject({ reason: 'malformed' }) + const controller = new AbortController() + controller.abort() + await expect( + validateKVAnswer( + { type: 'output-list', outputs: [f.output] }, + f.query, + chainTracker, + controller.signal + ) + ).rejects.toThrow('Lookup aborted') + }) + it('applies the configured service and an explicit service override', async () => { const lookup = jest.fn(async () => ({ type: 'output-list' as const, outputs: [] })) const client = new GlobalKVStore({ diff --git a/packages/sdk/src/overlay-tools/__tests/ReliableHostReputation.test.ts b/packages/sdk/src/overlay-tools/__tests/ReliableHostReputation.test.ts index b70b32c4f..a97a4c04a 100644 --- a/packages/sdk/src/overlay-tools/__tests/ReliableHostReputation.test.ts +++ b/packages/sdk/src/overlay-tools/__tests/ReliableHostReputation.test.ts @@ -92,4 +92,39 @@ describe('versioned advisory reputation', () => { } expect(Object.keys(JSON.parse(s.data.get(KEY)!).entries)).toHaveLength(256) }) + + it.each([ + ['invalid JSON scope', '{', {}], + ['non-array scope', '{}', {}], + ['wrong scope arity', '["mainnet"]', {}], + ['non-string service', '["mainnet",42,"https://one.example"]', {}], + ['oversized scope', 'x'.repeat(2049), {}], + ['missing record', JSON.stringify(['mainnet', 'ls_kvstore', h1]), null], + ['non-object record', JSON.stringify(['mainnet', 'ls_kvstore', h1]), 'invalid'], + ['non-finite time', JSON.stringify(['mainnet', 'ls_kvstore', h1]), { updatedAt: null }], + ['negative penalty', JSON.stringify(['mainnet', 'ls_kvstore', h1]), { penalty: -1 }], + ['excessive penalty', JSON.stringify(['mainnet', 'ls_kvstore', h1]), { penalty: 65 }], + ['negative cooldown', JSON.stringify(['mainnet', 'ls_kvstore', h1]), { cooldownUntil: -1 }], + [ + 'excessive cooldown', + JSON.stringify(['mainnet', 'ls_kvstore', h1]), + { cooldownUntil: 1030001 } + ], + ['unknown reason', JSON.stringify(['mainnet', 'ls_kvstore', h1]), { reason: 'unrecognized' }] + ])('discards persisted %s without blocking hosts', async (_label, key, changes) => { + const s = storage() + const value = + changes === null || typeof changes !== 'object' + ? changes + : { updatedAt: 1000000, cooldownUntil: 1001000, penalty: 16, reason: 'invalid', ...changes } + s.data.set(KEY, JSON.stringify({ version: 4, entries: { [key as string]: value } })) + const tracker = new ReliableHostReputation(s) + await tracker.refresh() + expect(tracker.rank('mainnet', 'ls_kvstore', [h1, h2])).toEqual([h1, h2]) + expect(tracker.snapshot('mainnet', 'ls_kvstore', h1)).toBeUndefined() + await tracker.record('mainnet', 'ls_kvstore', h2) + expect(Object.keys(JSON.parse(s.data.get(KEY)!).entries)).toEqual([ + JSON.stringify(['mainnet', 'ls_kvstore', h2]) + ]) + }) })