diff --git a/.github/workflows/tests-rs-wasm.yml b/.github/workflows/tests-rs-wasm.yml new file mode 100644 index 00000000000..6a2e18bc8df --- /dev/null +++ b/.github/workflows/tests-rs-wasm.yml @@ -0,0 +1,56 @@ +on: + workflow_call: + +# The browser SDK's quorum-key code paths only compile for wasm32: the `fetch` +# proof transport, the browser timers, and `WasmVerifiedContext` going through +# them. The workspace job builds natively and never reaches them, so their +# `wasm-bindgen-test` tests run here, under Node.js. Relays are stubbed +# through the global `fetch`; no test leaves loopback. +jobs: + wasm-tests: + name: Rust wasm32 tests + runs-on: ubuntu-24.04 + timeout-minutes: 45 + env: + CARGO_TARGET_WASM32_UNKNOWN_UNKNOWN_RUNNER: wasm-bindgen-test-runner + steps: + - name: Check out repo + uses: actions/checkout@v4 + + - name: Setup Node.JS + uses: actions/setup-node@v4 + with: + node-version: "24.14.1" + + - name: Setup Rust + uses: ./.github/actions/rust + with: + target: wasm32-unknown-unknown + + - name: Setup sccache + uses: ./.github/actions/sccache + with: + bucket: ${{ vars.CACHE_S3_BUCKET }} + region: ${{ vars.CACHE_REGION }} + endpoint: ${{ vars.CACHE_S3_ENDPOINT }} + access_key_id: ${{ secrets.CACHE_KEY_ID }} + secret_access_key: ${{ secrets.CACHE_SECRET_KEY }} + + - name: Install Cargo binstall + uses: cargo-bins/cargo-binstall@v1.3.1 + + # Must match the `wasm-bindgen` version the crates pin. + - name: Install wasm-bindgen-cli + run: cargo binstall wasm-bindgen-cli@0.2.108 + + # `--lib --test '*'`: the library and integration tests only. The + # crate's examples are native-only, and `--tests` would also build + # `refresh_checkpoints`, whose tests run with the crate's. `--nocapture` + # keeps the logged proof-verification times in the job output. + - name: Test rs-sdk-trusted-context-provider on wasm32 + run: >- + cargo test --locked -p rs-sdk-trusted-context-provider + --target wasm32-unknown-unknown --lib --test '*' -- --nocapture + + - name: Test wasm-sdk on wasm32 + run: cargo test --locked -p wasm-sdk --target wasm32-unknown-unknown --lib diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 68ba026a2d7..e75283a29ce 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -139,6 +139,7 @@ jobs: rs-workflows: - .github/workflows/tests-rs-workspace.yml - .github/workflows/tests-rs-wallet.yml + - .github/workflows/tests-rs-wasm.yml - .github/workflows/tests.yml - .github/scripts/check-wallet-closure.py - .github/runner-requirements.json @@ -543,6 +544,21 @@ jobs: shielded-changed: true coverage: false + # The wasm32-only code of the browser SDK's quorum context providers. Runs + # alongside either Rust job above whenever a crate they build on changes. + rs-wasm-tests: + name: Rust wasm32 tests + needs: + - changes + if: >- + ${{ contains(fromJson(needs.changes.outputs.rs-packages || '[]'), 'rs-sdk-trusted-context-provider') + || contains(fromJson(needs.changes.outputs.rs-packages || '[]'), 'wasm-sdk') + || needs.changes.outputs.rs-workflows-changed == 'true' + || github.event_name == 'schedule' + || github.event_name == 'workflow_dispatch' }} + secrets: inherit + uses: ./.github/workflows/tests-rs-wasm.yml + # Fast path: only wallet crates changed, so run the scoped wallet suite # instead of the full workspace job above (the two are mutually exclusive # via `rs-scope`). diff --git a/Cargo.lock b/Cargo.lock index d02f0ebdb42..e44f0bbf374 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1892,6 +1892,7 @@ dependencies = [ "dapi-grpc", "dash-async", "dash-context-provider", + "dash-core-proof", "dash-network-seeds", "dash-platform-macros", "dash-platform-queries", @@ -8987,6 +8988,7 @@ dependencies = [ "bip39", "console_error_panic_hook", "dapi-grpc", + "dash-core-proof", "dash-sdk", "dpp", "dpp-json-convertible-derive", diff --git a/book/src/SUMMARY.md b/book/src/SUMMARY.md index f26dba5affe..4cfacf61a7a 100644 --- a/book/src/SUMMARY.md +++ b/book/src/SUMMARY.md @@ -133,7 +133,7 @@ - [Overview](evo-sdk/overview.md) - [Getting Started](evo-sdk/getting-started.md) -- [Trusted Mode and Proofs](evo-sdk/trusted-mode.md) +- [Trusted and Verified Mode](evo-sdk/trusted-mode.md) - [State Transitions](evo-sdk/state-transitions.md) - [Wallet Utilities](evo-sdk/wallet-utilities.md) - [DashPay Contact Requests](evo-sdk/dashpay-contact-requests.md) diff --git a/book/src/evo-sdk/trusted-mode.md b/book/src/evo-sdk/trusted-mode.md index 998d7517c60..3a7544ad157 100644 --- a/book/src/evo-sdk/trusted-mode.md +++ b/book/src/evo-sdk/trusted-mode.md @@ -1,4 +1,4 @@ -# Trusted Mode and Proof Verification +# Trusted Mode, Verified Mode and Proof Verification ## The problem @@ -8,7 +8,8 @@ platform state tree. To verify these proofs the SDK needs the **quorum public keys** for the active validator set. On a full node you can look up quorum keys directly from the Core chain. In a -browser or lightweight environment you cannot. Trusted mode solves this. +browser or lightweight environment you cannot. Trusted mode and verified mode +are the two ways the SDK gets them there. ## How trusted mode works @@ -31,17 +32,48 @@ The trust model: This is a pragmatic trade-off: you trust one endpoint for the validator set, but verify all actual data cryptographically. -## When to use trusted mode +## How verified mode works -| Scenario | Trusted mode? | Why | -|----------|:---:|-----| -| Browser app | Yes | No access to Core chain | -| Node.js script | Yes | Simplest setup | +On mainnet and testnet, `verified: true` removes that trust. The SDK proves +each quorum public key with a DIP 175 quorum proof, starting from a checkpoint +built into the SDK. The quorum service only relays the proofs: a relay that +lies can delay requests, but it cannot make the SDK accept a forged key. + +```typescript +const sdk = EvoSDK.testnetVerified(); +await sdk.connect(); // fetches no quorum keys up front +``` + +Nothing is fetched at `connect()`. A key is proven the first time the SDK +verifies a response signed by its quorum, so the first query of a session, and +the first after a quorum rotation, take a little longer. In the browser, proof verification yields to +the event loop between links, so the page stays responsive. + +Proofs grow with the age of the state they start from. A long-lived app should +save `sdk.verifiedContinuation()` (for example on page hide) under a key per +network and pass it back as `continuation` on the next start. If `connect()` +refuses a saved continuation, drop it and start from the built-in checkpoint. +The [Evo SDK README](https://github.com/dashpay/platform/blob/v5.1-dev/packages/js-evo-sdk/README.md#verified-quorum-keys) +shows the full pattern. + +Verified mode needs relays that serve proofs. The public mainnet quorum service +does not serve them yet, so on mainnet pass `quorumRelays` that do. Verified +mode is not available on local networks or devnets, and it requires proofs +(`proofs: false` is refused). + +## Which mode to use + +| Scenario | Mode | Why | +|----------|------|-----| +| Browser app on testnet | Verified | No access to the Core chain, and no endpoint to trust | +| Browser app on mainnet | Verified with your own `quorumRelays`, or trusted | The public mainnet service does not serve proofs yet | +| Node.js script | Verified or trusted | Trusted is the simplest setup | | Server with Core RPC | Optional | Can fetch quorum keys from your own node | -| Local Docker setup | Yes | Use `EvoSDK.localTrusted()` | +| Local Docker setup or devnet | Trusted | Use `EvoSDK.localTrusted()`, or `EvoSDK.devnetTrusted(name)` for a devnet; verified mode needs mainnet or testnet | -If you do not use trusted mode, the SDK still works but cannot verify proofs. -Queries return data but you are trusting the responding DAPI node. +Neither mode is on by default. Without one, proved queries fail, because the +SDK has no quorum keys; only the `Unproved` variants work, and they trust the +responding DAPI node. ## Proofs in responses diff --git a/packages/dash-platform-balance-checker/src/main_trusted.rs b/packages/dash-platform-balance-checker/src/main_trusted.rs index e622555f168..7b98e691119 100644 --- a/packages/dash-platform-balance-checker/src/main_trusted.rs +++ b/packages/dash-platform-balance-checker/src/main_trusted.rs @@ -52,7 +52,7 @@ async fn main() -> Result<()> { .build()?; // Set our trusted context provider - sdk.set_context_provider(context_provider); + sdk.set_context_provider(context_provider)?; // Fetch the identity println!("Fetching identity with proof verification..."); diff --git a/packages/js-evo-sdk/README.md b/packages/js-evo-sdk/README.md index 1b189a03df8..3eb85e86b09 100644 --- a/packages/js-evo-sdk/README.md +++ b/packages/js-evo-sdk/README.md @@ -39,7 +39,7 @@ The package is ESM-only (`"type": "module"`). In CommonJS projects, use dynamic ## Usage -Trusted mode is required for all queries. It pre-fetches quorum public keys so the SDK can verify Platform proofs. +Queries need a source of quorum public keys so the SDK can verify Platform proofs: trusted mode pre-fetches them from the network's quorum service, and [verified mode](#verified-quorum-keys) proves them from a checkpoint. ```typescript import { EvoSDK } from '@dashevo/evo-sdk'; @@ -62,12 +62,16 @@ console.log('Current epoch:', epoch.index); | `addresses` | `string[]` | — | Seed masternode addresses. Required for non-trusted devnet; optional for other networks (replaces built-in defaults). | | `devnetName` | `string` | — | Short name of the devnet (e.g. `'paloma'`). Required when `network: 'devnet'` and `trusted: true` (used to derive the quorum URL); ignored otherwise — only valid when `network === 'devnet'`. | | `quorumUrl` | `string` | — | Override the trusted-context quorum base URL. Only meaningful when `trusted: true`. Useful for staging endpoints or devnets where the public DNS isn't deployed yet. | +| `verified` | `boolean` | `false` | Prove every Platform quorum key with a DIP 175 proof from a checkpoint instead of trusting the quorum service, which then only relays proofs. Mainnet and testnet only; exclusive with `trusted`. See [Verified quorum keys](#verified-quorum-keys). | +| `checkpoint` | `QuorumProofState` | built-in | Where proofs start from: a ChainLocked block you checked independently. Only valid with `verified: true`. | +| `continuation` | `QuorumProofContinuation` | — | Where a previous session got to, saved from `sdk.verifiedContinuation()`. Exclusive with `checkpoint`; only valid with `verified: true`. | +| `quorumRelays` | `string[]` | public quorum service | 1 to 8 relay base URLs serving `/proofs` (`https`, or `http` on loopback only). Only valid with `verified: true`. | | `proofs` | `boolean` | `true` | Setting to `false` disables proof requests where supported, but unproved mode is limited — several query paths (e.g. document fetches) force proofs regardless, and some query builders reject the unproved path. Mainly intended for mock/offline replay. | | `version` | `number` | — | Pins the platform protocol version. When unset, the SDK follows the network's protocol version. | | `logs` | `string` | — | Tracing/log filter for the underlying Wasm SDK. Accepts simple levels (`'info'`, `'debug'`, …) or a full `EnvFilter` string. | | `settings` | `{ connectTimeoutMs?, timeoutMs?, retries?, banFailedAddress? }` | — | DAPI client transport settings. | -Preset factories are available as convenience: `EvoSDK.testnet()`, `EvoSDK.mainnet()`, `EvoSDK.testnetTrusted()`, `EvoSDK.mainnetTrusted()`, `EvoSDK.local()`, `EvoSDK.localTrusted()` (the last two target a dashmate local node), and the devnet factories `EvoSDK.devnet(name, options)` / `EvoSDK.devnetTrusted(name, options)`. +Preset factories are available as convenience: `EvoSDK.testnet()`, `EvoSDK.mainnet()`, `EvoSDK.testnetTrusted()`, `EvoSDK.mainnetTrusted()`, `EvoSDK.testnetVerified()`, `EvoSDK.mainnetVerified()`, `EvoSDK.local()`, `EvoSDK.localTrusted()` (the last two target a dashmate local node), and the devnet factories `EvoSDK.devnet(name, options)` / `EvoSDK.devnetTrusted(name, options)`. ```typescript // Trusted devnet — quorum URL auto-derived from the devnet name. @@ -81,12 +85,43 @@ const local = EvoSDK.devnet('paloma', { await local.connect(); ``` +### Verified quorum keys + +`trusted: true` takes quorum public keys from the network's quorum service on trust. On mainnet and testnet, `verified: true` proves each key instead, from a checkpoint built into the SDK, with Dash Core's DIP 175 quorum proofs; the quorum service only relays them, and a relay that lies can delay requests but cannot make the SDK accept a forged key. Nothing is prefetched at `connect()`: a key is proven the first time a response signed by its quorum is verified. Neither `trusted` nor `verified` is on by default. + +```typescript +// One key per network: a continuation only resumes an SDK of the network that saved it. +const key = 'dash-continuation.testnet'; +const options = {}; // or { quorumRelays: ['https://relay.example'] } +const saved = localStorage.getItem(key); +let sdk: EvoSDK; +try { + sdk = EvoSDK.testnetVerified({ ...options, continuation: saved ? JSON.parse(saved) : undefined }); + await sdk.connect(); +} catch (error) { + if (!saved) throw error; + // Start again from the built-in checkpoint. If that works, the saved continuation was + // the problem, and it would fail every later load too: drop it. + sdk = EvoSDK.testnetVerified(options); + await sdk.connect(); + localStorage.removeItem(key); +} + +// ... later: proofs grow with the age of the state they start from, so save where they got to. +const continuation = sdk.verifiedContinuation(); +if (continuation) localStorage.setItem(key, JSON.stringify(continuation)); +``` + +A continuation is `{ anchor, states }`: an anchor, the newest verified state more than one DKG interval (24 blocks) below the lowest Core height of a response the SDK verified, which a Platform quorum signed (the session's own starting point if it verified none, or if no kept state is that far below it, as with a recent custom checkpoint; a response that fails verification never counts), so the next session can still prove the heights Platform reports, which only rise, including a key verified at a height from evidence up to that interval below it, for a quorum that has just retired; and the newer verified states, so proofs stay short. Save the whole continuation, not just the newest state: Platform's heights are often below it, and no key at or below a lone starting state can be proven. Like the checkpoint, a continuation is trust configuration; only pass back one the SDK saved. A continuation does not record the checkpoint it started from, so it stays trusted after an SDK release replaces that checkpoint; if a release's notes say to, drop saved continuations. + +`EvoSDK.mainnetVerified()` is the mainnet counterpart. The public mainnet quorum service does not serve proofs yet, so pass `quorumRelays` that do. `verified` throws on `local` and `devnet`, together with `trusted: true`, or with `quorumUrl`; `checkpoint`, `continuation` and `quorumRelays` throw without it, `checkpoint` and `continuation` together throw, and `connect()` rejects `verified` with `proofs: false`. + Static helpers are also exported: - `await EvoSDK.setLogLevel(filter)` — configure the underlying Wasm SDK's tracing globally. - `await EvoSDK.getLatestVersionNumber()` — return the latest Platform protocol version supported by the bundled Wasm SDK. - `sdk.version()` — the protocol version this SDK currently uses. Unpinned SDKs seed at a per-network floor (13 on mainnet, testnet and local; 14 on devnets) and ratchet upward from verified response metadata. On mainnet and testnet the Wasm SDK persists the learned version in `localStorage` under `dash-sdk.protocol-version.` and seeds the next SDK with it, so the first proved request of a later page load already runs at the network's version. Passing `version` pins the SDK and disables both the ratchet and the persistence. -- Data contracts fetched through the SDK are cached in the trusted context and, on mainnet and testnet, persisted in `localStorage` under `dash-sdk.contracts.`. The next SDK seeds its cache from that store, so the first proved document query of a later page load needs no contract round trip. A document stamped with a newer `$contractVersion` than the cached contract drops the entry and refetches the contract; `removeCachedContract` clears both the cache and the stored copy. +- Data contracts fetched through the SDK are cached in the trusted or verified context and, on mainnet and testnet, persisted in `localStorage` under `dash-sdk.contracts.` (`dash-sdk.contracts..verified` for a verified SDK, so it never starts from contracts proved with trusted keys). The next SDK seeds its cache from that store, so the first proved document query of a later page load needs no contract round trip. A document stamped with a newer `$contractVersion` than the cached contract drops the entry and refetches the contract; `removeCachedContract` clears both the cache and the stored copy. - `await EvoSDK.maxRankedLimit()` — the hard ceiling on a [ranked / having-range](#ranked-queries) `limit`. - `await EvoSDK.rankedAverageScale()` — the fixed-point divisor for the `avg` axis of a ranked / having-range result. - `await EvoSDK.maxPrefixInBranches()` — the hard ceiling on the element count of a branching `in` [prefix pin](#ranked-queries). diff --git a/packages/js-evo-sdk/src/sdk.ts b/packages/js-evo-sdk/src/sdk.ts index 8389374cdb5..dd373b9c4cf 100644 --- a/packages/js-evo-sdk/src/sdk.ts +++ b/packages/js-evo-sdk/src/sdk.ts @@ -56,11 +56,34 @@ export interface EvoSDKOptions extends ConnectionOptions { // Only consulted when trusted === true. Useful for pointing at a staging, // self-hosted, or not-yet-deployed quorums endpoint. quorumUrl?: string; + // Prove every Platform quorum key with a DIP 175 proof from a checkpoint + // instead of taking it from the quorum service on trust; the service then + // only relays proofs. Mainnet and testnet only, and exclusive with + // `trusted`. Nothing is fetched at connect time: each key is proven the + // first time a response signed by its quorum is verified. + verified?: boolean; + // Where proofs start from: a ChainLocked block you checked independently, + // in Dash Core's `getquorumproofchain` `target` form. Defaults to the + // checkpoint built into the SDK. To resume where a previous session got to, + // pass `continuation` instead. Only valid when verified === true. + checkpoint?: wasm.QuorumProofState; + // Where a previous session got to, as `verifiedContinuation()` returned it, + // so proofs stay short. Like a checkpoint it is trust configuration: only + // pass back what an SDK you trust saved. Exclusive with `checkpoint`; only + // valid when verified === true. + continuation?: wasm.QuorumProofContinuation; + // 1 to 8 relay base URLs whose `/proofs` endpoint serves the proofs; each + // must be https (plain http only on a loopback host). Defaults to the + // network's public quorum service. Only valid when verified === true. + quorumRelays?: string[]; } +type VerifiedOptions = Pick; + export class EvoSDK { private wasmSdk?: wasm.WasmSdk; - private options: Required> & ConnectionOptions & { addresses?: string[]; devnetName?: string; quorumUrl?: string }; + private verifiedContext?: wasm.WasmVerifiedContext; + private options: Required> & ConnectionOptions & VerifiedOptions & { addresses?: string[]; devnetName?: string; quorumUrl?: string }; public addresses!: AddressesFacade; public documents!: DocumentsFacade; @@ -81,7 +104,32 @@ export class EvoSDK { public decisionProposals!: DecisionProposalsFacade; constructor(options: EvoSDKOptions = {}) { // Apply defaults while preserving any future connection options - const { network = 'testnet', trusted = false, addresses, devnetName, quorumUrl, ...connection } = options; + const { + network = 'testnet', + trusted = false, + verified = false, + checkpoint, + continuation, + quorumRelays, + addresses, + devnetName, + quorumUrl, + ...connection + } = options; + + if (verified) { + if (trusted) { + throw new Error('EvoSDK: trusted and verified are mutually exclusive; pick one source of quorum keys'); + } + if (network !== 'mainnet' && network !== 'testnet') { + throw new Error(`EvoSDK: verified quorum keys are only available on mainnet and testnet, not ${network}`); + } + if (checkpoint != null && continuation != null) { + throw new Error('EvoSDK: checkpoint and continuation are mutually exclusive; pass one place to start proofs from'); + } + } else if (checkpoint != null || continuation != null || quorumRelays != null) { + throw new Error('EvoSDK: checkpoint, continuation and quorumRelays are only meaningful when verified === true'); + } if (network === 'devnet') { const hasAddresses = !!(addresses && addresses.length > 0); @@ -101,7 +149,18 @@ export class EvoSDK { throw new Error("EvoSDK: quorumUrl is only meaningful when trusted === true"); } - this.options = { network, trusted, addresses, devnetName, quorumUrl, ...connection }; + this.options = { + network, + trusted, + verified, + checkpoint, + continuation, + quorumRelays, + addresses, + devnetName, + quorumUrl, + ...connection, + }; this.addresses = new AddressesFacade(this); this.documents = new DocumentsFacade(this); @@ -144,7 +203,10 @@ export class EvoSDK { } await initWasm(); - const { network, trusted, version, proofs, settings, logs, addresses, devnetName, quorumUrl } = this.options; + const { + network, trusted, verified, checkpoint, continuation, quorumRelays, version, proofs, settings, logs, + addresses, devnetName, quorumUrl, + } = this.options; // Prefetch trusted context only when trusted mode is requested. Explicit // addresses win over discovered ones in `withTrustedContext`, so the @@ -198,6 +260,28 @@ export class EvoSDK { builder = builder.withTrustedContext(context); } + // A verified context discovers no addresses, so the builder keeps the + // network's or the caller's. The constructor admits mainnet and testnet only. + let verifiedContext: wasm.WasmVerifiedContext | undefined; + if (verified) { + if (network === 'mainnet') { + if (continuation) { + verifiedContext = wasm.WasmVerifiedContext.mainnetFromContinuation(continuation, quorumRelays); + } else if (checkpoint) { + verifiedContext = wasm.WasmVerifiedContext.mainnetWithCheckpoint(checkpoint, quorumRelays); + } else { + verifiedContext = wasm.WasmVerifiedContext.mainnet(quorumRelays); + } + } else if (continuation) { + verifiedContext = wasm.WasmVerifiedContext.testnetFromContinuation(continuation, quorumRelays); + } else if (checkpoint) { + verifiedContext = wasm.WasmVerifiedContext.testnetWithCheckpoint(checkpoint, quorumRelays); + } else { + verifiedContext = wasm.WasmVerifiedContext.testnet(quorumRelays); + } + builder = builder.withVerifiedContext(verifiedContext); + } + if (version) { builder = builder.withVersion(version); } @@ -218,6 +302,28 @@ export class EvoSDK { } this.wasmSdk = builder.build(); + this.verifiedContext = verifiedContext; + } + + /** + * Where quorum proofs got to, for the next start to resume from; + * `undefined` unless this EvoSDK connected with `verified: true` (an SDK + * from `fromWasm` has none to report). Save it once queries have run + * (for example on page hide) and pass it back as `continuation`, so later + * proofs stay short. + * + * @example + * ```typescript + * // Save under one key per network: + * const continuation = sdk.verifiedContinuation(); + * if (continuation) localStorage.setItem('dash-continuation.testnet', JSON.stringify(continuation)); + * ``` + * On the next start pass it back as `continuation`. If `connect()` then + * throws but succeeds without it, drop the saved one: the README's + * "Verified quorum keys" section shows the pattern. + */ + verifiedContinuation(): wasm.QuorumProofContinuation | undefined { + return this.verifiedContext?.continuation(); } static fromWasm(wasmSdk: wasm.WasmSdk): EvoSDK { @@ -278,6 +384,10 @@ export class EvoSDK { static mainnetTrusted(options: ConnectionOptions = {}): EvoSDK { return new EvoSDK({ network: 'mainnet', trusted: true, ...options }); } static local(options: ConnectionOptions = {}): EvoSDK { return new EvoSDK({ network: 'local', ...options }); } static localTrusted(options: ConnectionOptions = {}): EvoSDK { return new EvoSDK({ network: 'local', trusted: true, ...options }); } + /** Testnet with quorum keys proven from a checkpoint; see `EvoSDKOptions.verified`. */ + static testnetVerified(options: ConnectionOptions & VerifiedOptions = {}): EvoSDK { return new EvoSDK({ network: 'testnet', verified: true, ...options }); } + /** Mainnet with quorum keys proven from a checkpoint; see `EvoSDKOptions.verified`. */ + static mainnetVerified(options: ConnectionOptions & VerifiedOptions = {}): EvoSDK { return new EvoSDK({ network: 'mainnet', verified: true, ...options }); } /** * Create an EvoSDK instance configured for a devnet, without trusted-context diff --git a/packages/js-evo-sdk/tests/unit/connect.spec.ts b/packages/js-evo-sdk/tests/unit/connect.spec.ts index 0a6d23b57cd..8ce77b6052d 100644 --- a/packages/js-evo-sdk/tests/unit/connect.spec.ts +++ b/packages/js-evo-sdk/tests/unit/connect.spec.ts @@ -10,11 +10,13 @@ describe('EvoSDK.connect()', () => { let context: wasmSDKPackage.WasmTrustedContext; let builder: { withTrustedContext: SinonStub; + withVerifiedContext: SinonStub; withSettings: SinonStub; build: SinonStub; }; let withAddressesStub: SinonStub; let testnetBuilderStub: SinonStub; + let mainnetBuilderStub: SinonStub; let prefetchDevnetStub: SinonStub; let prefetchDevnetWithUrlStub: SinonStub; let prefetchTestnetStub: SinonStub; @@ -24,16 +26,19 @@ describe('EvoSDK.connect()', () => { context = {} as wasmSDKPackage.WasmTrustedContext; builder = { withTrustedContext: this.sinon.stub(), + withVerifiedContext: this.sinon.stub(), withSettings: this.sinon.stub(), build: this.sinon.stub(), }; builder.withTrustedContext.returns(builder); + builder.withVerifiedContext.returns(builder); builder.withSettings.returns(builder); builder.build.returns({ version: () => 1 }); const fakeBuilder = builder as unknown as wasmSDKPackage.WasmSdkBuilder; withAddressesStub = this.sinon.stub(wasmSDKPackage.WasmSdkBuilder, 'withAddresses').returns(fakeBuilder); testnetBuilderStub = this.sinon.stub(wasmSDKPackage.WasmSdkBuilder, 'testnet').returns(fakeBuilder); + mainnetBuilderStub = this.sinon.stub(wasmSDKPackage.WasmSdkBuilder, 'mainnet').returns(fakeBuilder); prefetchDevnetStub = this.sinon.stub(wasmSDKPackage.WasmTrustedContext, 'prefetchDevnet').resolves(context); prefetchDevnetWithUrlStub = this.sinon.stub(wasmSDKPackage.WasmTrustedContext, 'prefetchDevnetWithUrl').resolves(context); prefetchTestnetStub = this.sinon.stub(wasmSDKPackage.WasmTrustedContext, 'prefetchTestnet').resolves(context); @@ -82,4 +87,70 @@ describe('EvoSDK.connect()', () => { expect(withAddressesStub).to.not.have.been.called(); expect(builder.withTrustedContext).to.be.calledOnceWithExactly(context); }); + + describe('with verified: true', () => { + const CHECKPOINT = { + network: 0, + height: 2300000, + block_hash: '00000000000000000000000000000000000000000000000000000000000000aa', + masternode_root: '00000000000000000000000000000000000000000000000000000000000000bb', + quorum_root: '00000000000000000000000000000000000000000000000000000000000000cc', + }; + let verifiedContext: wasmSDKPackage.WasmVerifiedContext; + + beforeEach(function setupVerified() { + verifiedContext = { + continuation: this.sinon.stub().returns({ anchor: CHECKPOINT, states: [] }), + } as unknown as wasmSDKPackage.WasmVerifiedContext; + }); + + it('should attach a verified context from the embedded checkpoint and prefetch nothing', async function test() { + const factory = this.sinon.stub(wasmSDKPackage.WasmVerifiedContext, 'testnet').returns(verifiedContext); + const sdk = EvoSDK.testnetVerified(); + + await sdk.connect(); + + expect(factory).to.be.calledOnceWithExactly(undefined); + expect(testnetBuilderStub).to.be.calledOnce(); + expect(builder.withVerifiedContext).to.be.calledOnceWithExactly(verifiedContext); + expect(builder.withTrustedContext).to.not.have.been.called(); + expect(prefetchTestnetStub).to.not.have.been.called(); + expect(sdk.verifiedContinuation()).to.deep.equal({ anchor: CHECKPOINT, states: [] }); + }); + + it('should pass a saved checkpoint and relays to the mainnet factory', async function test() { + const factory = this.sinon.stub(wasmSDKPackage.WasmVerifiedContext, 'mainnetWithCheckpoint') + .returns(verifiedContext); + const relays = ['https://relay.test']; + const sdk = EvoSDK.mainnetVerified({ checkpoint: CHECKPOINT, quorumRelays: relays }); + + await sdk.connect(); + + expect(factory).to.be.calledOnceWithExactly(CHECKPOINT, relays); + expect(mainnetBuilderStub).to.be.calledOnce(); + expect(builder.withVerifiedContext).to.be.calledOnceWithExactly(verifiedContext); + }); + + it('should resume from a saved continuation', async function test() { + const factory = this.sinon.stub(wasmSDKPackage.WasmVerifiedContext, 'testnetFromContinuation') + .returns(verifiedContext); + const continuation = { anchor: CHECKPOINT, states: [{ ...CHECKPOINT, height: 2300100 }] }; + const sdk = EvoSDK.testnetVerified({ continuation }); + + await sdk.connect(); + + expect(factory).to.be.calledOnceWithExactly(continuation, undefined); + expect(builder.withVerifiedContext).to.be.calledOnceWithExactly(verifiedContext); + }); + + it('should keep explicit addresses', async function test() { + this.sinon.stub(wasmSDKPackage.WasmVerifiedContext, 'testnet').returns(verifiedContext); + const sdk = new EvoSDK({ network: 'testnet', verified: true, addresses: [TEST_ADDRESS] }); + + await sdk.connect(); + + expect(withAddressesStub).to.be.calledOnceWithExactly([TEST_ADDRESS], 'testnet'); + expect(builder.withVerifiedContext).to.be.calledOnceWithExactly(verifiedContext); + }); + }); }); diff --git a/packages/js-evo-sdk/tests/unit/sdk.spec.ts b/packages/js-evo-sdk/tests/unit/sdk.spec.ts index accb46a7707..3b4798ab89e 100644 --- a/packages/js-evo-sdk/tests/unit/sdk.spec.ts +++ b/packages/js-evo-sdk/tests/unit/sdk.spec.ts @@ -302,6 +302,68 @@ describe('EvoSDK', () => { }); }); + describe('verified', () => { + const CHECKPOINT = { + network: 1, + height: 1574768, + block_hash: '0000006648a17e1ab1c4c5745d80fdb6d69bff32fd93ad4fc60baad624e1bb62', + masternode_root: 'd19783ddd57d2cdcf8cc2552a82859f062faab7c4b675b870762bd0848b5af8c', + quorum_root: '5e6becb125aed06017530637a1423d87a27b468bef6865405d619b20e8dcf2c5', + }; + + it('should keep the trusted provider as the default', () => { + const sdk = EvoSDK.testnet(); + expect(sdk.options.verified).to.be.false(); + expect(sdk.options.trusted).to.be.false(); + expect(sdk.verifiedContinuation()).to.be.undefined(); + }); + + it('should create verified testnet and mainnet instances', () => { + const testnet = EvoSDK.testnetVerified(); + expect(testnet.options.network).to.equal('testnet'); + expect(testnet.options.verified).to.be.true(); + expect(testnet.options.trusted).to.be.false(); + + const mainnet = EvoSDK.mainnetVerified({ checkpoint: CHECKPOINT, quorumRelays: ['https://relay.test'] }); + expect(mainnet.options.network).to.equal('mainnet'); + expect(mainnet.options.checkpoint).to.deep.equal(CHECKPOINT); + expect(mainnet.options.quorumRelays).to.deep.equal(['https://relay.test']); + }); + + it('should reject verified together with trusted', () => { + expect(() => new EvoSDK({ network: 'testnet', trusted: true, verified: true })) + .to.throw(/mutually exclusive/); + }); + + it('should reject verified on local and devnet', () => { + expect(() => new EvoSDK({ network: 'local', verified: true })).to.throw(/mainnet and testnet/); + expect(() => new EvoSDK({ + network: 'devnet', verified: true, addresses: [TEST_ADDRESS_1], + })).to.throw(/mainnet and testnet/); + }); + + it('should reject a checkpoint, continuation or relays without verified', () => { + expect(() => new EvoSDK({ network: 'testnet', checkpoint: CHECKPOINT })).to.throw(/verified/); + expect(() => new EvoSDK({ + network: 'testnet', continuation: { anchor: CHECKPOINT, states: [] }, + })).to.throw(/verified/); + expect(() => EvoSDK.testnetTrusted({ quorumRelays: ['https://relay.test'] } as never)) + .to.throw(/verified/); + }); + + it('should reject a checkpoint together with a continuation', () => { + expect(() => EvoSDK.testnetVerified({ + checkpoint: CHECKPOINT, + continuation: { anchor: CHECKPOINT, states: [] }, + })).to.throw(/mutually exclusive/); + }); + + it('should reject quorumUrl with verified', () => { + expect(() => new EvoSDK({ network: 'testnet', verified: true, quorumUrl: 'https://x' })) + .to.throw(/quorumUrl/); + }); + }); + describe('ranked query constants', () => { // Documented in the README as the way to discover the ceiling, so the // forwarding statics have to actually exist on EvoSDK. diff --git a/packages/js-evo-sdk/tests/unit/verified.spec.ts b/packages/js-evo-sdk/tests/unit/verified.spec.ts new file mode 100644 index 00000000000..6b5666f7aa6 --- /dev/null +++ b/packages/js-evo-sdk/tests/unit/verified.spec.ts @@ -0,0 +1,65 @@ +import init, * as wasmSDKPackage from '@dashevo/wasm-sdk'; +import { EvoSDK } from '../../dist/sdk.js'; + +// Builds a real SDK through the real wasm builder. Building dials nothing, and +// the verified context proves keys only when a proof needs one. +describe('EvoSDK with verified: true (real wasm builder)', () => { + before(async () => { + await init(); + }); + + it('should connect and continue from the embedded checkpoint before any proof', async () => { + const sdk = EvoSDK.testnetVerified(); + + await sdk.connect(); + + const embedded = wasmSDKPackage.WasmVerifiedContext.testnet(); + expect(sdk.isConnected).to.equal(true); + expect(sdk.verifiedContinuation()).to.deep.equal({ anchor: embedded.checkpoint(), states: [] }); + embedded.free(); + }); + + it('should start from a saved checkpoint', async () => { + const saved = JSON.parse(JSON.stringify(wasmSDKPackage.WasmVerifiedContext.testnet().checkpoint())); + const sdk = EvoSDK.testnetVerified({ checkpoint: saved, quorumRelays: ['https://relay.test'] }); + + await sdk.connect(); + + expect(sdk.verifiedContinuation()).to.deep.equal({ anchor: saved, states: [] }); + }); + + it('should resume from a saved continuation', async () => { + const anchor = JSON.parse(JSON.stringify(wasmSDKPackage.WasmVerifiedContext.testnet().checkpoint())); + const later = { ...anchor, height: anchor.height + 100 }; + const saved = JSON.parse(JSON.stringify({ anchor, states: [later] })); + const sdk = EvoSDK.testnetVerified({ continuation: saved, quorumRelays: ['https://relay.test'] }); + + await sdk.connect(); + + expect(sdk.verifiedContinuation()).to.deep.equal(saved); + }); + + it('should refuse to connect with proofs turned off', async () => { + const sdk = EvoSDK.testnetVerified({ proofs: false }); + let thrown: unknown; + try { + await sdk.connect(); + } catch (error) { + thrown = error; + } + expect((thrown as { message?: string })?.message).to.match(/requires proofs/); + expect(sdk.isConnected).to.equal(false); + }); + + it('should surface an invalid relay from the wasm side on connect', async () => { + const sdk = EvoSDK.testnetVerified({ quorumRelays: ['http://relay.test'] }); + let thrown: unknown; + try { + await sdk.connect(); + } catch (error) { + thrown = error; + } + expect((thrown as { message?: string })?.message).to.match(/https/); + expect(sdk.isConnected).to.equal(false); + }); +}); diff --git a/packages/rs-context-provider/src/provider.rs b/packages/rs-context-provider/src/provider.rs index 9e31f4bffaf..a01258884ef 100644 --- a/packages/rs-context-provider/src/provider.rs +++ b/packages/rs-context-provider/src/provider.rs @@ -1,4 +1,5 @@ use crate::error::ContextProviderError; +use dpp::dashcore::Network; use dpp::data_contract::TokenConfiguration; use dpp::prelude::{CoreBlockHeight, DataContract, Identifier}; use dpp::version::PlatformVersion; @@ -139,6 +140,54 @@ pub trait ContextProvider: Send + Sync { /// * `Ok(CoreBlockHeight)`: On success, returns the platform activation height as defined by mn_rr /// * `Err(Error)`: On failure, returns an error indicating why the operation failed. fn get_platform_activation_height(&self) -> Result; + + /// The network whose quorum keys this provider serves, if it serves only + /// one. The SDK refuses a provider for another network than its own. + /// + /// The default, `None`, makes no claim. + fn network(&self) -> Option { + None + } + + /// Whether the provider is only sound on an SDK that requests and + /// verifies proofs, for example because its keys are proven for the + /// heights in verified responses. The SDK refuses such a provider with + /// proofs turned off. + /// + /// The default is `false`. + fn requires_proofs(&self) -> bool { + false + } + + /// Called by the proof verifier once the Tenderdash signature of a + /// response verified with a key from this provider, with the quorum that + /// signed it and the Core ChainLocked height in that response's metadata: + /// the same arguments, in the same order, as the + /// [get_quorum_public_key](Self::get_quorum_public_key) call that + /// returned the key. + /// + /// The quorum signed that height as part of the state it committed, so + /// it is a Core height Platform actually reached, unlike the heights + /// passed to [get_quorum_public_key](Self::get_quorum_public_key), which + /// come from responses not verified yet. Providers may use it to track + /// Platform's progress. It is not called for a response that fails + /// verification. + /// + /// A provider that combines others forwards the call only to the child + /// that supplied this quorum's key, found by the quorum type and hash: + /// another child never vouched for that height. Verifications run + /// concurrently, so the quorum identity, not the order of calls, says + /// which lookup succeeded. + /// + /// Called on the verification path, so it must be cheap and must not + /// block. The default does nothing. + fn core_height_verified( + &self, + _quorum_type: u32, + _quorum_hash: [u8; 32], + _core_chain_locked_height: u32, + ) { + } } impl + Send + Sync> ContextProvider for C { @@ -184,6 +233,24 @@ impl + Send + Sync> ContextProvider for C { fn get_platform_activation_height(&self) -> Result { self.as_ref().get_platform_activation_height() } + + fn network(&self) -> Option { + self.as_ref().network() + } + + fn requires_proofs(&self) -> bool { + self.as_ref().requires_proofs() + } + + fn core_height_verified( + &self, + quorum_type: u32, + quorum_hash: [u8; 32], + core_chain_locked_height: u32, + ) { + self.as_ref() + .core_height_verified(quorum_type, quorum_hash, core_chain_locked_height) + } } impl ContextProvider for std::sync::Mutex @@ -237,6 +304,26 @@ where let lock = self.lock().expect("lock poisoned"); lock.get_platform_activation_height() } + + fn network(&self) -> Option { + let lock = self.lock().expect("lock poisoned"); + lock.network() + } + + fn requires_proofs(&self) -> bool { + let lock = self.lock().expect("lock poisoned"); + lock.requires_proofs() + } + + fn core_height_verified( + &self, + quorum_type: u32, + quorum_hash: [u8; 32], + core_chain_locked_height: u32, + ) { + let lock = self.lock().expect("lock poisoned"); + lock.core_height_verified(quorum_type, quorum_hash, core_chain_locked_height) + } } /// A trait that provides a function that can be used to look up a [DataContract] by its [Identifier]. @@ -422,12 +509,21 @@ mod tests { use std::sync::atomic::{AtomicUsize, Ordering}; use std::sync::Mutex; + /// A quorum type, quorum hash and Core height, as passed to + /// [ContextProvider::core_height_verified]. + type Verified = (u32, [u8; 32], u32); + + /// A quorum type and quorum hash. + type Quorum = (u32, [u8; 32]); + /// Provider whose `fetch_quorum_public_key` counts its calls and records - /// the quorum it was asked for. + /// the quorum it was asked for, and which records the quorums and + /// heights reported verified. #[derive(Default)] struct FetchingProvider { calls: AtomicUsize, asked: Mutex>, + verified: Mutex>, } impl ContextProvider for FetchingProvider { @@ -472,6 +568,247 @@ mod tests { fn get_platform_activation_height(&self) -> Result { Ok(1) } + + fn network(&self) -> Option { + Some(Network::Testnet) + } + + fn requires_proofs(&self) -> bool { + true + } + + fn core_height_verified( + &self, + quorum_type: u32, + quorum_hash: [u8; 32], + core_chain_locked_height: u32, + ) { + self.verified.lock().unwrap().push(( + quorum_type, + quorum_hash, + core_chain_locked_height, + )); + } + } + + /// The SDK checks a provider's requirements through the same wrappers it + /// reaches the provider through; a wrapper that fell back to the defaults + /// would let the SDK accept a provider it must refuse. + #[test] + fn should_forward_provider_requirements_through_every_wrapper() { + let inner = Arc::new(FetchingProvider::default()); + let boxed: Arc> = + Arc::new(Box::new(Arc::clone(&inner)) as Box); + let mutex = std::sync::Mutex::new(Arc::clone(&inner)); + let wrappers: [&dyn ContextProvider; 3] = [&inner, &boxed, &mutex]; + for provider in wrappers { + assert_eq!(provider.network(), Some(Network::Testnet)); + assert!(provider.requires_proofs()); + } + } + + /// The proof verifier reports verified heights through the same + /// wrappers; a wrapper that fell back to the no-op default would hide + /// Platform's progress from the provider. + #[test] + fn should_forward_verified_core_heights_through_every_wrapper() { + let inner = Arc::new(FetchingProvider::default()); + let boxed: Arc> = + Arc::new(Box::new(Arc::clone(&inner)) as Box); + let mutex = Mutex::new(Arc::clone(&inner)); + let wrappers: [&dyn ContextProvider; 3] = [&inner, &boxed, &mutex]; + for (height, provider) in (10..).zip(wrappers) { + provider.core_height_verified(6, [height as u8; 32], height); + } + assert_eq!( + *inner.verified.lock().unwrap(), + [(6, [10; 32], 10), (6, [11; 32], 11), (6, [12; 32], 12)] + ); + } + + /// A child of [Combining]: serves the keys of the quorums it holds and + /// records the verified heights it is told about. + struct Child { + quorums: Vec<[u8; 32]>, + verified: Mutex>, + } + + impl Child { + fn holding(quorums: Vec<[u8; 32]>) -> Self { + Self { + quorums, + verified: Mutex::new(Vec::new()), + } + } + } + + impl ContextProvider for Child { + fn get_data_contract( + &self, + _id: &Identifier, + _platform_version: &PlatformVersion, + ) -> Result>, ContextProviderError> { + Ok(None) + } + + fn get_token_configuration( + &self, + _token_id: &Identifier, + ) -> Result, ContextProviderError> { + Ok(None) + } + + fn get_quorum_public_key( + &self, + _quorum_type: u32, + quorum_hash: [u8; 32], + _core_chain_locked_height: u32, + ) -> Result<[u8; 48], ContextProviderError> { + if self.quorums.contains(&quorum_hash) { + Ok([quorum_hash[0]; 48]) + } else { + Err(ContextProviderError::QuorumSourceUnavailable( + "not held".to_string(), + )) + } + } + + fn get_platform_activation_height(&self) -> Result { + Ok(1) + } + + fn core_height_verified( + &self, + quorum_type: u32, + quorum_hash: [u8; 32], + core_chain_locked_height: u32, + ) { + self.verified.lock().unwrap().push(( + quorum_type, + quorum_hash, + core_chain_locked_height, + )); + } + } + + /// A provider that asks its children in turn for a quorum's key, + /// remembers which child supplied each quorum, and forwards a verified + /// height only to that child. + struct Combining { + children: Vec, + suppliers: Mutex>, + } + + impl ContextProvider for Combining { + fn get_data_contract( + &self, + _id: &Identifier, + _platform_version: &PlatformVersion, + ) -> Result>, ContextProviderError> { + Ok(None) + } + + fn get_token_configuration( + &self, + _token_id: &Identifier, + ) -> Result, ContextProviderError> { + Ok(None) + } + + fn get_quorum_public_key( + &self, + quorum_type: u32, + quorum_hash: [u8; 32], + core_chain_locked_height: u32, + ) -> Result<[u8; 48], ContextProviderError> { + let mut last = None; + for (index, child) in self.children.iter().enumerate() { + match child.get_quorum_public_key( + quorum_type, + quorum_hash, + core_chain_locked_height, + ) { + Ok(key) => { + let mut suppliers = self.suppliers.lock().unwrap(); + suppliers.retain(|(quorum, _)| *quorum != (quorum_type, quorum_hash)); + suppliers.push(((quorum_type, quorum_hash), index)); + return Ok(key); + } + Err(error) => last = Some(error), + } + } + Err(last.unwrap_or_else(|| ContextProviderError::Config("no child".to_string()))) + } + + fn get_platform_activation_height(&self) -> Result { + Ok(1) + } + + fn core_height_verified( + &self, + quorum_type: u32, + quorum_hash: [u8; 32], + core_chain_locked_height: u32, + ) { + let supplier = self + .suppliers + .lock() + .unwrap() + .iter() + .find(|(quorum, _)| *quorum == (quorum_type, quorum_hash)) + .map(|(_, index)| *index); + if let Some(index) = supplier { + self.children[index].core_height_verified( + quorum_type, + quorum_hash, + core_chain_locked_height, + ); + } + } + } + + /// Illustrates the contract with a stub combiner rather than testing + /// production code: the notification names the quorum, so a provider + /// combining others can route it to the child that supplied that + /// quorum's key, even when lookups and verifications of two quorums at + /// one height interleave, as concurrent requests do. That the verifier + /// passes the identity it looked the key up by is tested in + /// `drive-proof-verifier`. + #[test] + fn should_let_a_combining_provider_route_verified_heights_by_quorum() { + let (a, b) = ([0xaa; 32], [0xbb; 32]); + let combining = Combining { + children: vec![Child::holding(vec![a]), Child::holding(vec![b])], + suppliers: Mutex::new(Vec::new()), + }; + let provider: &dyn ContextProvider = &combining; + + assert_eq!( + provider.get_quorum_public_key(6, a, 100).unwrap(), + [0xaa; 48] + ); + assert_eq!( + provider.get_quorum_public_key(6, b, 100).unwrap(), + [0xbb; 48] + ); + provider.core_height_verified(6, b, 100); + provider.core_height_verified(6, a, 100); + assert_eq!( + provider.get_quorum_public_key(6, b, 101).unwrap(), + [0xbb; 48] + ); + provider.core_height_verified(6, b, 101); + // A quorum no child supplied reaches no child. + provider.core_height_verified(6, [0xcc; 32], 100); + + assert_eq!( + *combining.children[0].verified.lock().unwrap(), + [(6, a, 100)] + ); + assert_eq!( + *combining.children[1].verified.lock().unwrap(), + [(6, b, 100), (6, b, 101)] + ); } /// The SDK reaches its provider through `Arc`, `Box` @@ -539,5 +876,7 @@ mod tests { } assert!(CacheOnly.fetch_quorum_public_key(6, [0; 32], 1).is_none()); + assert_eq!(CacheOnly.network(), None); + assert!(!CacheOnly.requires_proofs()); } } diff --git a/packages/rs-drive-proof-verifier/src/verify.rs b/packages/rs-drive-proof-verifier/src/verify.rs index 3739c6007a5..5f0fcd52acf 100644 --- a/packages/rs-drive-proof-verifier/src/verify.rs +++ b/packages/rs-drive-proof-verifier/src/verify.rs @@ -111,7 +111,8 @@ fn ensure_proof_signed_at_verifier_version( /// ## Returns /// /// Returns: -/// * `Ok(())` when the proof is valid +/// * `Ok(())` when the proof is valid, after reporting the signing quorum +/// and the response's Core height to [ContextProvider::core_height_verified] /// * `Err(Error)` when the proof is invalid /// pub(crate) fn verify_tenderdash_proof( @@ -128,6 +129,11 @@ pub(crate) fn verify_tenderdash_proof( /// Verify a Tenderdash signature for an endpoint whose proof payload is not a /// direct `GroveDBProof` envelope. The endpoint must validate the version of /// every nested GroveDB proof before calling this function. +/// +/// Once the signature verifies, and only then, the response's Core ChainLocked +/// height is reported to [ContextProvider::core_height_verified], with the +/// quorum type and hash its key was looked up by: the quorum signed it, so it +/// is a height Platform committed. pub(crate) fn verify_tenderdash_signature( proof: &Proof, mtd: &ResponseMetadata, @@ -218,7 +224,11 @@ pub(crate) fn verify_tenderdash_signature( ); match verify_signature_digest(&sign_digest, &signature, &pubkey)? { - true => Ok(()), + true => { + // Only now is the Core height one the quorum signed. + provider.core_height_verified(quorum_type, quorum_hash, core_locked_height); + Ok(()) + } false => Err(Error::InvalidSignature { error: format!( "signature {} could not be verified with public key {} for sign digest {}", @@ -263,7 +273,7 @@ mod tests { }; use platform_serialization::PLATFORM_BINCODE_CONFIG; use std::collections::BTreeMap; - use std::sync::Arc; + use std::sync::{Arc, Mutex}; fn grovedb_proof_bytes(version: u32) -> Vec { let proof = match version { @@ -420,18 +430,30 @@ mod tests { /// A ContextProvider that returns a cryptographically valid public key /// so that tests can progress past the public-key parsing step and - /// reach deeper code paths (e.g. signature verification). + /// reach deeper code paths (e.g. signature verification). It records + /// the quorums and Core heights reported verified. struct ValidKeyContextProvider { pubkey_bytes: [u8; 48], + verified: Mutex>, } impl ValidKeyContextProvider { fn new() -> Self { - let (_, pk) = test_keypair(); - let pk_vec = pk.to_bytes().to_vec(); + Self::serving(&test_keypair().0) + } + + /// Serves the public key of `key`. + fn serving(key: &bls::SecretKey) -> Self { let mut pubkey_bytes = [0u8; 48]; - pubkey_bytes.copy_from_slice(&pk_vec); - Self { pubkey_bytes } + pubkey_bytes.copy_from_slice(&key.public_key().to_bytes()); + Self { + pubkey_bytes, + verified: Mutex::new(Vec::new()), + } + } + + fn verified(&self) -> Vec<(u32, [u8; 32], u32)> { + self.verified.lock().expect("not poisoned").clone() } } @@ -463,6 +485,19 @@ mod tests { fn get_platform_activation_height(&self) -> Result { Ok(1) } + + fn core_height_verified( + &self, + quorum_type: u32, + quorum_hash: [u8; 32], + core_chain_locked_height: u32, + ) { + self.verified.lock().expect("not poisoned").push(( + quorum_type, + quorum_hash, + core_chain_locked_height, + )); + } } /// Helper to build default test metadata. @@ -733,12 +768,9 @@ mod tests { ); } - #[test] - fn should_reject_a_verified_result_root_that_differs_from_the_signed_app_hash() { - let metadata = test_metadata(); - let provider = ValidKeyContextProvider::new(); - let (key, _) = test_keypair(); - let root = [7; 32]; + /// A proof of `root` at `metadata`, signed by `key` as a quorum signs a + /// precommit. + fn signed_proof(key: &bls::SecretKey, metadata: &ResponseMetadata, root: [u8; 32]) -> Proof { let mut proof = Proof { grovedb_proof: grovedb_proof_bytes(1), quorum_hash: vec![2; 32], @@ -778,6 +810,16 @@ mod tests { ) .expect("hash vote"); proof.signature = key.sign(&digest).expect("sign vote").to_bytes().to_vec(); + proof + } + + #[test] + fn should_reject_a_verified_result_root_that_differs_from_the_signed_app_hash() { + let metadata = test_metadata(); + let provider = ValidKeyContextProvider::new(); + let (key, _) = test_keypair(); + let root = [7; 32]; + let proof = signed_proof(&key, &metadata, root); verify_tenderdash_proof(&proof, &metadata, &root, &provider) .expect("matching app hash control"); let mut different_root = root; @@ -788,6 +830,47 @@ mod tests { )); } + /// The provider learns a response's Core height only once the quorum's + /// signature over it verified: a response with an altered height, or + /// signed by another key, reports nothing, so a forged response cannot + /// pass for Platform progress. + #[test] + fn should_report_the_core_height_verified_only_after_the_signature_verifies() { + let (key, _) = test_keypair(); + let root = [7; 32]; + let metadata = ResponseMetadata { + core_chain_locked_height: 4321, + ..test_metadata() + }; + let proof = signed_proof(&key, &metadata, root); + + let provider = ValidKeyContextProvider::serving(&key); + verify_tenderdash_proof(&proof, &metadata, &root, &provider).expect("verifies"); + // With the quorum that signed it, as the key was looked up. + assert_eq!(provider.verified(), [(1, [2; 32], 4321)]); + + // The same signature, with the Core height one above the signed one. + let provider = ValidKeyContextProvider::serving(&key); + let forged = ResponseMetadata { + core_chain_locked_height: 4322, + ..metadata.clone() + }; + assert!(matches!( + verify_tenderdash_proof(&proof, &forged, &root, &provider), + Err(Error::InvalidSignature { .. }) + )); + assert!(provider.verified().is_empty()); + + // Signed by a key other than the one the provider serves. + let other = bls::SecretKey::from_be_bytes(&[0x11; 32]).expect("a secret key"); + let provider = ValidKeyContextProvider::serving(&other); + assert!(matches!( + verify_tenderdash_proof(&proof, &metadata, &root, &provider), + Err(Error::InvalidSignature { .. }) + )); + assert!(provider.verified().is_empty()); + } + mod verify_page_drive_proves { use super::*; use std::cell::RefCell; diff --git a/packages/rs-drive-proof-verifier/tests/common/mod.rs b/packages/rs-drive-proof-verifier/tests/common/mod.rs index ba341f6c3b5..55f2310e678 100644 --- a/packages/rs-drive-proof-verifier/tests/common/mod.rs +++ b/packages/rs-drive-proof-verifier/tests/common/mod.rs @@ -14,7 +14,7 @@ #![allow(dead_code)] use std::path::PathBuf; -use std::sync::Arc; +use std::sync::{Arc, Mutex}; use dapi_grpc::platform::v0::{Proof, ResponseMetadata}; use dpp::dashcore::Network; @@ -227,15 +227,29 @@ impl Case { quorum_type: self.manifest.proof_meta.quorum_type, quorum_hash: hex32(&self.manifest.proof_meta.quorum_hash_hex), quorum_pubkey: self.quorum_pubkey, + verified_core_heights: Mutex::new(Vec::new()), } } } -/// [ContextProvider] backed by the per-case corpus quorum key material. +/// [ContextProvider] backed by the per-case corpus quorum key material. It +/// records the quorums and Core heights the verifier reports verified. pub struct VectorContextProvider { quorum_type: u32, quorum_hash: [u8; 32], quorum_pubkey: [u8; 48], + verified_core_heights: Mutex>, +} + +impl VectorContextProvider { + /// The quorum types, quorum hashes and Core heights reported through + /// [ContextProvider::core_height_verified], in order. + pub fn verified_core_heights(&self) -> Vec<(u32, [u8; 32], u32)> { + self.verified_core_heights + .lock() + .expect("not poisoned") + .clone() + } } impl ContextProvider for VectorContextProvider { @@ -279,4 +293,16 @@ impl ContextProvider for VectorContextProvider { fn get_platform_activation_height(&self) -> Result { Ok(1) } + + fn core_height_verified( + &self, + quorum_type: u32, + quorum_hash: [u8; 32], + core_chain_locked_height: u32, + ) { + self.verified_core_heights + .lock() + .expect("not poisoned") + .push((quorum_type, quorum_hash, core_chain_locked_height)); + } } diff --git a/packages/rs-drive-proof-verifier/tests/vectors_quorum_sig.rs b/packages/rs-drive-proof-verifier/tests/vectors_quorum_sig.rs index 8b099eb98ad..5509e8858e6 100644 --- a/packages/rs-drive-proof-verifier/tests/vectors_quorum_sig.rs +++ b/packages/rs-drive-proof-verifier/tests/vectors_quorum_sig.rs @@ -11,7 +11,9 @@ mod common; -use common::{identifier, load_case, Case, ErrorClass, Expected, RequestSpec, NETWORK}; +use common::{ + identifier, load_case, Case, ErrorClass, Expected, RequestSpec, VectorContextProvider, NETWORK, +}; use dapi_grpc::platform::v0::{ self as platform, get_identity_balance_request, get_identity_balance_response, }; @@ -25,13 +27,12 @@ fn verify_balance(case: &Case) -> Result, Error> { verify_balance_with_metadata(case, case.metadata()) } -/// Like [`verify_balance`], but lets the caller substitute the response -/// metadata that gets carried alongside the case's untouched proof envelope -/// -- used to prove the metadata itself is inside the signed `StateId`. +/// Like [`verify_balance_with_metadata`], with the caller's provider. #[allow(clippy::result_large_err)] -fn verify_balance_with_metadata( +fn verify_balance_with( case: &Case, metadata: platform::ResponseMetadata, + provider: &VectorContextProvider, ) -> Result, Error> { let RequestSpec::IdentityBalance { identity_id } = &case.manifest.request else { panic!("{}: expected an identity_balance request", case.name); @@ -61,10 +62,21 @@ fn verify_balance_with_metadata( response, NETWORK, case.platform_version(), - &case.provider(), + provider, ) } +/// Like [`verify_balance`], but lets the caller substitute the response +/// metadata that gets carried alongside the case's untouched proof envelope +/// -- used to prove the metadata itself is inside the signed `StateId`. +#[allow(clippy::result_large_err)] +fn verify_balance_with_metadata( + case: &Case, + metadata: platform::ResponseMetadata, +) -> Result, Error> { + verify_balance_with(case, metadata, &case.provider()) +} + fn expect_invalid_signature(name: &str) { let case = load_case(name); let Expected::Error { class } = case.manifest.expected else { @@ -146,3 +158,36 @@ fn tampered_time_ms_breaks_signature() { fn tampered_core_chain_locked_height_breaks_signature() { expect_metadata_tamper_breaks_signature(|metadata| metadata.core_chain_locked_height += 1); } + +/// A real response whose quorum signature verifies reports its signing quorum +/// and Core height to the provider; the same response with the Core height +/// altered, or checked against the wrong quorum key, reports nothing, so a +/// forged response never counts as Platform progress. +#[test] +fn should_report_the_core_height_verified_only_for_a_verified_signature() { + let case = load_case("quorum-sig-valid"); + let provider = case.provider(); + verify_balance_with(&case, case.metadata(), &provider).expect("verifies"); + // With the quorum that signed it, as the key was looked up. + let signer = case.grpc_proof(); + let signer_hash: [u8; 32] = signer.quorum_hash.try_into().expect("32 bytes"); + assert_eq!( + provider.verified_core_heights(), + [( + signer.quorum_type, + signer_hash, + case.metadata().core_chain_locked_height + )] + ); + + let provider = case.provider(); + let mut forged = case.metadata(); + forged.core_chain_locked_height += 1; + verify_balance_with(&case, forged, &provider).expect_err("altered height"); + assert!(provider.verified_core_heights().is_empty()); + + let wrong_key = load_case("quorum-sig-wrong-quorum-key"); + let provider = wrong_key.provider(); + verify_balance_with(&wrong_key, wrong_key.metadata(), &provider).expect_err("wrong key"); + assert!(provider.verified_core_heights().is_empty()); +} diff --git a/packages/rs-sdk-trusted-context-provider/README.md b/packages/rs-sdk-trusted-context-provider/README.md index ebd0dadc768..03ecfa965fa 100644 --- a/packages/rs-sdk-trusted-context-provider/README.md +++ b/packages/rs-sdk-trusted-context-provider/README.md @@ -129,7 +129,6 @@ but cannot make the SDK accept a forged key or ban an honest node. ```rust use dash_sdk::SdkBuilder; -use dash_sdk::dapi_client::AddressList; use dpp::dashcore::Network; use rs_sdk_trusted_context_provider::VerifiedHttpContextProvider; use std::num::NonZeroUsize; @@ -140,24 +139,42 @@ let provider = VerifiedHttpContextProvider::new( NonZeroUsize::new(100).expect("cache size"), )?; -let sdk = SdkBuilder::new(AddressList::default()) +// The SDK refuses a provider for another network than its own. +let sdk = SdkBuilder::new_testnet() .with_context_provider(provider) .build()?; ``` -Your own checkpoint and relays (1 to 8, `https` except on loopback): +`with_options` takes the defaults (the embedded checkpoint, the network's +public quorum service, a cache of `DEFAULT_QUORUM_KEY_CACHE_SIZE` keys) for +every option it is not given: a starting point, relays (1 to 8, `https` +except on loopback) and the cache size. ```rust -let checkpoint = VerifiedHttpContextProvider::embedded_checkpoint(Network::Testnet)?; -let provider = VerifiedHttpContextProvider::with_checkpoint( +use rs_sdk_trusted_context_provider::{ + Continuation, StartingPoint, VerifiedHttpContextProvider, VerifiedProviderOptions, +}; + +let provider = VerifiedHttpContextProvider::with_options( Network::Testnet, - checkpoint, - vec!["https://relay.example.org".to_string()], - NonZeroUsize::new(100).expect("cache size"), + VerifiedProviderOptions { + sources: Some(vec!["https://relay.example.org".to_string()]), + ..Default::default() + }, )?; -// Later: persist the newest verified state and start from it next time. -let state_json = serde_json::to_string(&provider.latest_verified_state())?; +// Later: persist where proofs got to... +let saved = serde_json::to_string(&provider.continuation())?; + +// ...and resume from it on the next start. +let continuation: Continuation = serde_json::from_str(&saved)?; +let next = VerifiedHttpContextProvider::with_options( + Network::Testnet, + VerifiedProviderOptions { + start: Some(StartingPoint::Continuation(continuation)), + ..Default::default() + }, +)?; ``` Behaviour: @@ -197,7 +214,10 @@ Behaviour: - The public mainnet quorum service currently answers `/proofs` with HTTP 403. `VerifiedHttpContextProvider::new(Network::Mainnet, ..)` builds, but its fetches fail until the service serves proofs; pass working mainnet relays - to `with_checkpoint` meanwhile. + in `VerifiedProviderOptions::sources` meanwhile. +- The SDK refuses the provider with proofs turned off (`with_proofs(false)`) + or on an SDK for another network: it reports both requirements through + `ContextProvider::requires_proofs` and `ContextProvider::network`. ### Trust model @@ -231,8 +251,45 @@ response must be is the SDK's policy. A proof covers every ChainLock quorum rotation between its starting state and its target, so it grows as the checkpoint ages, and a relay has to keep the blocks back to it. Each release moves the embedded checkpoints forward, as -below. A long-lived application should persist `latest_verified_state()` and -pass it back to `with_checkpoint` on the next start, so its proofs stay short. +below. +A long-lived application should persist `continuation()` and resume from it +on the next start (`StartingPoint::Continuation`), so its proofs stay short. + +A continuation is an anchor and the verified states above it. The anchor is +the newest verified state strictly below one DKG interval (24 blocks) under +the lowest Core height of a response the SDK verified with the provider's +keys, or the provider's own checkpoint when none was verified or no state is +kept below that height. The proof verifier reports that height, with the +quorum that signed it, through `ContextProvider::core_height_verified` only +after the response's Tenderdash signature verified, so the height was signed +by a Platform quorum as part of the state it committed: Platform reached it. +The provider counts it only when its own cache serves that quorum's key at +that height, so it could have supplied the key; routing the height to the +provider that did is the job of any provider combining others. The next +session needs heights at or above Platform's Core height, which only rises, +so the lowest height verified this session is at or below every height the +next session needs. A key verified at a height `H` +may rest on evidence up to one DKG interval below `H`: it is served up to +24 blocks past the highest target that opened its quorum, for the block on +which Drive still signs with a quorum Core has just retired, and that quorum +is in no quorum root above its retirement. A proof starts from a state +strictly below its target, so the continuation keeps a state below `H - 24` +as its anchor, and every state above it, and the next session can prove such +a key again by asking for a target at or above `H - 24`. The heights keys +are looked up or fetched for come from responses not verified yet and never +count, so a forged response, or one that fails verification, cannot move the +anchor. Verified states above the heights verified, such as the relay's tip +or a target above the height asked for, never become the anchor. A node +answering from an older block can only make the anchor older, and proofs +longer. The anchor advances when the SDK verifies responses at heights more +than one DKG interval above the states verified before them. +Persisting `latest_verified_state()` as the next checkpoint instead would +strand the next session: that state is often the relay's tip, above the +Core height Platform reports, so no key at or below it could be proven until +Platform passes it. The states above the anchor keep later proofs short. +Every state in a continuation is trust configuration, like the checkpoint: +only resume from a continuation this provider produced, or one from a +provider verified from a checkpoint you trust. ### Limitations @@ -240,8 +297,8 @@ pass it back to `with_checkpoint` on the next start, so its proofs stay short. Platform halts, that height stops while Core moves on. A quorum not cached before the halt is still proven with a proof whose target is that height, as long as the relay's Core has that ChainLock. -- A height at or below the checkpoint cannot be proven, since every proof - starts from it. +- A height at or below the checkpoint (a continuation's anchor) cannot be + proven, since every proof starts from it. - If the Core height jumps by more than 24 blocks between two Platform blocks, for example on the first block after a long idle period or a halt, that one block's responses cannot be verified until the next Platform block. The diff --git a/packages/rs-sdk-trusted-context-provider/src/lib.rs b/packages/rs-sdk-trusted-context-provider/src/lib.rs index fb7ecc88e91..28ac9f5ef3c 100644 --- a/packages/rs-sdk-trusted-context-provider/src/lib.rs +++ b/packages/rs-sdk-trusted-context-provider/src/lib.rs @@ -28,7 +28,10 @@ pub mod verified_provider; pub use error::TrustedContextProviderError; pub use provider::TrustedHttpContextProvider; -pub use verified_provider::{VerifiedContextProviderError, VerifiedHttpContextProvider}; +pub use verified_provider::{ + Continuation, StartingPoint, VerifiedContextProviderError, VerifiedHttpContextProvider, + VerifiedProviderOptions, DEFAULT_QUORUM_KEY_CACHE_SIZE, +}; use dpp::dashcore::Network; diff --git a/packages/rs-sdk-trusted-context-provider/src/proof_fetches.rs b/packages/rs-sdk-trusted-context-provider/src/proof_fetches.rs index f4fd7736184..2ae0fc7cbf2 100644 --- a/packages/rs-sdk-trusted-context-provider/src/proof_fetches.rs +++ b/packages/rs-sdk-trusted-context-provider/src/proof_fetches.rs @@ -95,7 +95,7 @@ const MAX_FAILED_FETCHES: usize = 64; /// quorums could pile up; past it, new quorums are refused until one ends. const MAX_RUNNING_FETCHES: usize = 16; /// Verified states kept besides the checkpoint, to start later proofs from. -const MAX_RECENT_STATES: usize = 16; +pub(crate) const MAX_RECENT_STATES: usize = 16; /// The delay before asking a busy relay again doubles this many times. const MAX_RETRY_DOUBLINGS: u32 = 3; /// Answers a source may give that it cannot serve the quorum before a fetch @@ -257,8 +257,11 @@ struct Ask { /// below `height`, from the newest state below that target, so a quorum that /// retired between that target and `height` is reached too. Any such target that opens the /// quorum serves `height`, even on the block where Drive still signs with a -/// quorum Core has just retired. When no state is kept below that target, -/// the lowest target above the newest state below `height` is asked for. +/// quorum Core has just retired. When no state is kept below that target but +/// the checkpoint is below `height`, the target just above the checkpoint is +/// asked for, from the checkpoint: a later state may be past the quorum's +/// retirement, and a recent custom checkpoint may still lead to a target +/// that opens it, as it did before a restore. /// /// `extending` says the cache holds the quorum from a target at or below /// `height` that is more than one DKG interval old, so any target at or above @@ -281,9 +284,15 @@ fn choose_ask( ) -> Option { let below = states.range(..height).next_back(); if alternate { - // A quorum retired since `lower` is in no quorum root above it. + // A quorum retired since `lower` is in no quorum root above it, so + // with no state below `lower` the oldest state below `height` is the + // one most likely to precede the retirement. let lower = height.saturating_sub(DKG_INTERVAL_BLOCKS); - if let Some((&from, state)) = states.range(..lower).next_back().or(below) { + let from_state = states + .range(..lower) + .next_back() + .or_else(|| states.range(..height).next()); + if let Some((&from, state)) = from_state { return Some(Ask { checkpoint: state.clone(), height: lower.max(from + 1), @@ -443,6 +452,33 @@ impl ProofFetches { .map_or_else(|| self.checkpoint.clone(), |(_, state)| state.clone()) } + /// The checkpoint and the verified states kept besides it, lowest first. + pub(crate) fn states(&self) -> Vec { + self.lock().states.values().cloned().collect() + } + + /// Keep `states`, verified earlier from this checkpoint, as if proofs had + /// just verified them: later proofs start from them. Each must be above + /// the checkpoint; past the bound, the lowest are dropped. + pub(crate) fn resume(&self, states: Vec) { + let mut proven = self.lock(); + for state in states { + debug_assert!(state.height > self.checkpoint.height); + proven.states.insert(state.height, state); + } + while proven.states.len() > MAX_RECENT_STATES + 1 { + let Some(oldest) = proven + .states + .keys() + .copied() + .find(|height| *height != self.checkpoint.height) + else { + break; + }; + proven.states.remove(&oldest); + } + } + /// How many quorum keys are cached. pub(crate) fn cached_quorum_count(&self) -> usize { self.lock().keys.len() @@ -484,6 +520,15 @@ impl ProofFetches { } } + /// Whether the cache holds the quorum and serves it for `height`, as a + /// [`lookup`](Self::lookup) would. Leaves the cache order alone. + pub(crate) fn serves(&self, quorum_type: u32, quorum_hash: [u8; 32], height: u32) -> bool { + self.lock() + .keys + .peek(&(quorum_type, quorum_hash)) + .is_some_and(|cached| cached.serves(height)) + } + /// Prove a quorum's key for `height`, or join the fetch already proving /// it, and return it. Every failure is /// [`ContextProviderError::QuorumSourceUnavailable`]; this never reports @@ -1484,6 +1529,23 @@ mod tests { ask(150, 200 - DKG_INTERVAL_BLOCKS), "a quorum retired by 190", ), + // A checkpoint C = H - 23 and states L = H - 20, T = H - 10: none + // is below H - 24, so the lower ask goes from C, not from T, + // which may be past the quorum's retirement. + ( + &[100, 103, 113], + 123, + true, + ask(100, 101), + "recent checkpoint: from it", + ), + ( + &[100, 103, 113], + 128, + true, + ask(103, 104), + "a state below the lower target: the newest such", + ), ]; for (heights, height, alternate, expected, why) in cases { assert_eq!( @@ -1519,6 +1581,23 @@ mod tests { ), (&[100], 100, false, None, "nothing below"), (&[100], 50, true, None, "under the checkpoint"), + // As for the tip: with no state below H - 24, the lower ask goes + // from the oldest state below H, the checkpoint C = H - 23, not + // from T = H - 10. + ( + &[100, 103, 113], + 123, + true, + ask(100, 101), + "recent checkpoint: from it", + ), + ( + &[100, 103, 113], + 128, + true, + ask(103, 104), + "a state below the lower target: the newest such", + ), ]; for (heights, height, alternate, expected, why) in cases { assert_eq!( diff --git a/packages/rs-sdk-trusted-context-provider/src/verified_provider.rs b/packages/rs-sdk-trusted-context-provider/src/verified_provider.rs index 007830b0317..6970c68f056 100644 --- a/packages/rs-sdk-trusted-context-provider/src/verified_provider.rs +++ b/packages/rs-sdk-trusted-context-provider/src/verified_provider.rs @@ -10,7 +10,7 @@ //! requests but cannot make the SDK accept a forged key or ban honest nodes. use crate::get_quorum_base_url; -use crate::proof_fetches::{ProofFetches, Timing}; +use crate::proof_fetches::{ProofFetches, Timing, DKG_INTERVAL_BLOCKS}; use crate::proof_transport::ProofTransport; use crate::system_contracts::{ is_inactive_system_contract, platform_activation_height, system_data_contract, @@ -23,8 +23,10 @@ use dpp::data_contract::accessors::v0::DataContractV0Getters; use dpp::data_contract::TokenConfiguration; use dpp::prelude::{CoreBlockHeight, DataContract, Identifier}; use dpp::version::PlatformVersion; +use serde::{Deserialize, Serialize}; use std::collections::HashMap; use std::num::NonZeroUsize; +use std::sync::atomic::{AtomicU32, Ordering}; use std::sync::{Arc, Mutex, MutexGuard, PoisonError}; use thiserror::Error; use url::{Host, Url}; @@ -32,6 +34,14 @@ use url::{Host, Url}; /// Most relays one provider asks. const MAX_SOURCES: usize = 8; +/// How many proven quorum keys a provider keeps unless told otherwise: above +/// the 24 active Platform quorums a provider requires, so a quorum that just +/// rotated out is not proven again at once. +pub const DEFAULT_QUORUM_KEY_CACHE_SIZE: NonZeroUsize = match NonZeroUsize::new(32) { + Some(size) => size, + None => unreachable!(), +}; + /// The embedded mainnet checkpoint, Core `ProofState` JSON. const MAINNET_CHECKPOINT: &str = include_str!("../checkpoints/mainnet.json"); /// The embedded testnet checkpoint, Core `ProofState` JSON. @@ -134,12 +144,17 @@ pub enum VerifiedContextProviderError { /// relay's Core still has that ChainLock. /// /// A proof grows with the age of its starting state. Long-lived applications -/// should persist [`latest_verified_state`](Self::latest_verified_state) and -/// pass it back as the checkpoint on their next start. +/// should persist [`continuation`](Self::continuation) and start the next +/// session from it ([`StartingPoint::Continuation`]). Persisting +/// [`latest_verified_state`](Self::latest_verified_state) as the next +/// checkpoint instead would strand that session: a proof starts from a state +/// below the height it serves, and the newest state is often the relay's tip, +/// above the Core height Platform reports, so no key at or below it could be +/// proven until Platform passes it. /// /// The public mainnet relay currently answers `/proofs` with HTTP 403, so on /// mainnet [`new`](Self::new) builds but fetches fail until a relay serves -/// proofs; pass working sources to [`with_checkpoint`](Self::with_checkpoint). +/// proofs; pass working sources in [`VerifiedProviderOptions`]. /// /// Data contracts and token configurations come from the known caches below /// and the system contracts enabled by cargo features; nothing else is @@ -153,6 +168,10 @@ struct Inner { network: Network, sources: Vec, fetches: Arc, + /// The lowest Core height of a response whose signature verified with a + /// key this provider served, or `u32::MAX`; see + /// [`VerifiedHttpContextProvider::continuation`]. + lowest_verified_core_height: AtomicU32, known_contracts: Mutex>>, known_token_configurations: Mutex>, } @@ -162,6 +181,61 @@ fn lock(mutex: &Mutex) -> MutexGuard<'_, T> { mutex.lock().unwrap_or_else(PoisonError::into_inner) } +/// Where a session of a [`VerifiedHttpContextProvider`] starts proving from. +/// +/// Both are trust configuration: they decide which chain keys are proven +/// from, so they must come from this provider in an earlier session, or from +/// another provider verified from a checkpoint the application trusts, or be +/// a ChainLocked block the application checked independently. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum StartingPoint { + /// A single state, such as a block the application checked itself. + Checkpoint(State), + /// Where an earlier session got to, from + /// [`continuation`](VerifiedHttpContextProvider::continuation). + Continuation(Continuation), +} + +/// What a session of a [`VerifiedHttpContextProvider`] verified, for the next +/// session to start from: an anchor more than one DKG interval below the +/// heights that session will meet, so it can prove a key whose evidence lies +/// up to that interval below them (or the session's own checkpoint when no +/// kept state is that far below, as with a recent custom checkpoint), and +/// the newer states, so its proofs stay short. +/// +/// Serialized as `{ "anchor": state, "states": [state, ...] }`, each state in +/// the Core `ProofState` JSON form a checkpoint takes. Every state is trust +/// configuration, as a checkpoint is: only pass back a continuation this +/// provider produced, or one from another provider verified from a +/// checkpoint you trust. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Continuation { + /// The state the next session's proofs start from when no newer state + /// is below the height they serve; it becomes that session's checkpoint. + pub anchor: State, + /// Verified states above the anchor, in ascending order of height. + pub states: Vec, +} + +/// Overrides for [`VerifiedHttpContextProvider::with_options`]. Each field +/// left `None` takes the provider's default. +#[derive(Clone, Debug, Default)] +pub struct VerifiedProviderOptions { + /// Where proofs start. Default: the checkpoint this crate embeds for the + /// network. + pub start: Option, + /// 1 to 8 relay base URLs whose `/proofs` endpoint serves Core's + /// `getquorumproofchain` bootstrap proofs. They must be `https`, except + /// on loopback hosts, and distinct; hosts are not resolved. Default: the + /// network's public quorum service. + pub sources: Option>, + /// How many proven quorum keys are kept: at least the number of active + /// Platform quorums (24 on mainnet and testnet). Default: + /// [`DEFAULT_QUORUM_KEY_CACHE_SIZE`]. + pub cache_size: Option, +} + fn proof_network(network: Network) -> Result { match network { Network::Mainnet => Ok(ProofNetwork::Mainnet), @@ -178,6 +252,50 @@ fn validated(checkpoint: State) -> Result { Ok(checkpoint) } +/// A state a proof can start from, for `network`. +fn validated_for(state: State, network: Network) -> Result { + let expected = proof_network(network)?; + let state = validated(state)?; + if state.network != expected { + return Err(VerifiedContextProviderError::InvalidCheckpoint(format!( + "state at height {} is for {:?}, provider for {network:?}", + state.height, state.network + ))); + } + Ok(state) +} + +/// The checkpoint and the later states a starting point stands for, each +/// checked as a checkpoint is. A continuation's states must lie above its +/// anchor, lowest first, at distinct heights. +fn starting_states( + start: StartingPoint, + network: Network, +) -> Result<(State, Vec), VerifiedContextProviderError> { + let (checkpoint, states) = match start { + StartingPoint::Checkpoint(checkpoint) => (checkpoint, Vec::new()), + StartingPoint::Continuation(Continuation { anchor, states }) => (anchor, states), + }; + let checkpoint = validated_for(checkpoint, network)?; + let mut below = checkpoint.height; + let states = states + .into_iter() + .map(|state| { + let state = validated_for(state, network)?; + if state.height <= below { + return Err(VerifiedContextProviderError::InvalidCheckpoint(format!( + "continuation state at height {} is not above the anchor and the states \ + before it (at {below})", + state.height + ))); + } + below = state.height; + Ok(state) + }) + .collect::, _>>()?; + Ok((checkpoint, states)) +} + /// Parse a relay base URL. It must be absolute `https`, or `http` to a /// loopback host, and carry no credentials, query or fragment. The host is /// not resolved. @@ -232,10 +350,13 @@ impl VerifiedHttpContextProvider { network: Network, cache_size: NonZeroUsize, ) -> Result { - let checkpoint = Self::embedded_checkpoint(network)?; - let source = get_quorum_base_url(network, None) - .map_err(|_| VerifiedContextProviderError::UnsupportedNetwork(network))?; - Self::with_checkpoint(network, checkpoint, vec![source], cache_size) + Self::with_options( + network, + VerifiedProviderOptions { + cache_size: Some(cache_size), + ..Default::default() + }, + ) } /// A provider that starts from `checkpoint` and asks `sources`, 1 to 8 @@ -243,34 +364,66 @@ impl VerifiedHttpContextProvider { /// `getquorumproofchain` bootstrap proofs. /// /// The checkpoint is trust configuration: a ChainLocked block the - /// application checked independently, or a state this provider verified - /// earlier ([`latest_verified_state`](Self::latest_verified_state)). - /// Sources must be `https`, except on loopback hosts, and distinct. Hosts - /// are not resolved here. `cache_size` is as for [`new`](Self::new). + /// application checked independently. To resume where an earlier session + /// got to, start from its [`continuation`](Self::continuation) with + /// [`with_options`](Self::with_options) instead. Sources must be `https`, + /// except on loopback hosts, and distinct. Hosts are not resolved here. + /// `cache_size` is as for [`new`](Self::new). pub fn with_checkpoint( network: Network, checkpoint: State, sources: Vec, cache_size: NonZeroUsize, ) -> Result { - Self::build(network, checkpoint, sources, cache_size, Timing::default()) + Self::with_options( + network, + VerifiedProviderOptions { + start: Some(StartingPoint::Checkpoint(checkpoint)), + sources: Some(sources), + cache_size: Some(cache_size), + }, + ) + } + + /// A provider for mainnet or testnet with the defaults + /// [`VerifiedProviderOptions`] does not override: the embedded + /// checkpoint, the network's public quorum service and + /// [`DEFAULT_QUORUM_KEY_CACHE_SIZE`]. Makes no network request. + /// + /// A [`StartingPoint::Continuation`] is checked as a checkpoint is, + /// state by state: each must be valid and for this network, and the + /// states must lie above the anchor in ascending order of height. Only + /// the newest states a provider keeps are taken. + pub fn with_options( + network: Network, + options: VerifiedProviderOptions, + ) -> Result { + let start = match options.start { + Some(start) => start, + None => StartingPoint::Checkpoint(Self::embedded_checkpoint(network)?), + }; + let sources = match options.sources { + Some(sources) => sources, + None => vec![get_quorum_base_url(network, None) + .map_err(|_| VerifiedContextProviderError::UnsupportedNetwork(network))?], + }; + Self::build( + network, + start, + sources, + options.cache_size.unwrap_or(DEFAULT_QUORUM_KEY_CACHE_SIZE), + Timing::default(), + ) } fn build( network: Network, - checkpoint: State, + start: StartingPoint, sources: Vec, cache_size: NonZeroUsize, timing: Timing, ) -> Result { - let expected = proof_network(network)?; - let checkpoint = validated(checkpoint)?; - if checkpoint.network != expected { - return Err(VerifiedContextProviderError::InvalidCheckpoint(format!( - "checkpoint is for {:?}, provider for {network:?}", - checkpoint.network - ))); - } + let (checkpoint, states) = starting_states(start, network)?; if !(1..=MAX_SOURCES).contains(&sources.len()) { return Err(VerifiedContextProviderError::InvalidSource(format!( "{} sources given, expected 1 to {MAX_SOURCES}", @@ -325,11 +478,13 @@ impl VerifiedHttpContextProvider { ProofTransport::new()?, timing, ); + fetches.resume(states); Ok(Self { inner: Arc::new(Inner { network, sources, fetches: Arc::new(fetches), + lowest_verified_core_height: AtomicU32::new(u32::MAX), known_contracts: Mutex::new(HashMap::new()), known_token_configurations: Mutex::new(HashMap::new()), }), @@ -352,13 +507,88 @@ impl VerifiedHttpContextProvider { self.inner.fetches.checkpoint() } - /// The highest state a proof has verified, or the checkpoint. An - /// application may persist it and pass it back as the checkpoint, so - /// later proofs are shorter. + /// The highest state a proof has verified, or the checkpoint. + /// + /// For information: to start the next session from where this one got + /// to, persist [`continuation`](Self::continuation) instead. This state is + /// often the relay's tip, above the Core height Platform reports, and a + /// session started from it alone cannot prove a key at or below it. pub fn latest_verified_state(&self) -> State { self.inner.fetches.latest_state() } + /// Where this session got to, for the next one to start from with + /// [`StartingPoint::Continuation`]. + /// + /// The anchor is the newest verified state strictly below one DKG + /// interval (24 blocks) under the lowest Core height of a response the + /// SDK verified with this provider's keys (reported through + /// [`core_height_verified`](ContextProvider::core_height_verified)), or + /// this provider's checkpoint when none was verified or no state is kept + /// below that height. `states` are the verified states kept above the + /// anchor, lowest first; proofs mostly start from the newest one below + /// their height, so they stay short. + /// + /// Why the next session can prove the heights it needs: a proof starts + /// from a state strictly below its target, and the next session needs + /// heights at or above Platform's Core height. A height counted here was + /// signed by a Platform quorum as part of the state it committed, so + /// Platform reached it, and Platform's Core height only rises: every + /// height the next session needs is at or above the lowest one counted. + /// A key is served for a height `H` up to one DKG interval past the + /// highest target that opened its quorum, for the block on which Drive + /// still signs with a quorum Core has just retired, so a response at `H` + /// may have verified with a key whose only evidence is a target as low + /// as `H - 24`; the quorum may be in no quorum root above that target. + /// The next session proves such a key again by asking for a target at + /// or above `H - 24`, from a state below it: the anchor is below it, and + /// every state kept above the anchor is kept too. When the anchor is this + /// provider's checkpoint and not below `H - 24`, as a recent custom + /// checkpoint can be, it asks from the checkpoint for the target just + /// above it, which every target verified from it was at or above too. + /// Only verified responses count. The heights a key is looked up or + /// fetched for come from responses not verified yet, so a forged + /// response, or one that fails + /// verification for any reason, never moves the anchor. Verified states + /// above the heights counted, such as the relay's tip or a target above + /// the height asked for, never become the anchor. A node answering from + /// an older block only makes the anchor older, and proofs longer; in the + /// next session, a node behind the height verified here may name a + /// height at or below the anchor, which is then reported as unavailable + /// and the SDK moves to another node. + /// + /// The anchor advances when the SDK verifies responses at heights more + /// than one DKG interval above the states verified before them; until + /// then it stays where the session started. + pub fn continuation(&self) -> Continuation { + let states = self.inner.fetches.states(); + let anchor = self + .lowest_verified_core_height() + .map(|lowest| lowest.saturating_sub(DKG_INTERVAL_BLOCKS)) + .and_then(|below| states.iter().rev().find(|state| state.height < below)) + .unwrap_or(self.checkpoint()) + .clone(); + let states = states + .into_iter() + .filter(|state| state.height > anchor.height) + .collect(); + Continuation { anchor, states } + } + + /// The lowest Core height of a response verified with this provider's + /// keys this session, as reported through + /// [`core_height_verified`](ContextProvider::core_height_verified), if + /// any. [`continuation`](Self::continuation) anchors one DKG interval + /// below it. + pub fn lowest_verified_core_height(&self) -> Option { + Some( + self.inner + .lowest_verified_core_height + .load(Ordering::Relaxed), + ) + .filter(|&height| height != u32::MAX) + } + /// The relay base URLs the provider asks. pub fn sources(&self) -> &[Url] { &self.inner.sources @@ -492,14 +722,48 @@ impl ContextProvider for VerifiedHttpContextProvider { fn get_platform_activation_height(&self) -> Result { Ok(platform_activation_height(self.inner.network)) } + + fn network(&self) -> Option { + Some(self.inner.network) + } + + /// Keys are proven for the heights in verified responses; without proofs + /// nothing would be verified. + fn requires_proofs(&self) -> bool { + true + } + + /// Counts the height for [`continuation`](Self::continuation)'s anchor, + /// but only when this provider's cache serves the quorum's key for it, + /// so this provider could have supplied the key the response verified + /// with. A height reported for a quorum this provider does not serve + /// there, by a combining provider that forwards too widely, is not + /// counted; routing each height to the child that actually supplied the + /// key is the combining provider's job. + fn core_height_verified( + &self, + quorum_type: u32, + quorum_hash: [u8; 32], + core_chain_locked_height: u32, + ) { + if self + .inner + .fetches + .serves(quorum_type, quorum_hash, core_chain_locked_height) + { + self.inner + .lowest_verified_core_height + .fetch_min(core_chain_locked_height, Ordering::Relaxed); + } + } } #[cfg(all(test, not(target_arch = "wasm32")))] mod tests { use super::*; - use crate::proof_fetches::DKG_INTERVAL_BLOCKS; + use crate::proof_fetches::{DKG_INTERVAL_BLOCKS, MAX_RECENT_STATES}; use dash_core_proof::bootstrap; - use dash_core_proof::{parse_commitment, rpc_hex, MAX_PROOF_BYTES}; + use dash_core_proof::{parse_commitment, rpc_hex, MAX_PROOF_BYTES, PROOF_MAGIC}; use futures::future::join_all; use serde_json::{json, Value}; use std::collections::VecDeque; @@ -825,14 +1089,31 @@ mod tests { sources: Vec, timing: Timing, ) -> VerifiedHttpContextProvider { - VerifiedHttpContextProvider::build( - f.network, - f.checkpoint.clone(), + starting_from( + f, + StartingPoint::Checkpoint(f.checkpoint.clone()), sources, - cache_size(), timing, ) - .expect("a provider") + } + + fn starting_from( + f: &Fixture, + start: StartingPoint, + sources: Vec, + timing: Timing, + ) -> VerifiedHttpContextProvider { + VerifiedHttpContextProvider::build(f.network, start, sources, cache_size(), timing) + .expect("a provider") + } + + /// The fixture's checkpoint moved to `height`, standing for a state a + /// proof verified there. + fn state_at(f: &Fixture, height: u32) -> State { + let mut state = f.checkpoint.clone(); + state.height = height; + state.block_hash[..4].copy_from_slice(&height.to_le_bytes()); + state } async fn fetch( @@ -911,6 +1192,724 @@ mod tests { } } + /// Persisting the newest verified state as the next checkpoint strands + /// the next session: that state is the target of the proof Platform's + /// height needed, and a proof starts from a state strictly below the + /// height it serves, so the same key at the same height cannot be proven + /// again and no relay is even asked. + #[tokio::test] + async fn should_not_prove_a_key_at_the_height_of_its_only_state() { + let f = fixture(Network::Testnet); + let relay = MockRelay::spawn(vec![reply(200, f.bootstrap.clone())]); + let provider = starting_from( + &f, + StartingPoint::Checkpoint(f.target.clone()), + vec![relay.url.clone()], + quick(), + ); + + let result = fetch(&provider, f.quorum_type, f.quorum_hash, f.target.height).await; + + let reason = unavailable_reason(result); + assert!(reason.contains("no verified state is below"), "{reason}"); + assert!(relay.requests().is_empty()); + } + + /// The next session starts from the continuation, so it proves the key it + /// proved last time at the same height, through the relay, from the + /// anchor below that height; and it keeps the newer states. + #[tokio::test] + async fn should_prove_a_key_at_its_height_again_after_resuming() { + let f = fixture(Network::Testnet); + let height = f.target.height; + let first = MockRelay::spawn(vec![reply(200, f.bootstrap.clone())]); + let provider = provider_for(&f, vec![first.url.clone()], quick()); + let key = fetch(&provider, f.quorum_type, f.quorum_hash, height).await; + assert_eq!(key.expect("a proven key"), Some(f.key)); + provider.core_height_verified(f.quorum_type, f.quorum_hash, height); + + let continuation = provider.continuation(); + // A response at the target verified; the checkpoint is the only + // state below it. + assert_eq!( + continuation, + Continuation { + anchor: f.checkpoint.clone(), + states: vec![f.target.clone()], + } + ); + let saved = serde_json::to_string(&continuation).expect("serializes"); + let restored: Continuation = serde_json::from_str(&saved).expect("parses"); + assert_eq!(restored, continuation); + + let second = MockRelay::spawn(vec![reply(200, f.bootstrap.clone())]); + let resumed = starting_from( + &f, + StartingPoint::Continuation(restored), + vec![second.url.clone()], + quick(), + ); + assert_eq!(resumed.continuation(), continuation); + assert_eq!(resumed.latest_verified_state(), f.target); + + let key = fetch(&resumed, f.quorum_type, f.quorum_hash, height).await; + + assert_eq!(key.expect("proven again"), Some(f.key)); + assert_eq!( + second.requests(), + [json!({ + "checkpoint": rpc_hex(&f.checkpoint.block_hash), + "height": height, + "quorumHash": hex::encode(f.quorum_hash), + "llmqType": f.quorum_type, + "nodeCount": 0, + })] + ); + } + + /// A resumed session proves a height below every state it resumed with + /// from the continuation's anchor, which need not be the checkpoint the + /// earlier session started from. + #[tokio::test] + async fn should_prove_a_height_below_the_resumed_states_from_the_anchor() { + let f = fixture(Network::Testnet); + let earlier = earlier_fixture(); + let relay = MockRelay::spawn(vec![reply(200, earlier.bootstrap.clone())]); + let resumed = starting_from( + &f, + StartingPoint::Continuation(Continuation { + anchor: earlier.checkpoint.clone(), + states: vec![f.target.clone()], + }), + vec![relay.url.clone()], + quick(), + ); + assert!(earlier.target.height < f.target.height); + + let key = fetch( + &resumed, + f.quorum_type, + f.quorum_hash, + earlier.target.height, + ) + .await; + + assert_eq!(key.expect("proven from the anchor"), Some(f.key)); + assert_eq!( + relay.requests()[0]["checkpoint"], + rpc_hex(&earlier.checkpoint.block_hash).as_str() + ); + } + + /// A resumed session starts a proof from the newest state of the + /// continuation below the height, not from the anchor, so its proofs stay + /// as short as the last session's. + #[tokio::test] + async fn should_start_proofs_from_the_newest_resumed_state_below_the_height() { + let f = fixture(Network::Testnet); + let relay = MockRelay::spawn(vec![reply(502, Vec::new()), reply(502, Vec::new())]); + let resumed = starting_from( + &f, + StartingPoint::Continuation(Continuation { + anchor: f.checkpoint.clone(), + states: vec![f.target.clone()], + }), + vec![relay.url.clone()], + quick(), + ); + + let height = f.target.height + 1; + let _ = fetch(&resumed, f.quorum_type, f.quorum_hash, height).await; + + let requests = relay.requests(); + assert!(!requests.is_empty()); + assert_eq!( + requests[0]["checkpoint"], + rpc_hex(&f.target.block_hash).as_str() + ); + assert_eq!(requests[0]["height"], height); + } + + /// A relay may first answer with a valid target above the height asked + /// for, which is kept as evidence but cannot serve the height, and then + /// serve the height with a lower target. The anchor stays below the + /// height of the verified response, whatever was verified above it, so + /// the next session proves the key at that height again through the + /// relay. + #[tokio::test] + async fn should_prove_a_verified_height_again_after_a_target_above_it_was_verified() { + let f = fixture(Network::Testnet); + let height = f.target.height; + let above = state_at(&f, height + 300); + let first = MockRelay::spawn(vec![reply(200, f.bootstrap.clone())]); + let provider = provider_for(&f, vec![first.url.clone()], quick()); + // Stands in for the relay's first answer: a valid proof whose target + // is 300 blocks above the height, which opens the quorum there only. + prove_at(&provider, &f, &above); + assert!(provider + .get_quorum_public_key(f.quorum_type, f.quorum_hash, height) + .is_err()); + let key = fetch(&provider, f.quorum_type, f.quorum_hash, height).await; + assert_eq!(key.expect("served by the lower target"), Some(f.key)); + assert_eq!(first.heights(), [json!(height)]); + provider.core_height_verified(f.quorum_type, f.quorum_hash, height); + + let continuation = provider.continuation(); + assert_eq!( + continuation, + Continuation { + anchor: f.checkpoint.clone(), + states: vec![f.target.clone(), above], + } + ); + + let saved = serde_json::to_string(&continuation).expect("serializes"); + let restored: Continuation = serde_json::from_str(&saved).expect("parses"); + let second = MockRelay::spawn(vec![reply(200, f.bootstrap.clone())]); + let resumed = starting_from( + &f, + StartingPoint::Continuation(restored), + vec![second.url.clone()], + quick(), + ); + let key = fetch(&resumed, f.quorum_type, f.quorum_hash, height).await; + + assert_eq!(key.expect("proven again"), Some(f.key)); + assert_eq!( + second.requests()[0]["checkpoint"], + rpc_hex(&f.checkpoint.block_hash).as_str() + ); + } + + /// The anchor and states of `provider`'s continuation, as heights above + /// `base`. + fn continuation_heights(provider: &VerifiedHttpContextProvider, base: u32) -> (u32, Vec) { + let continuation = provider.continuation(); + ( + continuation.anchor.height - base, + continuation + .states + .iter() + .map(|state| state.height - base) + .collect(), + ) + } + + /// Serve the fixture's key at each of `heights` above `base`, as the SDK + /// looks it up for a response at that height. + fn serve_at(provider: &VerifiedHttpContextProvider, f: &Fixture, base: u32, heights: &[u32]) { + for height in heights { + let key = provider.get_quorum_public_key(f.quorum_type, f.quorum_hash, base + height); + assert_eq!(key.expect("served"), f.key, "{height}"); + } + } + + /// Serve the fixture's key at each of `heights` above `base` and report + /// each verified, as the SDK does for a response whose signature + /// verified. + fn verify_at(provider: &VerifiedHttpContextProvider, f: &Fixture, base: u32, heights: &[u32]) { + serve_at(provider, f, base, heights); + for height in heights { + provider.core_height_verified(f.quorum_type, f.quorum_hash, base + height); + } + } + + /// The anchor is the newest kept state strictly below one DKG interval + /// under the lowest Core height verified, so a key served at that height + /// from evidence up to one interval below it can be proven again. + #[test] + fn should_anchor_one_dkg_interval_below_the_lowest_verified_core_height() { + let f = fixture(Network::Testnet); + let base = f.checkpoint.height; + let continuation_after = |states: &[u32], verified: &[u32]| { + let provider = provider_for(&f, vec!["https://relay.example".to_string()], quick()); + for height in states { + prove_at(&provider, &f, &state_at(&f, base + height)); + } + verify_at(&provider, &f, base, verified); + continuation_heights(&provider, base) + }; + let grace = DKG_INTERVAL_BLOCKS; + + // Nothing verified yet: the checkpoint alone. + assert_eq!(continuation_after(&[], &[]), (0, vec![])); + // No response verified: the checkpoint anchors, however far the + // states reach above it. + assert_eq!( + continuation_after(&[100, 500, 900], &[]), + (0, vec![100, 500, 900]) + ); + // States C < L = H - 20 < T = H - 10 and a response verified at H: + // the key may have been served at H from L alone, its quorum retired + // before T, so C anchors and L and T are kept. Anchoring below H + // would anchor on T and drop C and L. + assert_eq!( + continuation_after(&[100, 480, 490], &[500]), + (100, vec![480, 490]) + ); + // A state exactly one interval below the height is not below the + // margin; one a block lower is. + assert_eq!( + continuation_after(&[100, 500 - grace, 490], &[500]), + (100, vec![500 - grace, 490]) + ); + assert_eq!( + continuation_after(&[100, 500 - grace - 1, 490], &[500]), + (500 - grace - 1, vec![490]) + ); + // The states above the anchor follow, lowest first. + assert_eq!( + continuation_after(&[100, 500, 900], &[530]), + (500, vec![900]) + ); + // The lowest height verified counts, whatever order they came in. + assert_eq!( + continuation_after(&[100, 500, 900], &[920, 530, 915]), + (500, vec![900]) + ); + // No state but the checkpoint below the margin: the checkpoint + // anchors. + assert_eq!( + continuation_after(&[100, 500, 900], &[110]), + (0, vec![100, 500, 900]) + ); + // Of the states kept, below the ones evicted. + let dense: Vec = (1..=MAX_RECENT_STATES as u32 + 4).map(|i| i * 10).collect(); + let kept = dense[4..].to_vec(); + let newest = *dense.last().expect("states"); + assert_eq!( + continuation_after(&dense, &[newest + grace]), + (newest - 10, vec![newest]) + ); + assert_eq!(continuation_after(&dense, &[kept[0]]), (0, kept)); + } + + /// Serving a key is not progress: the anchor moves only once a response + /// at a height is reported verified, and then to the newest state + /// strictly below one DKG interval under the lowest such height. + #[test] + fn should_move_the_anchor_only_for_core_heights_reported_verified() { + let f = fixture(Network::Testnet); + let base = f.checkpoint.height; + let provider = provider_for(&f, vec!["https://relay.example".to_string()], quick()); + for height in [100, 500, 900] { + prove_at(&provider, &f, &state_at(&f, base + height)); + } + + let verified = |height: u32| { + provider.core_height_verified(f.quorum_type, f.quorum_hash, base + height); + }; + serve_at(&provider, &f, base, &[530, 920]); + assert_eq!( + continuation_heights(&provider, base), + (0, vec![100, 500, 900]) + ); + + verified(920); + assert_eq!(continuation_heights(&provider, base), (500, vec![900])); + verified(530); + assert_eq!(continuation_heights(&provider, base), (500, vec![900])); + verified(510); + assert_eq!(continuation_heights(&provider, base), (100, vec![500, 900])); + // A higher height verified later never moves it back up. + verified(915); + assert_eq!(continuation_heights(&provider, base), (100, vec![500, 900])); + } + + /// From a checkpoint `C` below a state at `H`, with Platform at `H`, a + /// forged response names `H + 1`. Asked for `H + 1`, the relay answers + /// with a proof to `H`, which ends below it; asked for the lower target, + /// it answers with the same proof, which serves `H + 1` within the + /// grace. The key is fetched and then looked up for `H + 1`. The forged + /// response then fails verification, so nothing is reported verified + /// (that the verifier reports only verified heights is tested in + /// `drive-proof-verifier`). The continuation keeps `C` as the anchor, + /// and after a restore the honest response at `H` is proven through the + /// relay from `C`. Anchoring on the heights served would have anchored + /// on `H` and dropped `C`, and no proof could have served `H` until + /// Platform passed it. + #[tokio::test] + async fn should_keep_the_anchor_below_platform_when_a_forged_height_fails_verification() { + let f = fixture(Network::Testnet); + let platform = f.target.height; + let forged = platform + 1; + let first = MockRelay::spawn(vec![ + reply(200, f.bootstrap.clone()), + reply(200, f.bootstrap.clone()), + ]); + let provider = provider_for(&f, vec![first.url.clone()], quick()); + + let key = fetch(&provider, f.quorum_type, f.quorum_hash, forged).await; + assert_eq!(key.expect("served within the grace"), Some(f.key)); + assert_eq!( + first.heights(), + [json!(forged), json!(forged - DKG_INTERVAL_BLOCKS)] + ); + let cached = provider.get_quorum_public_key(f.quorum_type, f.quorum_hash, forged); + assert_eq!(cached.expect("served from the cache"), f.key); + assert_eq!(provider.latest_verified_state(), f.target); + + let continuation = provider.continuation(); + assert_eq!( + continuation, + Continuation { + anchor: f.checkpoint.clone(), + states: vec![f.target.clone()], + } + ); + + let saved = serde_json::to_string(&continuation).expect("serializes"); + let restored: Continuation = serde_json::from_str(&saved).expect("parses"); + let second = MockRelay::spawn(vec![reply(200, f.bootstrap.clone())]); + let resumed = starting_from( + &f, + StartingPoint::Continuation(restored), + vec![second.url.clone()], + quick(), + ); + let key = fetch(&resumed, f.quorum_type, f.quorum_hash, platform).await; + + assert_eq!(key.expect("proven from the anchor"), Some(f.key)); + assert_eq!( + second.requests()[0]["checkpoint"], + rpc_hex(&f.checkpoint.block_hash).as_str() + ); + } + + /// A resumed session keeps the anchor it started from until a response + /// verifies, then moves it up to below one DKG interval under the lowest + /// height verified. + #[test] + fn should_advance_the_anchor_of_a_resumed_session_past_the_one_it_started_from() { + let f = fixture(Network::Testnet); + let base = f.checkpoint.height; + let sources = || vec!["https://relay.example".to_string()]; + let first = provider_for(&f, sources(), quick()); + for height in [100, 500, 900] { + prove_at(&first, &f, &state_at(&f, base + height)); + } + verify_at(&first, &f, base, &[530]); + let continuation = first.continuation(); + assert_eq!(continuation_heights(&first, base), (500, vec![900])); + + let resumed = starting_from( + &f, + StartingPoint::Continuation(continuation.clone()), + sources(), + quick(), + ); + assert_eq!(resumed.continuation(), continuation); + for height in [1000, 1300] { + prove_at(&resumed, &f, &state_at(&f, base + height)); + } + serve_at(&resumed, &f, base, &[1250, 1310]); + assert_eq!( + continuation_heights(&resumed, base), + (500, vec![900, 1000, 1300]) + ); + resumed.core_height_verified(f.quorum_type, f.quorum_hash, base + 1310); + resumed.core_height_verified(f.quorum_type, f.quorum_hash, base + 1250); + + assert_eq!(continuation_heights(&resumed, base), (1000, vec![1300])); + } + + /// Only a height verified with a key this provider's cache serves for + /// it counts: a notification for another provider's quorum, or for a + /// height the cached key does not serve, leaves the anchor alone. + #[test] + fn should_count_only_heights_its_own_cache_serves_the_quorum_for() { + let f = fixture(Network::Testnet); + let base = f.checkpoint.height; + let provider = provider_for(&f, vec!["https://relay.example".to_string()], quick()); + for height in [100, 500, 900] { + prove_at(&provider, &f, &state_at(&f, base + height)); + } + let last_served = 900 + DKG_INTERVAL_BLOCKS; + + provider.core_height_verified(f.quorum_type, [0xee; 32], base + 530); + provider.core_height_verified(f.quorum_type + 1, f.quorum_hash, base + 530); + provider.core_height_verified(f.quorum_type, f.quorum_hash, base + last_served + 1); + assert_eq!( + continuation_heights(&provider, base), + (0, vec![100, 500, 900]) + ); + + provider.core_height_verified(f.quorum_type, f.quorum_hash, base + last_served); + assert_eq!(continuation_heights(&provider, base), (500, vec![900])); + provider.core_height_verified(f.quorum_type, f.quorum_hash, base + 510); + assert_eq!(continuation_heights(&provider, base), (100, vec![500, 900])); + } + + /// Kept states C < L = H - 20 < T = H - 10, with the key of quorum Q + /// proven at L only (a real proof from C) and T verified for another + /// quorum after Q retired. A response at H verifies with Q's key, served + /// within one DKG interval of L. After a restore Q is proven again at H + /// through the relay: asked from T, the relay answers that Q is not in + /// the target root, as for a retired quorum; asked for a target one + /// interval below H, from C, it answers with the real proof to L. + /// Anchoring strictly below H would have anchored on T and dropped C and + /// L, and the restored session could not reach Q at all. + #[tokio::test] + async fn should_prove_a_key_served_within_the_grace_again_after_resuming() { + let f = fixture(Network::Testnet); + let low = f.target.clone(); + let height = low.height + 20; + let later = state_at(&f, low.height + 10); + let retired = || reply(502, b"Requested quorum is not in the target root".to_vec()); + let first = MockRelay::spawn(vec![retired(), reply(200, f.bootstrap.clone())]); + let provider = provider_for(&f, vec![first.url.clone()], quick()); + let key = fetch(&provider, f.quorum_type, f.quorum_hash, height).await; + assert_eq!(key.expect("proven at L"), Some(f.key)); + // Stands in for a proof of another quorum, to T, after Q retired. + provider + .inner + .fetches + .publish((f.quorum_type, [0xee; 32]), [0xee; 48], &later) + .expect("another quorum's evidence"); + let served = provider.get_quorum_public_key(f.quorum_type, f.quorum_hash, height); + assert_eq!(served.expect("served from L within the grace"), f.key); + provider.core_height_verified(f.quorum_type, f.quorum_hash, height); + + let continuation = provider.continuation(); + assert_eq!( + continuation, + Continuation { + anchor: f.checkpoint.clone(), + states: vec![low, later.clone()], + } + ); + + let saved = serde_json::to_string(&continuation).expect("serializes"); + let restored: Continuation = serde_json::from_str(&saved).expect("parses"); + let second = MockRelay::spawn(vec![retired(), reply(200, f.bootstrap.clone())]); + let resumed = starting_from( + &f, + StartingPoint::Continuation(restored), + vec![second.url.clone()], + quick(), + ); + let key = fetch(&resumed, f.quorum_type, f.quorum_hash, height).await; + + assert_eq!(key.expect("proven again from C"), Some(f.key)); + let requests = second.requests(); + assert_eq!(requests.len(), 2); + assert_eq!( + requests[0]["checkpoint"], + rpc_hex(&later.block_hash).as_str() + ); + assert_eq!(requests[0]["height"], height); + assert_eq!( + requests[1]["checkpoint"], + rpc_hex(&f.checkpoint.block_hash).as_str() + ); + assert_eq!(requests[1]["height"], height - DKG_INTERVAL_BLOCKS); + } + + /// `f` with its checkpoint relabelled to `height`, in the checkpoint and + /// in the proof's anchor. Of a proof without links the anchor's height + /// only bounds the target from below, and a checkpoint's height is + /// trusted, not proven, so the proof still verifies from it. + fn checkpoint_relabelled(mut f: Fixture, height: u32) -> Fixture { + let at = f + .bootstrap + .windows(PROOF_MAGIC.len()) + .position(|window| window == PROOF_MAGIC) + .expect("a proof chain") + + PROOF_MAGIC.len() + + 1; + let old = f.checkpoint.height.to_le_bytes(); + assert_eq!(f.bootstrap[at..at + 4], old, "the anchor's height"); + f.bootstrap[at..at + 4].copy_from_slice(&height.to_le_bytes()); + f.checkpoint.height = height; + bootstrap::verify(&f.bootstrap, &f.checkpoint, 0).expect("verifies from it"); + f + } + + /// A custom checkpoint C = H - 23, inside the grace of a response at H. + /// Q is proven at L = H - 20 from C through the lower ask, clipped to + /// C + 1, a real proof without links; T = H - 10 is verified for another + /// quorum after Q retired, and the response at H verifies. After a + /// restore no state is below H - 24, so the lower ask goes from C, the + /// oldest state below H, and reaches Q again. Going from the newest + /// state below H, it would have asked from T for T + 1, after Q + /// retired, and never reached Q. + #[tokio::test] + async fn should_prove_a_key_again_from_a_recent_checkpoint_after_resuming() { + let real = earlier_fixture(); + let low = real.target.clone(); + let f = checkpoint_relabelled(real, low.height - 3); + let checkpoint = f.checkpoint.height; + let height = checkpoint + 23; + assert_eq!(low.height, height - 20); + let later = state_at(&f, height - 10); + let retired = || reply(502, b"Requested quorum is not in the target root".to_vec()); + let first = MockRelay::spawn(vec![retired(), reply(200, f.bootstrap.clone())]); + let provider = provider_for(&f, vec![first.url.clone()], quick()); + let key = fetch(&provider, f.quorum_type, f.quorum_hash, height).await; + assert_eq!(key.expect("proven at L"), Some(f.key)); + assert_eq!(first.heights(), [json!(height), json!(checkpoint + 1)]); + // Stands in for a proof of another quorum, to T, after Q retired. + provider + .inner + .fetches + .publish((f.quorum_type, [0xee; 32]), [0xee; 48], &later) + .expect("another quorum's evidence"); + let served = provider.get_quorum_public_key(f.quorum_type, f.quorum_hash, height); + assert_eq!(served.expect("served from L within the grace"), f.key); + provider.core_height_verified(f.quorum_type, f.quorum_hash, height); + + let continuation = provider.continuation(); + assert_eq!( + continuation, + Continuation { + anchor: f.checkpoint.clone(), + states: vec![low, later.clone()], + } + ); + + let saved = serde_json::to_string(&continuation).expect("serializes"); + let restored: Continuation = serde_json::from_str(&saved).expect("parses"); + let second = MockRelay::spawn(vec![retired(), reply(200, f.bootstrap.clone())]); + let resumed = starting_from( + &f, + StartingPoint::Continuation(restored), + vec![second.url.clone()], + quick(), + ); + let key = fetch(&resumed, f.quorum_type, f.quorum_hash, height).await; + + let requests = second.requests(); + assert_eq!(requests.len(), 2); + assert_eq!( + requests[0]["checkpoint"], + rpc_hex(&later.block_hash).as_str() + ); + assert_eq!(requests[0]["height"], height); + assert_eq!( + requests[1]["checkpoint"], + rpc_hex(&f.checkpoint.block_hash).as_str() + ); + assert_eq!(requests[1]["height"], checkpoint + 1); + assert_eq!(key.expect("proven again from C"), Some(f.key)); + } + + #[test] + fn should_refuse_a_continuation_it_cannot_start_from() { + let f = fixture(Network::Testnet); + let resume = |anchor: State, states: Vec| { + VerifiedHttpContextProvider::with_options( + Network::Testnet, + VerifiedProviderOptions { + start: Some(StartingPoint::Continuation(Continuation { anchor, states })), + sources: Some(vec!["https://relay.example".to_string()]), + cache_size: None, + }, + ) + }; + let base = f.checkpoint.height; + let mainnet = fixture(Network::Mainnet).target; + let mut empty = state_at(&f, base + 20); + empty.quorum_root = [0; 32]; + for (anchor, states, why) in [ + (mainnet.clone(), vec![], "an anchor for another network"), + ( + f.checkpoint.clone(), + vec![mainnet], + "a state for another network", + ), + ( + f.checkpoint.clone(), + vec![state_at(&f, base - 1)], + "a state below the anchor", + ), + ( + f.checkpoint.clone(), + vec![f.checkpoint.clone()], + "a state at the anchor", + ), + ( + f.checkpoint.clone(), + vec![state_at(&f, base + 10), state_at(&f, base + 10)], + "two states at one height", + ), + ( + f.checkpoint.clone(), + vec![state_at(&f, base + 20), state_at(&f, base + 10)], + "states out of order", + ), + (f.checkpoint.clone(), vec![empty], "an invalid state"), + ] { + assert!( + matches!( + resume(anchor, states), + Err(VerifiedContextProviderError::InvalidCheckpoint(_)) + ), + "{why}" + ); + } + + // Only the newest states a provider keeps are taken. + let many = (1..=MAX_RECENT_STATES as u32 + 4) + .map(|step| state_at(&f, base + step * 10)) + .collect(); + let resumed = resume(f.checkpoint.clone(), many).expect("resumes"); + let kept: Vec<_> = resumed + .inner + .fetches + .states() + .iter() + .map(|state| state.height - base) + .collect(); + assert_eq!(kept.len(), MAX_RECENT_STATES + 1); + assert_eq!(kept[0], 0); + assert_eq!(kept[1], 50); + assert_eq!( + *kept.last().expect("states"), + (MAX_RECENT_STATES as u32 + 4) * 10 + ); + } + + #[test] + fn should_take_defaults_for_the_options_it_is_not_given() { + let provider = + VerifiedHttpContextProvider::with_options(Network::Testnet, Default::default()) + .expect("a testnet provider from defaults alone"); + assert_eq!( + *provider.checkpoint(), + VerifiedHttpContextProvider::embedded_checkpoint(Network::Testnet).expect("embedded") + ); + assert_eq!( + provider.sources()[0].as_str(), + "https://quorums.testnet.networks.dash.org/" + ); + assert_eq!(DEFAULT_QUORUM_KEY_CACHE_SIZE.get(), 32); + assert_eq!(ContextProvider::network(&provider), Some(Network::Testnet)); + assert!(provider.requires_proofs()); + + let f = fixture(Network::Testnet); + let overridden = VerifiedHttpContextProvider::with_options( + Network::Testnet, + VerifiedProviderOptions { + start: Some(StartingPoint::Checkpoint(f.checkpoint.clone())), + sources: Some(vec!["https://relay.example".to_string()]), + cache_size: None, + }, + ) + .expect("overrides"); + assert_eq!(*overridden.checkpoint(), f.checkpoint); + assert_eq!(overridden.sources()[0].as_str(), "https://relay.example/"); + assert!(matches!( + VerifiedHttpContextProvider::with_options( + Network::Testnet, + VerifiedProviderOptions { + cache_size: NonZeroUsize::new(23), + ..Default::default() + }, + ), + Err(VerifiedContextProviderError::CacheTooSmall { .. }) + )); + } + #[test] fn should_open_the_mainnet_platform_quorum_named_in_rpc_order() { let f = fixture(Network::Mainnet); diff --git a/packages/rs-sdk-trusted-context-provider/tests/wasm_proof_verification.rs b/packages/rs-sdk-trusted-context-provider/tests/wasm_proof_verification.rs new file mode 100644 index 00000000000..152c615d632 --- /dev/null +++ b/packages/rs-sdk-trusted-context-provider/tests/wasm_proof_verification.rs @@ -0,0 +1,49 @@ +//! The fixture proofs verify on wasm32, the target the browser SDK runs on, +//! and how long `bootstrap::verify` takes there is logged, since that time +//! runs on the browser's event loop. +#![cfg(target_arch = "wasm32")] + +use dash_core_proof::{bootstrap, State}; +use serde_json::Value; +use wasm_bindgen_test::{console_log, wasm_bindgen_test}; +use web_time::Instant; + +/// How many times each proof is verified, so one slow run does not decide the +/// figure. +const RUNS: u32 = 5; + +#[wasm_bindgen_test] +fn should_verify_the_fixture_proofs_on_wasm_and_log_the_time() { + // The chain lengths are those of the fixtures Core built: 4 links over + // 2,000 testnet blocks, 2 over 1,500 mainnet blocks. + for (name, json, links) in [ + ("testnet", include_str!("data/proof-testnet.json"), 4), + ("mainnet", include_str!("data/proof-mainnet.json"), 2), + ] { + let fixture: Value = serde_json::from_str(json).expect("fixture JSON"); + let state = |key: &str| serde_json::from_value::(fixture[key].clone()).expect(key); + let checkpoint = state("checkpoint"); + let target = state("target"); + let proof = hex::decode(fixture["bootstrap_hex"].as_str().expect("bootstrap_hex")) + .expect("bootstrap hex"); + + let mut fastest = f64::MAX; + let mut total = 0.0; + for _ in 0..RUNS { + let started = Instant::now(); + let verified = bootstrap::verify(&proof, &checkpoint, target.height) + .expect("the fixture proof verifies"); + let ms = started.elapsed().as_secs_f64() * 1000.0; + assert_eq!(verified.state(), &target, "{name}"); + fastest = fastest.min(ms); + total += ms; + } + let mean = total / f64::from(RUNS); + console_log!( + "{name}: {} byte proof, {links} links: fastest {fastest:.1} ms ({:.1} ms per link), \ + mean {mean:.1} ms over {RUNS} runs", + proof.len(), + fastest / f64::from(links), + ); + } +} diff --git a/packages/rs-sdk/Cargo.toml b/packages/rs-sdk/Cargo.toml index e7b2f8d2ad3..15641ca1472 100644 --- a/packages/rs-sdk/Cargo.toml +++ b/packages/rs-sdk/Cargo.toml @@ -63,6 +63,7 @@ platform-version = { path = "../rs-platform-version", features = [ rs-dapi-client = { path = "../rs-dapi-client" } drive-proof-verifier = { path = "../rs-drive-proof-verifier" } rs-sdk-trusted-context-provider = { path = "../rs-sdk-trusted-context-provider" } +dash-core-proof = { path = "../rs-core-proof" } tokio = { version = "1.40", features = ["macros", "rt-multi-thread", "test-util"] } base64 = { version = "0.22.1" } tracing-subscriber = { version = "0.3.22", features = ["env-filter", "registry"] } diff --git a/packages/rs-sdk/examples/read_contract.rs b/packages/rs-sdk/examples/read_contract.rs index d2cbae8dcd9..38601a7fd96 100644 --- a/packages/rs-sdk/examples/read_contract.rs +++ b/packages/rs-sdk/examples/read_contract.rs @@ -94,7 +94,8 @@ fn setup_sdk(config: &Config) -> Sdk { // Reconfigure context provider with Sdk context_provider.set_sdk(Some(sdk.clone())); - sdk.set_context_provider(context_provider); + sdk.set_context_provider(context_provider) + .expect("the context provider fits the SDK"); // Return the SDK we created sdk } diff --git a/packages/rs-sdk/src/sdk.rs b/packages/rs-sdk/src/sdk.rs index 91e5e4896d5..eadbab8b165 100644 --- a/packages/rs-sdk/src/sdk.rs +++ b/packages/rs-sdk/src/sdk.rs @@ -61,6 +61,8 @@ mod protocol_gate_tests; mod quorum_key; #[cfg(all(test, feature = "mocks"))] mod signed_version_tests; +#[cfg(all(test, feature = "mocks"))] +mod verified_provider_tests; /// A local view of the authenticated network observation and this SDK's version tables. /// This is informational: a newer network version does not disable operations. @@ -1013,9 +1015,21 @@ impl Sdk { /// [ContextProvider] is used to access state information, like data contracts and quorum public keys. /// /// Note that this will overwrite any previous context provider. - pub fn set_context_provider(&self, context_provider: C) { + /// + /// # Errors + /// + /// [`Error::Config`], leaving the current provider in place, when the + /// provider serves another network than this SDK's, or requires proofs + /// and this SDK has them turned off (see [`ContextProvider::network`] + /// and [`ContextProvider::requires_proofs`]). + pub fn set_context_provider( + &self, + context_provider: C, + ) -> Result<(), Error> { + check_context_provider(&context_provider, self.network, self.proofs)?; self.context_provider .swap(Some(Arc::new(Box::new(context_provider)))); + Ok(()) } /// Returns a future that resolves when the Sdk is cancelled (e.g. shutdown was requested). @@ -1575,6 +1589,9 @@ impl SdkBuilder { /// /// This method will return an error if the Sdk cannot be created. pub fn build(self) -> Result { + if let Some(context_provider) = &self.context_provider { + check_context_provider(context_provider.as_ref(), self.network, self.proofs)?; + } let is_network_sdk = self.addresses.is_some(); let has_height_anchor = self .trusted_initial_height @@ -1734,6 +1751,29 @@ impl SdkBuilder { } } +/// Refuse a context provider whose requirements an SDK on `network`, with +/// proofs on or off, cannot meet. +fn check_context_provider( + context_provider: &dyn ContextProvider, + network: Network, + proofs: bool, +) -> Result<(), Error> { + if let Some(provider_network) = context_provider.network() { + if provider_network != network { + return Err(Error::Config(format!( + "the context provider serves {provider_network:?} quorums, but the SDK targets {network:?}" + ))); + } + } + if !proofs && context_provider.requires_proofs() { + return Err(Error::Config( + "the context provider requires proofs, but they are turned off with with_proofs(false)" + .to_string(), + )); + } + Ok(()) +} + pub fn prettify_proof(proof: &Proof) -> String { let config = PLATFORM_BINCODE_CONFIG; let grovedb_proof: Result = @@ -1775,6 +1815,10 @@ pub(crate) mod test { use crate::SdkBuilder; use super::{min_protocol_version, Network}; + use dash_context_provider::{ContextProvider, ContextProviderError}; + use dpp::data_contract::TokenConfiguration; + use dpp::prelude::{CoreBlockHeight, DataContract, Identifier}; + use dpp::version::PlatformVersion; #[test_matrix(["get_addresses_infos", "get_address_info"], [Some(1), Some(20)])] #[tokio::test] @@ -2099,6 +2143,149 @@ pub(crate) mod test { ); } + /// A provider that states requirements and serves nothing. + struct Requiring { + network: Option, + proofs: bool, + } + + impl ContextProvider for Requiring { + fn get_data_contract( + &self, + _id: &Identifier, + _platform_version: &PlatformVersion, + ) -> Result>, ContextProviderError> { + Ok(None) + } + + fn get_token_configuration( + &self, + _token_id: &Identifier, + ) -> Result, ContextProviderError> { + Ok(None) + } + + fn get_quorum_public_key( + &self, + _quorum_type: u32, + _quorum_hash: [u8; 32], + _core_chain_locked_height: u32, + ) -> Result<[u8; 48], ContextProviderError> { + Err(ContextProviderError::Config("serves nothing".to_string())) + } + + fn get_platform_activation_height(&self) -> Result { + Ok(1) + } + + fn network(&self) -> Option { + self.network + } + + fn requires_proofs(&self) -> bool { + self.proofs + } + } + + fn is_config_error(error: crate::Error, needle: &str) -> bool { + matches!(error, crate::Error::Config(message) if message.contains(needle)) + } + + /// A provider that requires proofs is refused with proofs turned off, + /// whichever of the two the caller configured first, and by + /// `set_context_provider` on an SDK built without proofs. + #[test] + fn should_refuse_a_provider_that_requires_proofs_with_proofs_off() { + let requiring = || Requiring { + network: Some(Network::Testnet), + proofs: true, + }; + let before = SdkBuilder::new_mock() + .with_network(Network::Testnet) + .with_proofs(false) + .with_context_provider(requiring()); + let after = SdkBuilder::new_mock() + .with_network(Network::Testnet) + .with_context_provider(requiring()) + .with_proofs(false); + for builder in [before, after] { + let error = builder.build().expect_err("refused"); + assert!(is_config_error(error, "requires proofs")); + } + + let unproved = SdkBuilder::new_mock() + .with_network(Network::Testnet) + .with_proofs(false) + .with_context_provider(Requiring { + network: Some(Network::Testnet), + proofs: false, + }) + .build() + .expect("an SDK without proofs"); + let error = unproved + .set_context_provider(requiring()) + .expect_err("refused"); + assert!(is_config_error(error, "requires proofs")); + assert!( + !unproved.context_provider().expect("kept").requires_proofs(), + "the refused provider replaced the one the SDK had" + ); + + SdkBuilder::new_mock() + .with_network(Network::Testnet) + .with_proofs(false) + .with_proofs(true) + .with_context_provider(requiring()) + .build() + .expect("proofs turned back on"); + SdkBuilder::new_mock() + .with_network(Network::Testnet) + .with_proofs(false) + .with_context_provider(Requiring { + network: None, + proofs: false, + }) + .build() + .expect("a provider that does not require proofs"); + } + + /// A provider that serves one network is refused by an SDK for another, + /// in the builder and in `set_context_provider`, which keeps the provider + /// it had. + #[test] + fn should_refuse_a_provider_for_another_network() { + let mainnet = || Requiring { + network: Some(Network::Mainnet), + proofs: false, + }; + let error = SdkBuilder::new_mock() + .with_network(Network::Testnet) + .with_context_provider(mainnet()) + .build() + .expect_err("refused"); + assert!(is_config_error(error, "Mainnet")); + + let sdk = SdkBuilder::new_mock() + .with_network(Network::Testnet) + .with_context_provider(Requiring { + network: Some(Network::Testnet), + proofs: false, + }) + .build() + .expect("the same network is accepted"); + let error = sdk.set_context_provider(mainnet()).expect_err("refused"); + assert!(is_config_error(error, "Mainnet")); + assert_eq!( + sdk.context_provider().expect("kept").network(), + Some(Network::Testnet) + ); + sdk.set_context_provider(Requiring { + network: None, + proofs: false, + }) + .expect("a provider that names no network"); + } + #[test] fn height_only_address_checkpoint_uses_trusted_height_floor() { let sdk = SdkBuilder::new_mock() diff --git a/packages/rs-sdk/src/sdk/quorum_key.rs b/packages/rs-sdk/src/sdk/quorum_key.rs index 6380c814d05..23217e27f75 100644 --- a/packages/rs-sdk/src/sdk/quorum_key.rs +++ b/packages/rs-sdk/src/sdk/quorum_key.rs @@ -510,7 +510,8 @@ mod tests { metadata.height + 10, )); } - sdk.set_context_provider(provider.clone()); + sdk.set_context_provider(provider.clone()) + .expect("the test provider states no requirements"); let settings = PutSettings { request_settings: RequestSettings { ban_failed_address: Some(true), diff --git a/packages/rs-sdk/src/sdk/verified_provider_tests.rs b/packages/rs-sdk/src/sdk/verified_provider_tests.rs new file mode 100644 index 00000000000..337adc3a00f --- /dev/null +++ b/packages/rs-sdk/src/sdk/verified_provider_tests.rs @@ -0,0 +1,277 @@ +//! A proved query verified end to end with a cold +//! [`VerifiedHttpContextProvider`]: the SDK proves the signing quorum's key +//! through the relay, verifies the response, and the provider counts the +//! response's Core height for its continuation. + +use super::Sdk; +use crate::SdkBuilder; +use dapi_grpc::platform::v0::{ + get_epochs_info_request, GetEpochsInfoRequest, GetEpochsInfoResponse, +}; +use dapi_grpc::platform::VersionedGrpcResponse; +use dapi_grpc::Message; +use dpp::dashcore::Network; +use dpp::version::PlatformVersion; +use drive_proof_verifier::types::ExtendedEpochInfos; +use drive_proof_verifier::FromProof; +use rs_dapi_client::AddressList; +use rs_sdk_trusted_context_provider::{ + Continuation, StartingPoint, VerifiedHttpContextProvider, VerifiedProviderOptions, +}; +use serde_json::Value; +use std::io::{BufRead, BufReader, Read, Write}; +use std::net::TcpListener; +use std::num::NonZeroUsize; +use std::path::PathBuf; +use std::sync::{Arc, Mutex}; +use std::thread; + +/// One DKG interval, in blocks: the margin a continuation's anchor keeps +/// below the lowest verified Core height. +const DKG_INTERVAL_BLOCKS: u32 = 24; + +fn fixture(name: &str) -> Value { + let path = PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("../rs-sdk-trusted-context-provider/tests/data") + .join(name); + serde_json::from_str(&std::fs::read_to_string(&path).expect("read fixture")) + .expect("fixture JSON") +} + +/// The testnet checkpoint at 1,574,768 the provider crate's proofs start +/// from. +fn checkpoint() -> dash_core_proof::State { + serde_json::from_value(fixture("proof-testnet.json")["checkpoint"].clone()) + .expect("a checkpoint") +} + +/// A captured, proved testnet `getEpochsInfo` response (epoch 0), its Core +/// height, and the relay's proof of its signing quorum from [`checkpoint`], +/// whose target is that height. +fn platform_fixture() -> (u32, GetEpochsInfoResponse, Vec) { + let value = fixture("platform-epochs-testnet.json"); + let bytes = |key: &str| hex::decode(value[key].as_str().expect("hex")).expect("hex"); + ( + value["core_chain_locked_height"] + .as_u64() + .expect("a height") + .try_into() + .expect("a Core height"), + GetEpochsInfoResponse::decode(bytes("response_hex").as_slice()).expect("a response"), + bytes("quorum_proof_hex"), + ) +} + +/// The request the fixture answers: the first epoch, ascending, proved. +fn epochs_request() -> GetEpochsInfoRequest { + GetEpochsInfoRequest { + version: Some(get_epochs_info_request::Version::V0( + get_epochs_info_request::GetEpochsInfoRequestV0 { + start_epoch: Some(0), + count: 1, + ascending: true, + prove: true, + }, + )), + } +} + +/// A relay on loopback answering every `/proofs` request with `proof` and +/// recording each request's JSON body. +fn relay(proof: Vec) -> (String, Arc>>) { + let listener = TcpListener::bind("127.0.0.1:0").expect("a loopback port"); + let address = listener.local_addr().expect("an address"); + let asked = Arc::new(Mutex::new(Vec::new())); + let recorded = Arc::clone(&asked); + thread::spawn(move || { + for stream in listener.incoming().flatten() { + let mut reader = BufReader::new(stream); + let mut line = String::new(); + if reader.read_line(&mut line).unwrap_or(0) == 0 { + continue; + } + let path = line.split_whitespace().nth(1).unwrap_or("").to_string(); + let mut length = 0; + loop { + let mut header = String::new(); + if reader.read_line(&mut header).unwrap_or(0) == 0 || header.trim().is_empty() { + break; + } + if let Some((name, value)) = header.split_once(':') { + if name.eq_ignore_ascii_case("content-length") { + length = value.trim().parse().unwrap_or(0); + } + } + } + let mut body = vec![0; length]; + if reader.read_exact(&mut body).is_err() { + continue; + } + let (status, answer) = if path == "/proofs" { + recorded + .lock() + .expect("relay log") + .push(serde_json::from_slice(&body).unwrap_or(Value::Null)); + (200, proof.clone()) + } else { + (404, Vec::new()) + }; + let mut stream = reader.into_inner(); + let _ = write!( + stream, + "HTTP/1.1 {status} X\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", + answer.len() + ); + let _ = stream.write_all(&answer); + } + }); + (format!("http://{address}"), asked) +} + +/// A network (not mock) testnet SDK whose proofs are verified with +/// `provider`, trusting Platform's block height from `response`: it was +/// captured long ago, so its signed time is not checked. +fn sdk_with(provider: &VerifiedHttpContextProvider, response: &GetEpochsInfoResponse) -> Sdk { + SdkBuilder::new(AddressList::new()) + .with_network(Network::Testnet) + .with_context_provider(provider.clone()) + .with_time_tolerance(None) + .with_height_tolerance(Some(1)) + .with_trusted_initial_height(response.metadata().expect("metadata").height) + .build() + .expect("a verified testnet SDK") +} + +/// Verify the fixture response the way every proved fetch does once the +/// transport returns it. +async fn verify_through_sdk(sdk: &Sdk, response: GetEpochsInfoResponse) -> ExtendedEpochInfos { + let (epochs, metadata, _) = sdk + .parse_proof_with_metadata_and_proof::( + epochs_request(), + response.clone(), + "get_epochs_info", + ) + .await + .expect("the response verifies once the quorum key is proven"); + assert_eq!( + metadata.core_chain_locked_height, + response + .metadata() + .expect("metadata") + .core_chain_locked_height + ); + epochs.expect("epoch 0 exists") +} + +/// The key of the quorum that signed the fixture is not cached. +fn assert_key_unavailable(provider: &VerifiedHttpContextProvider, response: GetEpochsInfoResponse) { + let version = PlatformVersion::get(response.metadata().expect("metadata").protocol_version) + .unwrap_or(PlatformVersion::latest()); + let error = ExtendedEpochInfos::maybe_from_proof_with_metadata( + epochs_request(), + response, + Network::Testnet, + version, + provider, + ) + .expect_err("a cold provider has no key"); + assert!( + matches!( + error, + drive_proof_verifier::Error::QuorumKeyUnavailable { .. } + ), + "got {error:?}" + ); +} + +/// A cold verified provider behind a network SDK: the first verification of +/// a captured testnet response misses the quorum key, the SDK has the +/// provider prove it through the relay and verifies the same response +/// again, and the verified Core height reaches the provider. A session +/// restored from its continuation proves the key again and verifies the +/// same response at the same height. +#[tokio::test] +async fn should_verify_a_proved_query_with_a_cold_verified_provider_and_resume_from_its_continuation( +) { + let (height, response, quorum_proof) = platform_fixture(); + let checkpoint = checkpoint(); + let cache_size = NonZeroUsize::new(32).expect("nonzero"); + + // First session, from the checkpoint. + let (url, asked) = relay(quorum_proof.clone()); + let provider = VerifiedHttpContextProvider::with_checkpoint( + Network::Testnet, + checkpoint.clone(), + vec![url], + cache_size, + ) + .expect("a provider"); + assert_key_unavailable(&provider, response.clone()); + assert_eq!(provider.lowest_verified_core_height(), None); + let sdk = sdk_with(&provider, &response); + + let epochs = verify_through_sdk(&sdk, response.clone()).await; + + assert_eq!(epochs.len(), 1, "epoch 0"); + { + let asked = asked.lock().expect("relay log"); + assert_eq!(asked.len(), 1, "one proof, for the quorum: {asked:?}"); + assert_eq!( + asked[0]["height"], height, + "asked for the response's height" + ); + } + assert_eq!(provider.cached_quorum_count(), 1); + // The verifier reported the response's Core height, after its signature + // verified, to the provider the SDK holds. + assert_eq!(provider.lowest_verified_core_height(), Some(height)); + let continuation = provider.continuation(); + assert!( + continuation.anchor.height < height - DKG_INTERVAL_BLOCKS, + "anchor {} is not more than one DKG interval below {height}", + continuation.anchor.height + ); + assert_eq!(continuation.anchor, checkpoint); + assert_eq!( + continuation + .states + .iter() + .map(|state| state.height) + .collect::>(), + vec![height], + "the proven target is kept above the anchor" + ); + + // Next session, restored from the continuation, its cache cold again. + let saved = serde_json::to_string(&continuation).expect("serialize the continuation"); + let restored: Continuation = serde_json::from_str(&saved).expect("restore it"); + let (url, asked) = relay(quorum_proof); + let resumed = VerifiedHttpContextProvider::with_options( + Network::Testnet, + VerifiedProviderOptions { + start: Some(StartingPoint::Continuation(restored)), + sources: Some(vec![url]), + cache_size: Some(cache_size), + }, + ) + .expect("a resumed provider"); + assert_eq!(resumed.checkpoint(), &checkpoint); + assert_key_unavailable(&resumed, response.clone()); + let sdk = sdk_with(&resumed, &response); + + let epochs = verify_through_sdk(&sdk, response).await; + + assert_eq!(epochs.len(), 1, "epoch 0"); + { + let asked = asked.lock().expect("relay log"); + assert_eq!(asked.len(), 1, "proved again: {asked:?}"); + assert_eq!(asked[0]["height"], height); + assert_eq!( + asked[0]["checkpoint"], + dash_core_proof::rpc_hex(&checkpoint.block_hash).as_str(), + "from the anchor, the only state below the height" + ); + } + assert_eq!(resumed.lowest_verified_core_height(), Some(height)); + assert_eq!(resumed.continuation(), continuation); +} diff --git a/packages/wasm-sdk/Cargo.toml b/packages/wasm-sdk/Cargo.toml index 090791898d1..c1fa12be466 100644 --- a/packages/wasm-sdk/Cargo.toml +++ b/packages/wasm-sdk/Cargo.toml @@ -124,6 +124,8 @@ wasm-dpp2 = { path = "../wasm-dpp2" } # and silently defeat the tests that assert an uncompiled one is fetched. wasm-sdk = { path = ".", features = ["mocks"] } tokio = { version = "1.40", features = ["macros", "rt-multi-thread"] } +# The verified context's tests verify fixture proofs and build checkpoints. +dash-core-proof = { path = "../rs-core-proof" } [target.'cfg(not(target_arch = "wasm32"))'.dev-dependencies] tempfile = "3.3.0" diff --git a/packages/wasm-sdk/README.md b/packages/wasm-sdk/README.md index 47a70756c34..9f7a86e0af8 100644 --- a/packages/wasm-sdk/README.md +++ b/packages/wasm-sdk/README.md @@ -80,6 +80,39 @@ await init(); const ok = sdk.validate_address('yXXX...', 'testnet'); ``` +### Quorum keys: trusted or verified + +Proof verification needs the public key of the Platform quorum that signed each response. A builder takes them from one of two contexts: + +- `WasmTrustedContext` (`await WasmTrustedContext.prefetchTestnet()`, then `builder.withTrustedContext(context)`) fetches the quorum lists from the network's quorum service and trusts what it returns. It works on every network, including devnets and local. +- `WasmVerifiedContext` (mainnet and testnet only) proves each key with a DIP 175 quorum proof from a checkpoint built into the SDK. The quorum service then only relays the proofs; a relay that lies can delay requests but cannot make the SDK accept a forged key. Nothing is fetched up front: a key is proven the first time a response signed by its quorum is verified. + +```js +const context = sdk.WasmVerifiedContext.testnet(); // or .mainnet(), optionally with relay URLs +const client = sdk.WasmSdkBuilder.testnet().withVerifiedContext(context).build(); + +// ... after queries ran: proofs grow with the age of the state they start from, +// so save where they got to (e.g. on page hide)... +// One key per network: a continuation only resumes a context of the network that saved it. +localStorage.setItem('dash-continuation.testnet', JSON.stringify(context.continuation())); +// ...and resume from it next time (a `QuorumProofContinuation`: `{ anchor, states }`). +const relays = undefined; // or ['https://relay.example'] +const saved = localStorage.getItem('dash-continuation.testnet'); +let next = sdk.WasmVerifiedContext.testnet(relays); // invalid relays throw here +if (saved) { + try { + next = sdk.WasmVerifiedContext.testnetFromContinuation(JSON.parse(saved), relays); + } catch { + // Refused or unreadable, so it would fail every later load too: drop it. + localStorage.removeItem('dash-continuation.testnet'); + } +} +``` + +A continuation's `anchor` is the newest verified state more than one DKG interval (24 blocks) below the lowest Core height of a response the SDK verified with the context's keys (the context's own starting point if none was verified, or if no kept state is that far below it, as with a recent custom checkpoint). A Platform quorum signed that height, so Platform reached it, and Platform's height only rises: the next session can still prove the heights Platform reports. The margin is there because a key verified at a height may rest on evidence up to one DKG interval below it (the grace for the block on which Platform still signs with a quorum Core has just retired), and the next session needs a state below that evidence to prove the key again. A response that fails verification never moves the anchor, and neither do keys proven only through `fetchQuorumPublicKey`. `states` are the newer verified states, so proofs stay short. Saved before any response was verified, a continuation is just the starting point and its states. Save the whole continuation rather than `latestVerifiedState()`: that state is often above Platform's height, and no key at or below a lone starting state can be proven. `mainnetWithCheckpoint` / `testnetWithCheckpoint` start from a single `QuorumProofState` you checked independently, in the form of the `target` Dash Core's `getquorumproofchain` returns. Checkpoints and continuations are trust configuration: only pass back what a context saved. A continuation does not record the checkpoint it started from, so it stays trusted after an SDK release replaces that checkpoint; if a release's notes say to, drop saved continuations. + +Relays are 1 to 8 base URLs whose `/proofs` endpoint serves the proofs; each must be `https` (plain `http` only on a loopback host). As with the Rust SDK's builder, the context attached last is the one the SDK uses. `build()` throws when the Rust SDK refuses the configuration: a verified context of another network than the builder's, or one with `withProofs(false)`. Contracts a verified SDK caches are persisted apart from the trusted ones (`dash-sdk.contracts..verified`). The verified context discovers no masternode addresses, so the builder keeps the addresses it has (its preset or `withAddresses` list, or those a trusted context attached before it discovered). The public mainnet quorum service does not serve proofs yet, so on mainnet pass relays that do. + ### Advanced: raw artifacts (separate .wasm) If you prefer to manage the `.wasm` file via your bundler, import the raw entry explicitly: diff --git a/packages/wasm-sdk/src/context_provider.rs b/packages/wasm-sdk/src/context_provider.rs index b7046795a23..39d4371b2be 100644 --- a/packages/wasm-sdk/src/context_provider.rs +++ b/packages/wasm-sdk/src/context_provider.rs @@ -7,7 +7,7 @@ use dash_sdk::{ error::ContextProviderError, platform::{DataContract, Identifier}, }; -use rs_sdk_trusted_context_provider::TrustedHttpContextProvider; +use rs_sdk_trusted_context_provider::{TrustedHttpContextProvider, VerifiedHttpContextProvider}; use wasm_bindgen::prelude::wasm_bindgen; use crate::error::WasmSdkError; @@ -45,7 +45,7 @@ impl ContextProvider for WasmContext { _core_chain_locked_height: u32, ) -> Result<[u8; 48], ContextProviderError> { Err(ContextProviderError::Config( - "Non-trusted mode is not supported in WASM. Please construct a WasmTrustedContext via prefetchMainnet/prefetchTestnet/prefetchDevnet/prefetchLocal and attach it with WasmSdkBuilder.withTrustedContext().".to_string() + "Non-trusted mode is not supported in WASM. Please construct a WasmTrustedContext via prefetchMainnet/prefetchTestnet/prefetchDevnet/prefetchLocal and attach it with WasmSdkBuilder.withTrustedContext(), or a WasmVerifiedContext via mainnet/testnet and attach it with WasmSdkBuilder.withVerifiedContext().".to_string() )) } @@ -133,6 +133,24 @@ impl ContextProvider for WasmTrustedContext { fn get_platform_activation_height(&self) -> Result { self.inner.get_platform_activation_height() } + + fn network(&self) -> Option { + self.inner.network() + } + + fn requires_proofs(&self) -> bool { + self.inner.requires_proofs() + } + + fn core_height_verified( + &self, + quorum_type: u32, + quorum_hash: [u8; 32], + core_chain_locked_height: u32, + ) { + self.inner + .core_height_verified(quorum_type, quorum_hash, core_chain_locked_height) + } } // JS-exported async factory methods @@ -406,6 +424,85 @@ impl WasmTrustedContext { } } +/// The quorum context a `WasmSdk` serves proofs from: one of the two kinds a +/// builder can attach. The SDK caches the contracts and token +/// configurations it fetches in whichever is attached. A verified context is +/// held as the provider a `WasmVerifiedContext` wraps, which is also what the +/// builder attaches; clones share its caches. +#[derive(Clone)] +pub(crate) enum QuorumContext { + Trusted(WasmTrustedContext), + Verified(VerifiedHttpContextProvider), +} + +impl From for QuorumContext { + fn from(context: WasmTrustedContext) -> Self { + Self::Trusted(context) + } +} + +impl QuorumContext { + /// Refresh the trusted context's quorum lists before proof verification. + /// A verified context proves each quorum key when it is first needed, so + /// it has nothing to refresh. + pub(crate) async fn refresh_quorums(&self) -> Result<(), WasmSdkError> { + match self { + Self::Trusted(context) => context.refresh_quorums().await, + Self::Verified(_) => Ok(()), + } + } + + pub(crate) fn get_data_contract( + &self, + id: &Identifier, + platform_version: &PlatformVersion, + ) -> Result>, ContextProviderError> { + match self { + Self::Trusted(context) => context.get_data_contract(id, platform_version), + Self::Verified(context) => context.get_data_contract(id, platform_version), + } + } + + pub(crate) fn add_known_contract(&self, contract: DataContract) { + match self { + Self::Trusted(context) => context.add_known_contract(contract), + Self::Verified(context) => context.add_known_contract(contract), + } + } + + pub(crate) fn add_known_contract_if_newer(&self, contract: DataContract) -> Arc { + match self { + Self::Trusted(context) => context.add_known_contract_if_newer(contract), + Self::Verified(context) => context.add_known_contract_if_newer(contract), + } + } + + pub(crate) fn get_known_contract(&self, id: &Identifier) -> Option> { + match self { + Self::Trusted(context) => context.get_known_contract(id), + Self::Verified(context) => context.get_known_contract(id), + } + } + + pub(crate) fn remove_known_contract(&self, id: &Identifier) -> bool { + match self { + Self::Trusted(context) => context.remove_known_contract(id), + Self::Verified(context) => context.remove_known_contract(id), + } + } + + pub(crate) fn add_known_token_configuration( + &self, + token_id: Identifier, + config: TokenConfiguration, + ) { + match self { + Self::Trusted(context) => context.add_known_token_configuration(token_id, config), + Self::Verified(context) => context.add_known_token_configuration(token_id, config), + } + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/packages/wasm-sdk/src/contract_store.rs b/packages/wasm-sdk/src/contract_store.rs index 41cd84aa4bd..c05844be65a 100644 --- a/packages/wasm-sdk/src/contract_store.rs +++ b/packages/wasm-sdk/src/contract_store.rs @@ -17,6 +17,11 @@ //! Persisted for mainnet and testnet only, like the protocol version: devnets //! are re-cut and a contract id (a hash of owner and nonce) can outlive the //! chain that minted it; regtest is local. +//! +//! An SDK with a verified quorum context keeps its contracts apart, under +//! `dash-sdk.contracts..verified`: a contract a trusted session saved +//! was proved with quorum keys the quorum service vouched for, and seeding a +//! verified context with it would bring that trust back in. use std::collections::BTreeMap; @@ -59,15 +64,48 @@ impl StoredContract { type StoredContracts = BTreeMap; -/// The storage key for a network, or `None` for networks that are not persisted. -pub(crate) fn storage_key(network: Network) -> Option { - match network { - Network::Mainnet => Some(format!("{KEY_PREFIX}mainnet")), - Network::Testnet => Some(format!("{KEY_PREFIX}testnet")), - Network::Devnet | Network::Regtest => None, +/// Which store contracts go to: one per network for SDKs that take quorum +/// keys on trust, and a separate one per network for SDKs with a verified +/// quorum context. Every storage operation names its slot, so a caller cannot +/// reach the trusted store by passing a bare network. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) struct Slot { + network: Network, + verified: bool, +} + +impl Slot { + /// The store of SDKs that take quorum keys on trust: those with a + /// trusted context, or none. + pub(crate) fn trusted(network: Network) -> Self { + Self { + network, + verified: false, + } + } + + /// The store of SDKs with a verified quorum context, whose contracts were + /// proved with quorum keys the SDK proved itself. + pub(crate) fn verified(network: Network) -> Self { + Self { + network, + verified: true, + } } } +/// The storage key for a slot, or `None` for networks that are not persisted. +pub(crate) fn storage_key(slot: Slot) -> Option { + let Slot { network, verified } = slot; + let network = match network { + Network::Mainnet => "mainnet", + Network::Testnet => "testnet", + Network::Devnet | Network::Regtest => return None, + }; + let suffix = if verified { ".verified" } else { "" }; + Some(format!("{KEY_PREFIX}{network}{suffix}")) +} + fn read(key: &str) -> StoredContracts { browser_storage::get_item(key) .and_then(|raw| serde_json::from_str::(&raw).ok()) @@ -82,8 +120,8 @@ fn write(key: &str, contracts: &StoredContracts) { /// Every persisted contract for `network` this build can deserialize, in id /// order. Entries that fail to decode are skipped, not returned. -pub(crate) fn load_all(network: Network, platform_version: &PlatformVersion) -> Vec { - let Some(key) = storage_key(network) else { +pub(crate) fn load_all(slot: Slot, platform_version: &PlatformVersion) -> Vec { + let Some(key) = storage_key(slot) else { return Vec::new(); }; read(&key) @@ -95,26 +133,26 @@ pub(crate) fn load_all(network: Network, platform_version: &PlatformVersion) -> /// Persist `contract` for `network`, replacing any entry under its id. /// Silent when the network is not persisted, the contract does not /// serialize, or the map is full. -pub(crate) fn store(network: Network, contract: &DataContract, platform_version: &PlatformVersion) { - store_internal(network, contract, false, platform_version); +pub(crate) fn store(slot: Slot, contract: &DataContract, platform_version: &PlatformVersion) { + store_internal(slot, contract, false, platform_version); } /// Persist only a strictly newer version; equal versions keep the stored body. pub(crate) fn store_if_newer( - network: Network, + slot: Slot, contract: &DataContract, platform_version: &PlatformVersion, ) { - store_internal(network, contract, true, platform_version); + store_internal(slot, contract, true, platform_version); } fn store_internal( - network: Network, + slot: Slot, contract: &DataContract, only_if_newer: bool, platform_version: &PlatformVersion, ) { - let Some(key) = storage_key(network) else { + let Some(key) = storage_key(slot) else { return; }; let Ok(bytes) = contract.serialize_to_bytes_with_platform_version(platform_version) else { @@ -146,8 +184,8 @@ fn store_internal( } /// Remove the entry for `id`, if any. -pub(crate) fn remove(network: Network, id: &Identifier) { - let Some(key) = storage_key(network) else { +pub(crate) fn remove(slot: Slot, id: &Identifier) { + let Some(key) = storage_key(slot) else { return; }; let mut contracts = read(&key); @@ -158,12 +196,12 @@ pub(crate) fn remove(network: Network, id: &Identifier) { /// Remove a row unless its decoded same-ID body covers the observed version. pub(crate) fn remove_if_older( - network: Network, + slot: Slot, id: &Identifier, observed_version: u32, platform_version: &PlatformVersion, ) { - let Some(key) = storage_key(network) else { + let Some(key) = storage_key(slot) else { return; }; let mut contracts = read(&key); @@ -202,22 +240,50 @@ mod tests { #[test] fn only_public_networks_are_persisted() { - assert!(storage_key(Network::Mainnet).is_some()); - assert!(storage_key(Network::Testnet).is_some()); - assert!(storage_key(Network::Devnet).is_none()); - assert!(storage_key(Network::Regtest).is_none()); - store(Network::Devnet, &contract(1, 1), PlatformVersion::latest()); - assert!(load_all(Network::Devnet, PlatformVersion::latest()).is_empty()); + assert!(storage_key(Slot::trusted(Network::Mainnet)).is_some()); + assert!(storage_key(Slot::trusted(Network::Testnet)).is_some()); + assert!(storage_key(Slot::trusted(Network::Devnet)).is_none()); + assert!(storage_key(Slot::trusted(Network::Regtest)).is_none()); + store( + Slot::trusted(Network::Devnet), + &contract(1, 1), + PlatformVersion::latest(), + ); + assert!(load_all(Slot::trusted(Network::Devnet), PlatformVersion::latest()).is_empty()); + } + + /// Contracts an SDK with a verified context caches never mix with the ones + /// other SDKs saved, in either direction. + #[test] + fn should_keep_verified_contracts_apart() { + let pv = PlatformVersion::latest(); + let verified = Slot::verified(Network::Testnet); + assert_eq!( + storage_key(verified).as_deref(), + Some("dash-sdk.contracts.testnet.verified") + ); + assert_eq!(storage_key(Slot::verified(Network::Devnet)), None); + + store(Slot::trusted(Network::Testnet), &contract(0x31, 1), pv); + store(verified, &contract(0x32, 1), pv); + + let ids = + |slot: Slot| -> Vec { load_all(slot, pv).iter().map(|c| c.id()).collect() }; + assert_eq!(ids(verified), vec![Identifier::new([0x32; 32])]); + assert_eq!( + ids(Slot::trusted(Network::Testnet)), + vec![Identifier::new([0x31; 32])] + ); } #[test] fn store_load_replace_and_remove_round_trip() { let pv = PlatformVersion::latest(); - assert!(load_all(Network::Testnet, pv).is_empty()); + assert!(load_all(Slot::trusted(Network::Testnet), pv).is_empty()); - store(Network::Testnet, &contract(0x11, 1), pv); - store(Network::Testnet, &contract(0x22, 3), pv); - let loaded = load_all(Network::Testnet, pv); + store(Slot::trusted(Network::Testnet), &contract(0x11, 1), pv); + store(Slot::trusted(Network::Testnet), &contract(0x22, 3), pv); + let loaded = load_all(Slot::trusted(Network::Testnet), pv); assert_eq!(loaded.len(), 2); assert!(loaded .iter() @@ -227,15 +293,18 @@ mod tests { .any(|c| c.id() == Identifier::new([0x22; 32]) && c.version() == 3)); // A newer fetch of the same id replaces the entry. - store(Network::Testnet, &contract(0x11, 2), pv); - let loaded = load_all(Network::Testnet, pv); + store(Slot::trusted(Network::Testnet), &contract(0x11, 2), pv); + let loaded = load_all(Slot::trusted(Network::Testnet), pv); assert_eq!(loaded.len(), 2); assert!(loaded .iter() .any(|c| c.id() == Identifier::new([0x11; 32]) && c.version() == 2)); - remove(Network::Testnet, &Identifier::new([0x11; 32])); - let loaded = load_all(Network::Testnet, pv); + remove( + Slot::trusted(Network::Testnet), + &Identifier::new([0x11; 32]), + ); + let loaded = load_all(Slot::trusted(Network::Testnet), pv); assert_eq!(loaded.len(), 1); assert_eq!(loaded[0].id(), Identifier::new([0x22; 32])); } @@ -243,13 +312,13 @@ mod tests { #[test] fn undecodable_entries_are_skipped_and_garbage_is_ignored() { let pv = PlatformVersion::latest(); - let key = storage_key(Network::Testnet).expect("testnet is persisted"); + let key = storage_key(Slot::trusted(Network::Testnet)).expect("testnet is persisted"); browser_storage::set_item(&key, "not json"); - assert!(load_all(Network::Testnet, pv).is_empty()); + assert!(load_all(Slot::trusted(Network::Testnet), pv).is_empty()); // A garbage store does not block new entries. - store(Network::Testnet, &contract(0x33, 1), pv); - assert_eq!(load_all(Network::Testnet, pv).len(), 1); + store(Slot::trusted(Network::Testnet), &contract(0x33, 1), pv); + assert_eq!(load_all(Slot::trusted(Network::Testnet), pv).len(), 1); // One bad entry next to a good one: only the good one comes back. let mut contracts = read(&key); @@ -261,7 +330,7 @@ mod tests { }, ); write(&key, &contracts); - let loaded = load_all(Network::Testnet, pv); + let loaded = load_all(Slot::trusted(Network::Testnet), pv); assert_eq!(loaded.len(), 1); assert_eq!(loaded[0].id(), Identifier::new([0x33; 32])); } @@ -270,18 +339,21 @@ mod tests { fn a_full_map_refuses_new_ids_but_still_replaces_known_ones() { let pv = PlatformVersion::latest(); for i in 0..MAX_ENTRIES { - store(Network::Mainnet, &contract(i as u8, 1), pv); + store(Slot::trusted(Network::Mainnet), &contract(i as u8, 1), pv); } - assert_eq!(load_all(Network::Mainnet, pv).len(), MAX_ENTRIES); - store(Network::Mainnet, &contract(0xFF, 1), pv); assert_eq!( - load_all(Network::Mainnet, pv).len(), + load_all(Slot::trusted(Network::Mainnet), pv).len(), + MAX_ENTRIES + ); + store(Slot::trusted(Network::Mainnet), &contract(0xFF, 1), pv); + assert_eq!( + load_all(Slot::trusted(Network::Mainnet), pv).len(), MAX_ENTRIES, "an unknown id must not grow a full map" ); - store(Network::Mainnet, &contract(0, 7), pv); + store(Slot::trusted(Network::Mainnet), &contract(0, 7), pv); assert!( - load_all(Network::Mainnet, pv) + load_all(Slot::trusted(Network::Mainnet), pv) .iter() .any(|c| c.id() == Identifier::new([0; 32]) && c.version() == 7), "a known id must still be replaced" @@ -289,7 +361,7 @@ mod tests { } fn stored_body_with_metadata(id_byte: u8, body: &DataContract, version: u32) { - let key = storage_key(Network::Testnet).expect("persisted testnet"); + let key = storage_key(Slot::trusted(Network::Testnet)).expect("persisted testnet"); let bytes = body .serialize_to_bytes_with_platform_version(PlatformVersion::latest()) .expect("fixture body"); @@ -309,22 +381,28 @@ mod tests { fn should_replace_older_body_despite_inflated_auxiliary_version() { let pv = PlatformVersion::latest(); stored_body_with_metadata(0x51, &contract(0x51, 1), 99); - store_if_newer(Network::Testnet, &contract(0x51, 2), pv); - assert_eq!(load_all(Network::Testnet, pv)[0].version(), 2); + store_if_newer(Slot::trusted(Network::Testnet), &contract(0x51, 2), pv); + assert_eq!( + load_all(Slot::trusted(Network::Testnet), pv)[0].version(), + 2 + ); } #[test] fn should_keep_newer_body_despite_deflated_auxiliary_version() { let pv = PlatformVersion::latest(); stored_body_with_metadata(0x52, &contract(0x52, 3), 1); - store_if_newer(Network::Testnet, &contract(0x52, 2), pv); - assert_eq!(load_all(Network::Testnet, pv)[0].version(), 3); + store_if_newer(Slot::trusted(Network::Testnet), &contract(0x52, 2), pv); + assert_eq!( + load_all(Slot::trusted(Network::Testnet), pv)[0].version(), + 3 + ); } #[test] fn should_replace_undecodable_body_despite_inflated_auxiliary_version() { let pv = PlatformVersion::latest(); - let key = storage_key(Network::Testnet).expect("persisted testnet"); + let key = storage_key(Slot::trusted(Network::Testnet)).expect("persisted testnet"); write( &key, &BTreeMap::from([( @@ -335,8 +413,8 @@ mod tests { }, )]), ); - store_if_newer(Network::Testnet, &contract(0x53, 2), pv); - let loaded = load_all(Network::Testnet, pv); + store_if_newer(Slot::trusted(Network::Testnet), &contract(0x53, 2), pv); + let loaded = load_all(Slot::trusted(Network::Testnet), pv); assert_eq!( loaded.len(), 1, @@ -349,8 +427,8 @@ mod tests { fn should_replace_body_for_different_id_despite_newer_version() { let pv = PlatformVersion::latest(); stored_body_with_metadata(0x54, &contract(0x55, 99), 99); - store_if_newer(Network::Testnet, &contract(0x54, 2), pv); - let loaded = load_all(Network::Testnet, pv); + store_if_newer(Slot::trusted(Network::Testnet), &contract(0x54, 2), pv); + let loaded = load_all(Slot::trusted(Network::Testnet), pv); assert_eq!(loaded[0].id(), Identifier::new([0x54; 32])); assert_eq!(loaded[0].version(), 2); } @@ -375,7 +453,7 @@ mod tests { fn should_remove_stale_body_despite_inflated_auxiliary_version() { stored_body_with_metadata(0x61, &contract(0x61, 1), 99); observe_newer_document(0x61); - assert!(load_all(Network::Testnet, PlatformVersion::latest()).is_empty()); + assert!(load_all(Slot::trusted(Network::Testnet), PlatformVersion::latest()).is_empty()); } #[test] @@ -383,7 +461,7 @@ mod tests { stored_body_with_metadata(0x62, &contract(0x62, 3), 1); observe_newer_document(0x62); assert_eq!( - load_all(Network::Testnet, PlatformVersion::latest()) + load_all(Slot::trusted(Network::Testnet), PlatformVersion::latest()) .first() .expect("a newer decoded body must survive despite deflated metadata") .version(), @@ -395,13 +473,13 @@ mod tests { fn should_remove_wrong_id_body_despite_newer_version_on_stale_query() { stored_body_with_metadata(0x63, &contract(0x64, 99), 99); observe_newer_document(0x63); - let key = storage_key(Network::Testnet).expect("persisted testnet"); + let key = storage_key(Slot::trusted(Network::Testnet)).expect("persisted testnet"); assert!(!read(&key).contains_key(&Identifier::new([0x63; 32]).to_string(Encoding::Base58))); } #[test] fn should_remove_undecodable_body_despite_newer_version_on_stale_query() { - let key = storage_key(Network::Testnet).expect("persisted testnet"); + let key = storage_key(Slot::trusted(Network::Testnet)).expect("persisted testnet"); let id = Identifier::new([0x65; 32]).to_string(Encoding::Base58); write( &key, diff --git a/packages/wasm-sdk/src/lib.rs b/packages/wasm-sdk/src/lib.rs index 550abe47bcb..e1a60154e49 100644 --- a/packages/wasm-sdk/src/lib.rs +++ b/packages/wasm-sdk/src/lib.rs @@ -17,6 +17,7 @@ pub mod sdk; pub mod serialization; pub mod settings; pub mod state_transitions; +pub mod verified_context; pub mod wallet; // Re-export commonly used items diff --git a/packages/wasm-sdk/src/queries/token.rs b/packages/wasm-sdk/src/queries/token.rs index 41161c953df..32865d8cf63 100644 --- a/packages/wasm-sdk/src/queries/token.rs +++ b/packages/wasm-sdk/src/queries/token.rs @@ -916,10 +916,10 @@ impl WasmSdk { use dash_sdk::dpp::tokens::contract_info::v0::TokenContractInfoV0Accessors; use dash_sdk::dpp::tokens::contract_info::TokenContractInfo; - // Step 1: Verify trusted context is available - let trusted_context = self.trusted_context().ok_or_else(|| { + // Step 1: Verify a quorum context is available to cache into + let quorum_context = self.quorum_context().ok_or_else(|| { WasmSdkError::generic( - "Trusted context not initialized. Use a trusted builder with a prefetched cache.", + "No quorum context attached. Build the SDK with withTrustedContext() or withVerifiedContext().", ) })?; @@ -950,8 +950,8 @@ impl WasmSdk { })? .clone(); - // Step 5: Add the token configuration to the trusted context cache - trusted_context.add_known_token_configuration(token_id, token_configuration); + // Step 5: Add the token configuration to the quorum context cache + quorum_context.add_known_token_configuration(token_id, token_configuration); Ok(()) } diff --git a/packages/wasm-sdk/src/sdk.rs b/packages/wasm-sdk/src/sdk.rs index 0d477f67e88..e0b4ff23109 100644 --- a/packages/wasm-sdk/src/sdk.rs +++ b/packages/wasm-sdk/src/sdk.rs @@ -1,5 +1,6 @@ -use crate::context_provider::{WasmContext, WasmTrustedContext}; +use crate::context_provider::{QuorumContext, WasmContext, WasmTrustedContext}; use crate::error::WasmSdkError; +use crate::verified_context::WasmVerifiedContext; use crate::{contract_store, protocol_version_store}; use dash_sdk::dpp::dashcore::Network; use dash_sdk::dpp::data_contract::accessors::v0::DataContractV0Getters as _; @@ -45,7 +46,7 @@ fn default_local_addresses() -> Vec
{ #[wasm_bindgen] pub struct WasmSdk { sdk: Sdk, - trusted_context: Option, + context: Option, /// Contract fetches currently on the wire, keyed by contract id. /// /// A cold cache is hit by a BURST, not a single call: an app's first @@ -59,14 +60,27 @@ pub struct WasmSdk { inflight_contract_fetches: Rc>>, } +/// The contract store an SDK on `network` with `context` reads and writes; see +/// [`contract_store`]. +fn contract_slot(network: Network, context: Option<&QuorumContext>) -> contract_store::Slot { + match context { + Some(QuorumContext::Verified(_)) => contract_store::Slot::verified(network), + Some(QuorumContext::Trusted(_)) | None => contract_store::Slot::trusted(network), + } +} + impl WasmSdk { - fn new(sdk: Sdk, trusted_context: Option) -> Self { + fn new(sdk: Sdk, context: Option) -> Self { Self { sdk, - trusted_context, + context, inflight_contract_fetches: Rc::new(RefCell::new(HashMap::new())), } } + + fn contract_slot(&self) -> contract_store::Slot { + contract_slot(self.sdk.network, self.context.as_ref()) + } } // Dereference WasmSdk to Sdk so that we can use &WasmSdk everywhere where &Sdk is needed @@ -94,9 +108,9 @@ impl WasmSdk { &self.sdk } - /// Get a reference to the trusted context, if available - pub(crate) fn trusted_context(&self) -> Option<&WasmTrustedContext> { - self.trusted_context.as_ref() + /// The trusted or verified quorum context, if one is attached + pub(crate) fn quorum_context(&self) -> Option<&QuorumContext> { + self.context.as_ref() } } @@ -120,20 +134,20 @@ impl WasmSdk { self.sdk.refresh_identity_nonce(&identity_id.into()).await; } - /// Get a cached contract from the trusted context if available + /// Get a cached contract from the quorum context if available pub(crate) fn get_cached_contract( &self, contract_id: &dash_sdk::platform::Identifier, ) -> Option> { - self.trusted_context + self.context .as_ref() .and_then(|ctx| ctx.get_known_contract(contract_id)) } - /// Cache a contract in the trusted context, and persist it for the next + /// Cache a contract in the quorum context, and persist it for the next /// page load (see [`contract_store`]). pub(crate) fn cache_contract(&self, contract: dash_sdk::platform::DataContract) { - if let Some(ref context) = self.trusted_context { + if let Some(ref context) = self.context { cache_contract_in_context(&self.sdk, context, contract); } } @@ -158,7 +172,7 @@ impl WasmSdk { return false; } let id = contract.id(); - if let Some(context) = &self.trusted_context { + if let Some(context) = &self.context { if context .get_known_contract(&id) .is_some_and(|retained| retained.version() < observed_version) @@ -167,7 +181,7 @@ impl WasmSdk { } } contract_store::remove_if_older( - self.sdk.network, + self.contract_slot(), &id, observed_version, self.sdk.version(), @@ -180,7 +194,7 @@ impl WasmSdk { &self, contract_id: dash_sdk::platform::Identifier, ) -> Result { - fetch_contract_into_cache(self.sdk.clone(), self.trusted_context.clone(), contract_id).await + fetch_contract_into_cache(self.sdk.clone(), self.context.clone(), contract_id).await } /// Fetch a contract, checking the cache first. @@ -198,10 +212,10 @@ impl WasmSdk { } let sdk = self.sdk.clone(); - let trusted_context = self.trusted_context.clone(); + let context = self.context.clone(); let fetched = self .join_or_start_contract_fetch(contract_id, move || { - fetch_contract_into_cache(sdk, trusted_context, contract_id) + fetch_contract_into_cache(sdk, context, contract_id) }) .await?; Ok(self @@ -253,8 +267,8 @@ impl WasmSdk { &self, contract_id: &dash_sdk::platform::Identifier, ) -> bool { - contract_store::remove(self.sdk.network, contract_id); - self.trusted_context + contract_store::remove(self.contract_slot(), contract_id); + self.context .as_ref() .map(|ctx| ctx.remove_known_contract(contract_id)) .unwrap_or(false) @@ -269,17 +283,16 @@ impl WasmSdk { /// Pair with `getDataContractsLatestVersions` (off the critical path) to /// learn whether the held contract is still the network's current version. /// - /// Returns false when the SDK has no trusted context to cache into. + /// Returns false when the SDK has no trusted or verified context to cache + /// into. #[wasm_bindgen(js_name = "addKnownContract")] pub fn add_known_contract_js(&self, contract: &DataContractWasm) -> bool { - if self.trusted_context.is_none() { + let Some(context) = &self.context else { return false; - } + }; let contract: DataContract = contract.clone().into(); - contract_store::store(self.sdk.network, &contract, self.sdk.version()); - if let Some(context) = &self.trusted_context { - context.add_known_contract(contract); - } + contract_store::store(self.contract_slot(), &contract, self.sdk.version()); + context.add_known_contract(contract); true } @@ -298,7 +311,7 @@ impl WasmSdk { #[wasm_bindgen] pub struct WasmSdkBuilder { inner: SdkBuilder, - trusted_context: Option, + context: Option, /// True when the caller explicitly supplied an address list via /// `withAddresses(...)`. Preset constructors (mainnet/testnet/local/ /// newDevnet) leave this `false` so that `withTrustedContext` is free @@ -393,7 +406,7 @@ impl WasmSdkBuilder { Ok(Self { inner: sdk_builder, - trusted_context: None, + context: None, has_user_addresses: true, network, version_configured: false, @@ -406,7 +419,7 @@ impl WasmSdkBuilder { Self { inner: sdk_builder, - trusted_context: None, + context: None, has_user_addresses: false, network: Network::Mainnet, version_configured: false, @@ -419,7 +432,7 @@ impl WasmSdkBuilder { Self { inner: sdk_builder, - trusted_context: None, + context: None, has_user_addresses: false, network: Network::Testnet, version_configured: false, @@ -441,7 +454,7 @@ impl WasmSdkBuilder { Self { inner: sdk_builder, - trusted_context: None, + context: None, has_user_addresses: false, network: Network::Devnet, version_configured: false, @@ -458,7 +471,7 @@ impl WasmSdkBuilder { Self { inner: sdk_builder, - trusted_context: None, + context: None, has_user_addresses: false, network: Network::Regtest, version_configured: false, @@ -480,6 +493,10 @@ impl WasmSdkBuilder { /// proof verification. Prefetch such a context with /// `discoverAddresses: false` to skip the discovery request outright. /// + /// Like the Rust SDK's builder, the context attached last is the one the + /// SDK uses: this replaces any trusted or verified context attached + /// before. + /// /// # Example /// ```javascript /// const context = await WasmTrustedContext.prefetchTestnet(); @@ -503,7 +520,39 @@ impl WasmSdkBuilder { Self { inner, - trusted_context: Some(context.clone()), + context: Some(context.clone().into()), + ..self + } + } + + /// Attach a verified context to this builder: quorum keys are then + /// proven with DIP 175 proofs instead of taken from the quorum service + /// on trust. + /// + /// A verified context discovers no masternode addresses, so the builder + /// keeps the addresses it has: its preset (`mainnet`, `testnet`) or + /// explicit (`withAddresses`) list, or those a trusted context attached + /// before it discovered. + /// + /// Like the Rust SDK's builder, the context attached last is the one the + /// SDK uses: this replaces any trusted or verified context attached + /// before. `build()` throws, as the Rust SDK refuses the + /// configuration, when the context is for another network than the + /// builder or proofs were turned off with `withProofs(false)`. + /// + /// # Example + /// ```javascript + /// const context = WasmVerifiedContext.testnet(); + /// const sdk = WasmSdkBuilder.testnet().withVerifiedContext(context).build(); + /// // Later, save where proofs got to for the next start: + /// localStorage.setItem('continuation', JSON.stringify(context.continuation())); + /// ``` + #[wasm_bindgen(js_name = "withVerifiedContext")] + pub fn with_verified_context(self, context: &WasmVerifiedContext) -> Self { + let provider = context.provider().clone(); + Self { + inner: self.inner.with_context_provider(provider.clone()), + context: Some(QuorumContext::Verified(provider)), ..self } } @@ -522,15 +571,16 @@ impl WasmSdkBuilder { inner = inner.with_protocol_version_observer(persist_protocol_version(network)); } let sdk = inner.build().map_err(WasmSdkError::from)?; - // Contracts fetched on earlier page loads: seed the trusted-context + // Contracts fetched on earlier page loads: seed the quorum context's // cache so the first proved document query needs no contract round // trip. Persisted for mainnet and testnet only; see `contract_store`. - if let Some(context) = &self.trusted_context { - for contract in contract_store::load_all(self.network, sdk.version()) { + if let Some(context) = &self.context { + let slot = contract_slot(self.network, Some(context)); + for contract in contract_store::load_all(slot, sdk.version()) { cache_contract_in_context(&sdk, context, contract); } } - Ok(WasmSdk::new(sdk, self.trusted_context)) + Ok(WasmSdk::new(sdk, self.context)) } #[wasm_bindgen(js_name = "withContextProvider")] @@ -540,7 +590,7 @@ impl WasmSdkBuilder { ) -> Self { Self { inner: self.inner.with_context_provider(context_provider), - trusted_context: None, + context: None, ..self } } @@ -664,7 +714,14 @@ impl WasmSdk { /// Pair a mock `Sdk` with a trusted context so tests in sibling modules can /// drive the paths that read the contract and quorum caches. pub(crate) fn new_for_testing(sdk: Sdk, trusted_context: Option) -> Self { - Self::new(sdk, trusted_context) + Self::new(sdk, trusted_context.map(QuorumContext::from)) + } + + /// As [`Self::new_for_testing`], with either kind of quorum context. Only + /// the native contract-cache tests need it. + #[cfg(not(target_arch = "wasm32"))] + pub(crate) fn new_for_testing_with_context(sdk: Sdk, context: Option) -> Self { + Self::new(sdk, context) } /// How many contract fetches are registered as in flight right now. @@ -674,12 +731,12 @@ impl WasmSdk { } /// The one real contract fetch: a proved `DataContract::fetch`, whose -/// result seeds the trusted-context cache before it is handed back. Takes +/// result seeds the quorum context's cache before it is handed back. Takes /// the SDK and context by value so the returned future is `'static` and /// can be registered as a shared in-flight fetch. async fn fetch_contract_into_cache( sdk: Sdk, - trusted_context: Option, + context: Option, contract_id: Identifier, ) -> Result { use dash_sdk::platform::Fetch; @@ -688,7 +745,7 @@ async fn fetch_contract_into_cache( .await? .ok_or_else(|| WasmSdkError::not_found("Data contract not found"))?; - if let Some(context) = trusted_context { + if let Some(context) = context { cache_contract_in_context(&sdk, &context, contract.clone()); } @@ -697,20 +754,27 @@ async fn fetch_contract_into_cache( /// Persist the retained memory entry so an older completion cannot write its /// candidate back over a newer contract already shared by this context. -fn cache_contract_in_context(sdk: &Sdk, context: &WasmTrustedContext, contract: DataContract) { +fn cache_contract_in_context(sdk: &Sdk, context: &QuorumContext, contract: DataContract) { let retained = context.add_known_contract_if_newer(contract); - contract_store::store_if_newer(sdk.network, &retained, sdk.version()); + contract_store::store_if_newer( + contract_slot(sdk.network, Some(context)), + &retained, + sdk.version(), + ); } #[cfg(test)] mod tests { use super::*; + use crate::contract_store::Slot; + use crate::error::WasmSdkErrorKind; use dash_sdk::dpp::data_contract::accessors::v0::{ DataContractV0Getters, DataContractV0Setters, }; use dash_sdk::dpp::prelude::DataContract; use dash_sdk::dpp::system_data_contracts::{load_system_data_contract, SystemDataContract}; use dash_sdk::dpp::version::PlatformVersion; + use dash_sdk::platform::ContextProvider; use dash_sdk::sdk::Uri; use rs_dapi_client::Address; @@ -877,9 +941,11 @@ mod tests { use dash_sdk::dpp::dashcore::Network; let sdk = user_builder().build().expect("build"); sdk.cache_contract(custom_contract(0x66, 1, sdk.inner_sdk().version())); - assert!( - crate::contract_store::load_all(Network::Testnet, sdk.inner_sdk().version()).is_empty() - ); + assert!(contract_store::load_all( + Slot::trusted(Network::Testnet), + sdk.inner_sdk().version() + ) + .is_empty()); let ctx = WasmTrustedContext::for_testing(vec![parse(DISCOVERED_ADDR_1)]); let devnet = WasmSdkBuilder::new_devnet() @@ -890,10 +956,11 @@ mod tests { assert!(devnet .get_cached_contract(&Identifier::new([0x77; 32])) .is_some()); - assert!( - crate::contract_store::load_all(Network::Devnet, devnet.inner_sdk().version()) - .is_empty() - ); + assert!(contract_store::load_all( + Slot::trusted(Network::Devnet), + devnet.inner_sdk().version() + ) + .is_empty()); } /// A contract the app holds is seeded through the JS-facing entry point, @@ -987,7 +1054,11 @@ mod tests { } let mut durable = snapshot.clone(); durable.set_version(persisted_version); - contract_store::store(Network::Testnet, &durable, sdk.inner_sdk().version()); + contract_store::store( + Slot::trusted(Network::Testnet), + &durable, + sdk.inner_sdk().version(), + ); let document = Document::V0(dash_sdk::dpp::document::DocumentV0 { contract_version: Some(observed_version), ..Default::default() @@ -1001,7 +1072,7 @@ mod tests { "memory is evaluated independently against the observed document version" ); assert_eq!( - contract_store::load_all(Network::Testnet, sdk.inner_sdk().version()) + contract_store::load_all(Slot::trusted(Network::Testnet), sdk.inner_sdk().version()) .iter() .find(|contract| contract.id() == id) .expect("a durable body covering the observed version must survive") @@ -1047,11 +1118,12 @@ mod tests { assert!(sdk.drop_stale_contract(&snapshot, &documents)); assert!(sdk.get_cached_contract(&snapshot.id()).is_none()); - assert!( - contract_store::load_all(Network::Testnet, sdk.inner_sdk().version()) - .iter() - .all(|contract| contract.id() != snapshot.id()) - ); + assert!(contract_store::load_all( + Slot::trusted(Network::Testnet), + sdk.inner_sdk().version() + ) + .iter() + .all(|contract| contract.id() != snapshot.id())); } } @@ -1081,10 +1153,11 @@ mod tests { .version(), 2 ); - assert!( - contract_store::load_all(Network::Testnet, sdk.inner_sdk().version()) - .contains(&snapshot) - ); + assert!(contract_store::load_all( + Slot::trusted(Network::Testnet), + sdk.inner_sdk().version() + ) + .contains(&snapshot)); } } @@ -1180,6 +1253,212 @@ mod tests { assert!(b.has_user_addresses()); } + /// A verified context discovers no addresses: a preset builder keeps its + /// preset list and an explicit one keeps the caller's. + #[test] + fn should_keep_the_builders_addresses_with_a_verified_context() { + let context = WasmVerifiedContext::testnet(None).expect("embedded testnet context"); + + let preset = WasmSdkBuilder::new_testnet() + .with_verified_context(&context) + .build() + .expect("build"); + let mut expected = sdk_addresses(&WasmSdkBuilder::new_testnet().build().expect("build")); + let mut got = sdk_addresses(&preset); + expected.sort(); + got.sort(); + assert!(!got.is_empty()); + assert_eq!(got, expected); + + let explicit = user_builder().with_verified_context(&context); + assert!(explicit.has_user_addresses()); + let addrs = sdk_addresses(&explicit.build().expect("build")); + assert_eq!(addrs.len(), 2, "{addrs:?}"); + assert!(addrs.iter().all(|a| a.contains("203.0.113.1")), "{addrs:?}"); + } + + /// As with the Rust SDK's builder, the quorum context attached last is + /// the one the SDK uses, whichever kind came before it. + #[test] + fn should_use_the_quorum_context_attached_last() { + let trusted = WasmTrustedContext::for_testing(vec![]); + let verified = WasmVerifiedContext::testnet(None).expect("embedded testnet context"); + + let sdk = WasmSdkBuilder::new_testnet() + .with_verified_context(&verified) + .with_trusted_context(&trusted) + .build() + .expect("build"); + assert!(matches!( + sdk.quorum_context(), + Some(QuorumContext::Trusted(_)) + )); + // Only the verified provider requires proofs. + let provider = sdk.inner_sdk().context_provider().expect("a provider"); + assert!(!provider.requires_proofs()); + + let sdk = WasmSdkBuilder::new_testnet() + .with_trusted_context(&trusted) + .with_verified_context(&verified) + .build() + .expect("build"); + assert!(matches!( + sdk.quorum_context(), + Some(QuorumContext::Verified(_)) + )); + let provider = sdk.inner_sdk().context_provider().expect("a provider"); + assert!(provider.requires_proofs()); + } + + /// A context proves the quorums of its own network only, so the Rust SDK + /// refuses to build with one for another network instead of failing every + /// proof later; the builder reports its error. + #[test] + fn should_refuse_a_verified_context_of_another_network() { + let mainnet = WasmVerifiedContext::mainnet(None).expect("embedded mainnet context"); + for builder in [ + WasmSdkBuilder::new_testnet(), + WasmSdkBuilder::new_local(), + WasmSdkBuilder::new_with_addresses(vec![USER_ADDR_1.to_string()], "devnet".into()) + .expect("devnet builder"), + ] { + let error = builder + .with_verified_context(&mainnet) + .build() + .err() + .expect("another network is refused"); + assert_eq!(error.kind(), WasmSdkErrorKind::Config); + assert!(error.message().contains("Mainnet"), "{}", error.message()); + } + WasmSdkBuilder::new_mainnet() + .with_verified_context(&mainnet) + .build() + .expect("the same network is accepted"); + } + + /// Turning proofs off would leave a verified context nothing to verify, + /// and let unproved contracts into the verified store, so the Rust SDK + /// refuses it in either order of the two calls. + #[test] + fn should_refuse_to_build_a_verified_sdk_without_proofs() { + let context = WasmVerifiedContext::testnet(None).expect("embedded testnet context"); + let before = WasmSdkBuilder::new_testnet() + .with_proofs(false) + .with_verified_context(&context); + let after = WasmSdkBuilder::new_testnet() + .with_verified_context(&context) + .with_proofs(false); + for builder in [before, after] { + let error = builder.build().err().expect("refused"); + assert_eq!(error.kind(), WasmSdkErrorKind::Config); + assert!( + error.message().contains("requires proofs"), + "{}", + error.message() + ); + } + WasmSdkBuilder::new_testnet() + .with_proofs(false) + .with_proofs(true) + .with_verified_context(&context) + .build() + .expect("proofs turned back on"); + } + + /// Contracts a trusted SDK saved were proved with keys the quorum service + /// vouched for, so a verified SDK neither seeds its cache from them nor + /// saves into their store. + #[test] + fn should_keep_a_verified_sdks_contracts_apart_from_a_trusted_ones() { + let trusted_sdk = WasmSdkBuilder::new_testnet() + .with_trusted_context(&WasmTrustedContext::for_testing(vec![])) + .build() + .expect("build"); + let from_trusted = custom_contract(0x61, 1, trusted_sdk.inner_sdk().version()); + assert!(trusted_sdk.add_known_contract_js(&DataContractWasm::from(from_trusted.clone()))); + + let verified = WasmVerifiedContext::testnet(None).expect("embedded testnet context"); + let verified_sdk = WasmSdkBuilder::new_testnet() + .with_verified_context(&verified) + .build() + .expect("build"); + assert!( + verified_sdk + .get_cached_contract(&from_trusted.id()) + .is_none(), + "a trusted session's contract must not seed a verified context" + ); + + let from_verified = custom_contract(0x62, 1, verified_sdk.inner_sdk().version()); + verified_sdk.cache_contract(from_verified.clone()); + let next_trusted = WasmSdkBuilder::new_testnet() + .with_trusted_context(&WasmTrustedContext::for_testing(vec![])) + .build() + .expect("build"); + assert!(next_trusted + .get_cached_contract(&from_verified.id()) + .is_none()); + let next_verified = WasmSdkBuilder::new_testnet() + .with_verified_context(&WasmVerifiedContext::testnet(None).expect("context")) + .build() + .expect("build"); + assert_eq!( + next_verified + .get_cached_contract(&from_verified.id()) + .as_deref(), + Some(&from_verified), + "a verified SDK seeds from what verified SDKs saved" + ); + } + + /// With a verified context attached, contracts the SDK learns are cached + /// in that context, which also answers the proof verifier, and there are + /// no quorum lists to refresh before a proof is checked. + #[tokio::test] + async fn should_cache_contracts_in_the_verified_context() { + let verified = WasmVerifiedContext::testnet(None).expect("embedded testnet context"); + let sdk = WasmSdkBuilder::new_testnet() + .with_verified_context(&verified) + .build() + .expect("build"); + let contract = custom_contract(0x5a, 1, sdk.inner_sdk().version()); + + assert!(sdk.add_known_contract_js(&DataContractWasm::from(contract.clone()))); + + assert_eq!( + verified + .provider() + .get_known_contract(&contract.id()) + .as_deref(), + Some(&contract), + "the builder's clone shares the caller's cache" + ); + let provider = sdk + .inner_sdk() + .context_provider() + .expect("the verified context is the SDK's provider"); + assert_eq!( + ContextProvider::get_data_contract( + &provider, + &contract.id(), + sdk.inner_sdk().version(), + ) + .expect("answers") + .as_deref(), + Some(&contract) + ); + sdk.quorum_context() + .expect("attached") + .refresh_quorums() + .await + .expect("nothing to refresh"); + assert!(sdk.remove_cached_contract(&contract.id())); + assert!(verified + .provider() + .get_known_contract(&contract.id()) + .is_none()); + } + #[tokio::test] async fn refresh_contract_caches_a_missing_custom_contract() { let mut inner_sdk = Sdk::new_mock(); diff --git a/packages/wasm-sdk/src/state_transitions/broadcast.rs b/packages/wasm-sdk/src/state_transitions/broadcast.rs index 643be5cb622..2972d397125 100644 --- a/packages/wasm-sdk/src/state_transitions/broadcast.rs +++ b/packages/wasm-sdk/src/state_transitions/broadcast.rs @@ -15,7 +15,6 @@ use dash_sdk::dpp::state_transition::StateTransition; use dash_sdk::platform::transition::broadcast::{ require_execution_proved, BroadcastStateTransition, WaitForOutcome, }; -use dash_sdk::platform::ContextProvider; use std::collections::BTreeSet; use wasm_bindgen::prelude::*; use wasm_dpp2::state_transitions::proof_result::{ @@ -71,7 +70,7 @@ impl WasmSdk { /// Whether the context provider can already supply this contract, either /// from its cache or from a definition compiled into the SDK. fn can_resolve_contract(&self, contract_id: Identifier) -> bool { - self.trusted_context() + self.quorum_context() .and_then(|context| { context // `WasmSdk::version` is the exported protocol number; the @@ -89,7 +88,7 @@ impl WasmSdk { &self, state_transition: &StateTransition, ) -> Result<(), WasmSdkError> { - if let Some(context) = self.trusted_context() { + if let Some(context) = self.quorum_context() { if let Err(error) = context.refresh_quorums().await { tracing::warn!( error = %error, @@ -332,6 +331,7 @@ mod tests { use dash_sdk::dpp::bls::SecretKey; use super::*; use crate::context_provider::WasmTrustedContext; + use dash_sdk::platform::ContextProvider; use crate::sdk::WasmSdkBuilder; use dash_sdk::dpp::state_transition::batch_transition::batched_transition::document_base_transition::v0::DocumentBaseTransitionV0; use dash_sdk::dpp::state_transition::batch_transition::batched_transition::document_base_transition::DocumentBaseTransition; diff --git a/packages/wasm-sdk/src/state_transitions/contract/cache_tests.rs b/packages/wasm-sdk/src/state_transitions/contract/cache_tests.rs index 7fc146e8d00..0bde2d9e92e 100644 --- a/packages/wasm-sdk/src/state_transitions/contract/cache_tests.rs +++ b/packages/wasm-sdk/src/state_transitions/contract/cache_tests.rs @@ -1,5 +1,6 @@ use super::*; use crate::context_provider::WasmTrustedContext; +use crate::contract_store::Slot; use crate::error::WasmSdkErrorKind; use dapi_grpc::platform::v0::{ get_data_contract_response, get_identity_contract_nonce_response, @@ -639,7 +640,7 @@ async fn should_preserve_promoted_contract_when_a_stale_query_observes_its_versi 2 ); assert_eq!( - crate::contract_store::load_all(Network::Testnet, PlatformVersion::latest()) + crate::contract_store::load_all(Slot::trusted(Network::Testnet), PlatformVersion::latest()) .iter() .find(|contract| contract.id() == Identifier::from(CONTRACT_ID)) .expect("retained persisted contract") @@ -900,7 +901,7 @@ async fn should_return_fetched_snapshot_without_regressing_promoted_cache() { 2 ); assert_eq!( - crate::contract_store::load_all(Network::Testnet, PlatformVersion::latest()) + crate::contract_store::load_all(Slot::trusted(Network::Testnet), PlatformVersion::latest()) .iter() .find(|c| c.id() == Identifier::from(CONTRACT_ID)) .expect("durable contract") @@ -914,8 +915,10 @@ async fn should_use_updated_schema_when_an_older_shared_fetch_finishes_late() { let (sdk, update_request, write_request) = fixture(|_| {}).await; let fetch_request = add_older_contract_fetch(&sdk); sdk.remove_cached_contract(&CONTRACT_ID.into()); - let fetch_sdk = - WasmSdk::new_for_testing(sdk.inner_sdk().clone(), sdk.trusted_context().cloned()); + let fetch_sdk = WasmSdk::new_for_testing_with_context( + sdk.inner_sdk().clone(), + sdk.quorum_context().cloned(), + ); let (release, gate) = futures::channel::oneshot::channel::<()>(); let mut producer = Box::pin(sdk.join_or_start_contract_fetch( CONTRACT_ID.into(), @@ -1005,7 +1008,7 @@ async fn should_use_updated_schema_when_an_older_shared_fetch_finishes_late() { 2 ); assert_eq!( - crate::contract_store::load_all(Network::Testnet, PlatformVersion::latest()) + crate::contract_store::load_all(Slot::trusted(Network::Testnet), PlatformVersion::latest()) .iter() .find(|c| c.id() == Identifier::from(CONTRACT_ID)) .expect("durable newer contract") @@ -1098,7 +1101,8 @@ fn should_keep_newer_contract_in_shared_memory_and_persistence_after_old_complet .version(), 2 ); - let persisted = crate::contract_store::load_all(Network::Testnet, PlatformVersion::latest()); + let persisted = + crate::contract_store::load_all(Slot::trusted(Network::Testnet), PlatformVersion::latest()); assert_eq!( persisted .iter() @@ -1139,10 +1143,11 @@ fn should_preserve_explicit_manual_replacement_with_older_and_equal_versions() { .expect("manual body"), equal ); - assert!( - crate::contract_store::load_all(Network::Testnet, PlatformVersion::latest()) - .contains(&equal) - ); + assert!(crate::contract_store::load_all( + Slot::trusted(Network::Testnet), + PlatformVersion::latest() + ) + .contains(&equal)); assert!(sdk.remove_cached_contract_js(&CONTRACT_ID.into())); assert!(sdk.get_cached_contract(&CONTRACT_ID.into()).is_none()); } @@ -1151,7 +1156,11 @@ fn should_preserve_explicit_manual_replacement_with_older_and_equal_versions() { fn should_hydrate_shared_context_without_downgrading_newer_contract() { let context = WasmTrustedContext::for_testing(vec![]); context.add_known_contract(contract(3)); - crate::contract_store::store(Network::Testnet, &contract(1), PlatformVersion::latest()); + crate::contract_store::store( + Slot::trusted(Network::Testnet), + &contract(1), + PlatformVersion::latest(), + ); let sdk = crate::sdk::WasmSdkBuilder::new_testnet() .with_trusted_context(&context) .build() @@ -1181,7 +1190,8 @@ fn should_preserve_durable_authority_when_an_independent_stale_context_inserts() let mut equal = existing.clone(); equal.set_owner_id([0x49; 32].into()); stale.cache_contract(equal); - let persisted = crate::contract_store::load_all(Network::Testnet, PlatformVersion::latest()); + let persisted = + crate::contract_store::load_all(Slot::trusted(Network::Testnet), PlatformVersion::latest()); assert!( persisted.contains(&existing), "lower and equal internal writes preserve the durable body" diff --git a/packages/wasm-sdk/src/verified_context.rs b/packages/wasm-sdk/src/verified_context.rs new file mode 100644 index 00000000000..ce64a441add --- /dev/null +++ b/packages/wasm-sdk/src/verified_context.rs @@ -0,0 +1,839 @@ +//! `WasmVerifiedContext`: the browser binding of `VerifiedHttpContextProvider`. +//! +//! The provider proves every Platform quorum key it serves with a DIP 175 +//! proof from a checkpoint, and treats the quorum service as an untrusted +//! relay. This module only marshals: checkpoints cross as plain objects in +//! Core's `getquorumproofchain` `target` form, errors become `WasmSdkError`s, +//! and every decision (which relays are acceptable, whether a checkpoint is +//! valid, when to fetch, how a proof is judged) stays in the Rust provider. +//! The builder attaches the provider itself, so contract registration, the +//! known-contract and token-configuration caches and quorum-key fetches are +//! the provider's own. + +use dash_sdk::dpp::dashcore::Network; +use dash_sdk::platform::ContextProvider; +use dash_sdk::Error as SdkError; +use js_sys::JSON; +use rs_sdk_trusted_context_provider::{ + StartingPoint, VerifiedContextProviderError, VerifiedHttpContextProvider, + VerifiedProviderOptions, +}; +use serde::de::DeserializeOwned; +use serde::Serialize; +use wasm_bindgen::prelude::wasm_bindgen; +use wasm_bindgen::{JsCast, JsValue}; + +use crate::error::WasmSdkError; + +#[wasm_bindgen(typescript_custom_section)] +const QUORUM_PROOF_STATE_TS: &'static str = r#" +/** + * A ChainLocked Core block and the masternode and quorum Merkle roots its + * coinbase commits to: where a `WasmVerifiedContext` can start proving quorum + * keys from. The form is Dash Core's: the `target` that + * `getquorumproofchain` returns is a valid checkpoint. Unknown fields are + * rejected. + */ +export interface QuorumProofState { + /** 0 for mainnet, 1 for testnet. */ + network: number; + height: number; + /** Block hash, hex in RPC display order. */ + block_hash: string; + /** The coinbase's `merkleRootMNList`, hex in RPC display order. */ + masternode_root: string; + /** The coinbase's `merkleRootQuorums`, hex in RPC display order. */ + quorum_root: string; +} + +/** + * Where a `WasmVerifiedContext` got to, for the next session to start from: + * what `WasmVerifiedContext.continuation()` returns and the + * `*FromContinuation` factories take. `anchor` is the newest verified + * state more than one DKG interval (24 blocks) below the lowest Core height + * of a response the SDK verified with this context's keys, a height a + * Platform quorum signed (the context's starting point if none was + * verified, or if no kept state is that far below it, as with a recent + * custom checkpoint), so the next session can prove the heights Platform reports + * from it, including a key verified from evidence up to that interval below + * its height, for a quorum that has just retired; `states` are the newer + * verified states, lowest first, so its proofs stay short. Every state is + * trust configuration, as a checkpoint is: only pass back a continuation a + * context produced. Unknown fields are rejected. + */ +export interface QuorumProofContinuation { + anchor: QuorumProofState; + states: QuorumProofState[]; +} +"#; + +#[wasm_bindgen] +extern "C" { + #[wasm_bindgen(typescript_type = "QuorumProofState")] + pub type QuorumProofStateJs; + + #[wasm_bindgen(typescript_type = "QuorumProofContinuation")] + pub type QuorumProofContinuationJs; +} + +/// A context whose Platform quorum keys are proven, not trusted. +/// +/// Quorum keys are proven lazily with DIP 175 proofs from a checkpoint, the +/// first time the SDK meets each quorum, so nothing is fetched up front and +/// the factories are synchronous. Mainnet and testnet only. +/// +/// Every factory takes optional `relays`: 1 to 8 distinct relay base URLs +/// whose `/proofs` endpoint serves the proofs, each `https` (plain `http` +/// only on a loopback host). Without them the context asks the network's +/// public quorum service. Relays are untrusted byte sources: a lying relay +/// can delay requests but cannot make the SDK accept a forged key. +/// +/// Where proofs start is trust configuration: the checkpoint embedded in the +/// SDK, a ChainLocked block the app checked independently, or the +/// `continuation()` of an earlier context. A proof grows with the age of the +/// state it starts from, so long-lived apps should save `continuation()` and +/// pass it to `mainnetFromContinuation` / `testnetFromContinuation` on their +/// next start. Do not restart from `latestVerifiedState()` alone: it is often +/// above the Core height Platform reports, and no key at or below it could be +/// proven from it. +/// +/// Attach it with `WasmSdkBuilder.withVerifiedContext(context)`. It discovers +/// no masternode addresses, so the builder keeps the ones it has. +/// +/// The public mainnet relay does not serve proofs yet, so a mainnet context +/// without `relays` builds, but proofs fail until it does. +#[wasm_bindgen] +#[derive(Clone)] +pub struct WasmVerifiedContext { + inner: VerifiedHttpContextProvider, +} + +#[wasm_bindgen] +impl WasmVerifiedContext { + /// A mainnet context starting from the checkpoint embedded in the SDK. + #[wasm_bindgen(js_name = "mainnet")] + pub fn mainnet(relays: Option>) -> Result { + Self::build(Network::Mainnet, None, relays) + } + + /// A testnet context starting from the checkpoint embedded in the SDK. + #[wasm_bindgen(js_name = "testnet")] + pub fn testnet(relays: Option>) -> Result { + Self::build(Network::Testnet, None, relays) + } + + /// A mainnet context starting from `checkpoint`, a `QuorumProofState` + /// the app checked independently. + #[wasm_bindgen(js_name = "mainnetWithCheckpoint")] + pub fn mainnet_with_checkpoint( + checkpoint: QuorumProofStateJs, + relays: Option>, + ) -> Result { + Self::with_checkpoint(Network::Mainnet, &checkpoint, relays) + } + + /// A testnet context starting from `checkpoint`, a `QuorumProofState` + /// the app checked independently. + #[wasm_bindgen(js_name = "testnetWithCheckpoint")] + pub fn testnet_with_checkpoint( + checkpoint: QuorumProofStateJs, + relays: Option>, + ) -> Result { + Self::with_checkpoint(Network::Testnet, &checkpoint, relays) + } + + /// A mainnet context resuming from `continuation`, what an earlier + /// context's `continuation()` returned. + #[wasm_bindgen(js_name = "mainnetFromContinuation")] + pub fn mainnet_from_continuation( + continuation: QuorumProofContinuationJs, + relays: Option>, + ) -> Result { + Self::from_continuation(Network::Mainnet, &continuation, relays) + } + + /// A testnet context resuming from `continuation`, what an earlier + /// context's `continuation()` returned. + #[wasm_bindgen(js_name = "testnetFromContinuation")] + pub fn testnet_from_continuation( + continuation: QuorumProofContinuationJs, + relays: Option>, + ) -> Result { + Self::from_continuation(Network::Testnet, &continuation, relays) + } + + /// The checkpoint this context started from: the anchor, when it + /// resumed from a continuation. + #[wasm_bindgen(js_name = "checkpoint")] + pub fn checkpoint_js(&self) -> Result { + to_js(self.inner.checkpoint()) + } + + /// The highest state a proof has verified, or the checkpoint before any + /// has. For information; save `continuation()` to resume from instead. + #[wasm_bindgen(js_name = "latestVerifiedState")] + pub fn latest_verified_state_js(&self) -> Result { + to_js(&self.inner.latest_verified_state()) + } + + /// Where this context got to, a `QuorumProofContinuation`: save it once + /// queries have run and pass it to `mainnetFromContinuation` / + /// `testnetFromContinuation` on the next start. + #[wasm_bindgen(js_name = "continuation")] + pub fn continuation_js(&self) -> Result { + to_js(&self.inner.continuation()) + } + + /// The relay base URLs this context asks. + #[wasm_bindgen(js_name = "relays")] + pub fn relays(&self) -> Vec { + self.inner + .sources() + .iter() + .map(|url| url.to_string()) + .collect() + } + + /// Prove a Platform quorum's public key for a Core ChainLocked height, as + /// the SDK does before it verifies a response that quorum signed, and + /// return the 48-byte BLS key. A key already proven for the height is + /// returned without a request. The height is not a verified response's, + /// so it does not move `continuation()`'s anchor. + /// + /// `quorumHash` is hex in RPC display order, as Core's `quorum list` + /// shows it. + #[wasm_bindgen(js_name = "fetchQuorumPublicKey")] + pub async fn fetch_quorum_public_key_js( + &self, + #[wasm_bindgen(js_name = "quorumType")] quorum_type: u32, + #[wasm_bindgen(js_name = "quorumHash")] quorum_hash: String, + #[wasm_bindgen(js_name = "coreChainLockedHeight")] core_chain_locked_height: u32, + ) -> Result, WasmSdkError> { + let quorum_hash: [u8; 32] = hex::decode(&quorum_hash) + .ok() + .and_then(|bytes| bytes.try_into().ok()) + .ok_or_else(|| { + WasmSdkError::invalid_argument(format!( + "quorumHash must be 32 bytes of hex, got '{quorum_hash}'" + )) + })?; + self.prove_quorum_public_key(quorum_type, quorum_hash, core_chain_locked_height) + .await + .map(Vec::from) + } +} + +impl WasmVerifiedContext { + /// The provider the builder attaches and the SDK caches into. + pub(crate) fn provider(&self) -> &VerifiedHttpContextProvider { + &self.inner + } + + fn with_checkpoint( + network: Network, + checkpoint: &JsValue, + relays: Option>, + ) -> Result { + let checkpoint = from_js(checkpoint, "checkpoint", "a QuorumProofState")?; + Self::build(network, Some(StartingPoint::Checkpoint(checkpoint)), relays) + } + + fn from_continuation( + network: Network, + continuation: &JsValue, + relays: Option>, + ) -> Result { + let continuation = from_js(continuation, "continuation", "a QuorumProofContinuation")?; + Self::build( + network, + Some(StartingPoint::Continuation(continuation)), + relays, + ) + } + + /// The provider supplies every default left `None`: the embedded + /// checkpoint, the network's public quorum service and the key cache. + fn build( + network: Network, + start: Option, + relays: Option>, + ) -> Result { + VerifiedHttpContextProvider::with_options( + network, + VerifiedProviderOptions { + start, + sources: relays, + cache_size: None, + }, + ) + .map(|inner| Self { inner }) + .map_err(construction_error) + } + + /// The fetch the SDK runs for a quorum it has no key for. When the + /// provider declines to fetch (a quorum type or height it never proves), + /// the synchronous lookup says why. Failures keep the SDK's error kind, + /// so an unavailable relay stays retriable. + async fn prove_quorum_public_key( + &self, + quorum_type: u32, + quorum_hash: [u8; 32], + core_chain_locked_height: u32, + ) -> Result<[u8; 48], WasmSdkError> { + let proven = match self.inner.fetch_quorum_public_key( + quorum_type, + quorum_hash, + core_chain_locked_height, + ) { + Some(fetch) => fetch.await, + None => self + .inner + .get_quorum_public_key(quorum_type, quorum_hash, core_chain_locked_height) + .map(Some), + }; + match proven { + Ok(Some(key)) => Ok(key), + Ok(None) => Err(WasmSdkError::not_found(format!( + "quorum {} of type {quorum_type} was not found", + hex::encode(quorum_hash) + ))), + Err(error) => Err(WasmSdkError::with_context( + "Failed to prove the quorum public key", + SdkError::from(error), + )), + } + } +} + +/// Every construction failure is the caller's input (network, starting +/// point, relays) except an HTTP client that cannot be built. +fn construction_error(error: VerifiedContextProviderError) -> WasmSdkError { + let message = format!("Failed to create verified context: {error}"); + match error { + VerifiedContextProviderError::Http(_) => WasmSdkError::generic(message), + _ => WasmSdkError::invalid_argument(message), + } +} + +/// A value from its JSON text, in the form its serde defines: Core's for a +/// state, and `{ anchor, states }` of those for a continuation. +fn from_json(json: &str, what: &str) -> Result { + serde_json::from_str(json) + .map_err(|error| WasmSdkError::invalid_argument(format!("Invalid {what}: {error}"))) +} + +/// A value as JSON text, in the form `from_json` reads. +fn to_json(value: &T) -> Result { + serde_json::to_string(value).map_err(|error| { + WasmSdkError::serialization(format!("Failed to serialize quorum proof data: {error}")) + }) +} + +/// Crosses through `JSON`, so the object takes exactly the form serde uses. +fn from_js( + value: &JsValue, + what: &str, + expected: &str, +) -> Result { + if !value.is_object() { + return Err(WasmSdkError::invalid_argument(format!( + "{what} must be {expected} object" + ))); + } + let json = JSON::stringify(value) + .ok() + .and_then(|json| json.as_string()) + .ok_or_else(|| { + WasmSdkError::invalid_argument(format!("{what} cannot be converted to JSON")) + })?; + from_json(&json, what) +} + +fn to_js(value: &T) -> Result { + JSON::parse(&to_json(value)?) + .map(JsCast::unchecked_into) + .map_err(|_| WasmSdkError::serialization("Failed to convert quorum proof data")) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::error::WasmSdkErrorKind; + use crate::sdk::WasmSdkBuilder; + use dash_core_proof::State; + use dash_sdk::dpp::data_contract::accessors::v0::DataContractV0Getters; + use dash_sdk::dpp::system_data_contracts::{load_system_data_contract, SystemDataContract}; + use dash_sdk::dpp::version::PlatformVersion; + use rs_sdk_trusted_context_provider::{get_quorum_base_url, Continuation}; + use std::io::{BufRead, BufReader, Read, Write}; + use std::net::TcpListener; + use std::sync::Arc; + use std::thread::{self, JoinHandle}; + + const PROOF_TESTNET: &str = + include_str!("../../rs-sdk-trusted-context-provider/tests/data/proof-testnet.json"); + + /// The quorum the testnet fixture proof opens, its hash in RPC display order, and its + /// key; `tests/unit/verified-context.spec.ts` uses the same values. + pub(super) const FIXTURE_QUORUM: (u8, &str, &str) = ( + 6, + "0000001b381f694e98ab6efcf1f380aba750bd6b4f70f48f9e8818cff2a64af6", + "b813d560b9a73db7b01cfe80ee7ab20a4af7ccda4b94a0cd8ceda8f5cb0fa604760430ef8c644e0f823e089aa7839f93", + ); + + /// A state of the testnet fixture: `checkpoint` or `target`. + pub(super) fn fixture_state(key: &str) -> State { + let fixture: serde_json::Value = serde_json::from_str(PROOF_TESTNET).expect("fixture"); + from_json(&fixture[key].to_string(), key).expect(key) + } + + /// A context starting from `checkpoint`, as `*WithCheckpoint` builds one. + pub(super) fn with_checkpoint( + network: Network, + checkpoint: &State, + relays: Option>, + ) -> Result { + WasmVerifiedContext::build( + network, + Some(StartingPoint::Checkpoint(checkpoint.clone())), + relays, + ) + } + + /// The testnet fixture's proof, from `fixture_state("checkpoint")` to + /// `fixture_state("target")`, as `getquorumproofchain` returned it. + pub(super) fn fixture_proof() -> Vec { + let fixture: serde_json::Value = serde_json::from_str(PROOF_TESTNET).expect("fixture"); + hex::decode(fixture["bootstrap_hex"].as_str().expect("bootstrap_hex")).expect("hex") + } + + /// The fixture's quorum, read from the proof itself. + #[test] + fn should_name_the_quorum_the_fixture_proof_opens() { + let proof = fixture_proof(); + let verified = + dash_core_proof::bootstrap::verify(&proof, &fixture_state("checkpoint"), 0).unwrap(); + let commitment = dash_core_proof::parse_commitment(verified.records()[0].leaf).unwrap(); + let mut quorum_hash = commitment.quorum_hash; + quorum_hash.reverse(); + assert_eq!( + ( + commitment.quorum_type, + hex::encode(quorum_hash).as_str(), + hex::encode(commitment.public_key).as_str() + ), + FIXTURE_QUORUM + ); + } + + #[test] + fn should_start_from_the_embedded_checkpoint_before_any_proof() { + for (network, context) in [ + (Network::Mainnet, WasmVerifiedContext::mainnet(None)), + (Network::Testnet, WasmVerifiedContext::testnet(None)), + ] { + let context = context.expect("the embedded checkpoint builds"); + let embedded = + VerifiedHttpContextProvider::embedded_checkpoint(network).expect("embedded"); + assert_eq!(ContextProvider::network(context.provider()), Some(network)); + assert_eq!(context.provider().checkpoint(), &embedded); + assert_eq!(context.provider().latest_verified_state(), embedded); + assert_eq!( + context.relays(), + vec![format!("{}/", get_quorum_base_url(network, None).unwrap())] + ); + } + } + + #[test] + fn should_round_trip_a_state_through_its_json_form() { + let target = fixture_state("target"); + let json = to_json(&target).expect("serializes"); + assert_eq!(from_json::(&json, "state").expect("parses"), target); + let value: serde_json::Value = serde_json::from_str(&json).expect("JSON"); + assert_eq!(value["network"], 1); + assert_eq!(value["height"], 1_576_768); + assert_eq!( + value["block_hash"], + "00000021a51acdce0ba4c5f1bff3fd121143e7ab958f9f30bd1de4856b449285" + ); + } + + #[test] + fn should_reject_a_malformed_checkpoint_as_an_invalid_argument() { + for json in [ + r#"{"network":1}"#, + r#"{"network":1,"height":1576768,"block_hash":"zz","masternode_root":"00","quorum_root":"00"}"#, + ] { + let error = from_json::(json, "checkpoint").expect_err(json); + assert_eq!(error.kind(), WasmSdkErrorKind::InvalidArgument, "{json}"); + } + let mut extra: serde_json::Value = serde_json::from_str(PROOF_TESTNET).unwrap(); + extra["target"]["extra"] = 1.into(); + assert!(from_json::(&extra["target"].to_string(), "checkpoint").is_err()); + } + + #[test] + fn should_reject_a_checkpoint_of_the_other_network() { + let testnet = fixture_state("checkpoint"); + let error = with_checkpoint(Network::Mainnet, &testnet, None) + .err() + .expect("a testnet checkpoint cannot start a mainnet context"); + assert_eq!(error.kind(), WasmSdkErrorKind::InvalidArgument); + assert!( + error.message().contains("checkpoint"), + "{}", + error.message() + ); + } + + #[test] + fn should_reject_relays_the_provider_may_not_ask() { + let checkpoint = fixture_state("checkpoint"); + for relays in [ + vec![], + vec!["http://relay.example".to_string()], + vec!["not a url".to_string()], + vec![ + "https://relay.example".to_string(), + "https://relay.example/".to_string(), + ], + (0..9) + .map(|i| format!("https://relay{i}.example")) + .collect(), + ] { + let error = with_checkpoint(Network::Testnet, &checkpoint, Some(relays)) + .err() + .expect("refused"); + assert_eq!(error.kind(), WasmSdkErrorKind::InvalidArgument); + assert!( + error.message().contains("proof source"), + "{}", + error.message() + ); + } + } + + #[test] + fn should_use_a_custom_checkpoint_and_relays() { + let checkpoint = fixture_state("checkpoint"); + let context = with_checkpoint( + Network::Testnet, + &checkpoint, + Some(vec![ + "https://relay.example".to_string(), + "http://127.0.0.1:8080/base".to_string(), + ]), + ) + .expect("builds"); + assert_eq!(context.provider().latest_verified_state(), checkpoint); + assert_eq!( + context.relays(), + vec!["https://relay.example/", "http://127.0.0.1:8080/base"] + ); + let default_relay = with_checkpoint(Network::Testnet, &checkpoint, None) + .expect("builds with the public relay"); + assert_eq!( + default_relay.relays(), + vec!["https://quorums.testnet.networks.dash.org/"] + ); + } + + #[tokio::test] + async fn should_report_a_quorum_type_it_never_proves_without_a_request() { + // Nothing listens on the relay's port, so a request would fail as + // retriable rather than as the provider's refusal. + let port = TcpListener::bind("127.0.0.1:0") + .and_then(|listener| listener.local_addr()) + .expect("a free port") + .port(); + let context = with_checkpoint( + Network::Testnet, + &fixture_state("checkpoint"), + Some(vec![format!("http://127.0.0.1:{port}")]), + ) + .expect("builds"); + // Type 1 is not testnet's Platform quorum type, so nothing is fetched. + let error = context + .prove_quorum_public_key(1, [7; 32], fixture_state("target").height) + .await + .expect_err("never proven"); + assert_eq!(error.kind(), WasmSdkErrorKind::ContextProviderError); + assert!(!error.is_retriable(), "{}", error.message()); + assert!( + error + .message() + .contains("is not a Platform quorum this provider verifies"), + "{}", + error.message() + ); + } + + /// A relay that cannot be reached is the SDK's retriable "source + /// unavailable", not a final failure. + #[tokio::test] + async fn should_report_an_unreachable_relay_as_retriable() { + // Bound and dropped, so nothing listens on the port. + let port = TcpListener::bind("127.0.0.1:0") + .and_then(|listener| listener.local_addr()) + .expect("a free port") + .port(); + let context = with_checkpoint( + Network::Testnet, + &fixture_state("checkpoint"), + Some(vec![format!("http://127.0.0.1:{port}")]), + ) + .expect("builds"); + let (quorum_type, quorum_hash, _) = FIXTURE_QUORUM; + let quorum_hash: [u8; 32] = hex::decode(quorum_hash).unwrap().try_into().unwrap(); + + let error = context + .prove_quorum_public_key( + u32::from(quorum_type), + quorum_hash, + fixture_state("target").height, + ) + .await + .expect_err("nothing answers"); + + assert_eq!(error.kind(), WasmSdkErrorKind::ContextProviderError); + assert!(error.is_retriable(), "{}", error.message()); + } + + /// Answer one `POST /proofs` on a loopback port with `body`, and hand back + /// the request's JSON. + fn spawn_relay(body: Vec) -> (String, JoinHandle) { + let listener = TcpListener::bind("127.0.0.1:0").expect("bind the mock relay"); + let url = format!("http://{}", listener.local_addr().expect("address")); + let handle = thread::spawn(move || { + let (mut stream, _) = listener.accept().expect("the provider connects"); + let mut reader = BufReader::new(stream.try_clone().expect("clone")); + let mut line = String::new(); + reader.read_line(&mut line).expect("request line"); + assert!(line.starts_with("POST /proofs "), "{line}"); + let mut length = 0; + loop { + let mut header = String::new(); + reader.read_line(&mut header).expect("header"); + if header == "\r\n" { + break; + } + if let Some((name, value)) = header.split_once(':') { + if name.eq_ignore_ascii_case("content-length") { + length = value.trim().parse().expect("length"); + } + } + } + let mut request = vec![0; length]; + reader.read_exact(&mut request).expect("request body"); + write!( + stream, + "HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", + body.len() + ) + .expect("write the head"); + stream.write_all(&body).expect("write the proof"); + serde_json::from_slice(&request).expect("a JSON request") + }); + (url, handle) + } + + /// Through the SDK it is attached to, the verified context asks a relay + /// for the proof of a quorum it has no key for and serves the key the + /// proof opens. The relay is a loopback mock serving the testnet fixture. + #[tokio::test] + async fn should_prove_a_key_from_a_relay_through_the_sdk() { + let (relay, server) = spawn_relay(fixture_proof()); + let target = fixture_state("target"); + let context = with_checkpoint( + Network::Testnet, + &fixture_state("checkpoint"), + Some(vec![relay]), + ) + .expect("builds"); + let sdk = WasmSdkBuilder::new_testnet() + .with_verified_context(&context) + .build() + .expect("build"); + let provider = sdk + .inner_sdk() + .context_provider() + .expect("the verified context is the SDK's provider"); + let (quorum_type, quorum_hash, key) = FIXTURE_QUORUM; + let quorum_hash: [u8; 32] = hex::decode(quorum_hash).unwrap().try_into().unwrap(); + + let proven = provider + .fetch_quorum_public_key(u32::from(quorum_type), quorum_hash, target.height) + .expect("a Platform quorum is fetched") + .await + .expect("the proof verifies"); + + assert_eq!(proven.map(hex::encode).as_deref(), Some(key)); + assert_eq!(context.provider().latest_verified_state(), target); + let request = server.join().expect("the relay answered"); + assert_eq!(request["height"], target.height); + assert_eq!(request["llmqType"], u32::from(quorum_type)); + } + + /// The saved continuation crosses through its JSON form and resumes where + /// the last session got to: the next context proves the same key at the + /// same height through the relay. + #[tokio::test] + async fn should_prove_a_key_at_its_height_again_from_a_saved_continuation() { + let (quorum_type, quorum_hash, key) = FIXTURE_QUORUM; + let quorum_hash: [u8; 32] = hex::decode(quorum_hash).unwrap().try_into().unwrap(); + let target = fixture_state("target"); + let (relay, server) = spawn_relay(fixture_proof()); + let first = with_checkpoint( + Network::Testnet, + &fixture_state("checkpoint"), + Some(vec![relay]), + ) + .expect("builds"); + first + .prove_quorum_public_key(u32::from(quorum_type), quorum_hash, target.height) + .await + .expect("the proof verifies"); + server.join().expect("the relay answered"); + + // What an app saves: the continuation's JSON form. + let saved = to_json(&first.provider().continuation()).expect("serializes"); + let value: serde_json::Value = serde_json::from_str(&saved).expect("JSON"); + assert_eq!( + value["anchor"]["height"], + fixture_state("checkpoint").height + ); + assert_eq!(value["states"][0]["height"], target.height); + let continuation: Continuation = from_json(&saved, "continuation").expect("parses"); + + let (relay, server) = spawn_relay(fixture_proof()); + let next = WasmVerifiedContext::build( + Network::Testnet, + Some(StartingPoint::Continuation(continuation)), + Some(vec![relay]), + ) + .expect("resumes"); + let proven = next + .prove_quorum_public_key(u32::from(quorum_type), quorum_hash, target.height) + .await + .expect("proven again"); + + assert_eq!(hex::encode(proven), key); + assert_eq!(server.join().expect("asked")["height"], target.height); + assert_eq!(next.provider().latest_verified_state(), target); + } + + #[test] + fn should_reject_a_continuation_it_cannot_resume_from() { + let checkpoint = fixture_state("checkpoint"); + // The provider checks the states; the binding reports its refusal as + // the caller's input. + let error = WasmVerifiedContext::build( + Network::Testnet, + Some(StartingPoint::Continuation(Continuation { + anchor: fixture_state("target"), + states: vec![checkpoint], + })), + None, + ) + .err() + .expect("a state below the anchor is refused"); + assert_eq!(error.kind(), WasmSdkErrorKind::InvalidArgument); + for json in [ + r#"{"anchor":{}}"#, + r#"{"states":[]}"#, + r#"{"anchor":null,"states":[],"extra":1}"#, + ] { + let error = from_json::(json, "continuation").expect_err(json); + assert_eq!(error.kind(), WasmSdkErrorKind::InvalidArgument, "{json}"); + } + } + + /// A contract the SDK registers replaces the one the known-contracts cache + /// held, as with the trusted context. + #[test] + fn should_forward_a_registered_contract_to_the_known_contracts_cache() { + let context = WasmVerifiedContext::testnet(None).expect("builds"); + let sdk = WasmSdkBuilder::new_testnet() + .with_verified_context(&context) + .build() + .expect("build"); + let provider = sdk + .inner_sdk() + .context_provider() + .expect("the verified context is the SDK's provider"); + let dpns_v2 = load_system_data_contract( + SystemDataContract::DPNS, + PlatformVersion::get(13).expect("protocol version 13"), + ) + .expect("DPNS v2 loads"); + let dpns_v3 = + load_system_data_contract(SystemDataContract::DPNS, PlatformVersion::latest()) + .expect("DPNS v3 loads"); + context.provider().add_known_contract(dpns_v2); + + provider.register_data_contract(Arc::new(dpns_v3.clone())); + + let resolved = provider + .get_data_contract(&dpns_v3.id(), PlatformVersion::latest()) + .expect("the context answers") + .expect("the context holds DPNS"); + assert_eq!(*resolved, dpns_v3); + } +} + +#[cfg(all(test, target_arch = "wasm32"))] +mod wasm_tests { + use super::tests::{fixture_proof, fixture_state, with_checkpoint, FIXTURE_QUORUM}; + use super::*; + use js_sys::{Function, Reflect, Uint8Array}; + use wasm_bindgen_test::wasm_bindgen_test; + + /// The browser's `fetch` carries the proof request to a relay, and the + /// context returns the key the proof opens. The relay is the global + /// `fetch`, replaced to serve the testnet fixture proof, so no network is + /// touched. + #[wasm_bindgen_test] + async fn should_prove_a_key_through_the_browser_fetch_transport() { + let global = js_sys::global(); + Reflect::set( + &global, + &JsValue::from_str("__proof"), + &Uint8Array::from(fixture_proof().as_slice()), + ) + .expect("hand the proof to the stub"); + let fetch = Function::new_with_args( + "request", + "globalThis.__relayed = request.url; \ + return Promise.resolve(new Response(globalThis.__proof, { status: 200 }));", + ); + let original = Reflect::get(&global, &JsValue::from_str("fetch")).expect("the fetch"); + Reflect::set(&global, &JsValue::from_str("fetch"), &fetch).expect("replace the fetch"); + + let target = fixture_state("target"); + let context = with_checkpoint( + Network::Testnet, + &fixture_state("checkpoint"), + Some(vec!["https://relay.test".to_string()]), + ) + .expect("builds"); + let (quorum_type, quorum_hash, key) = FIXTURE_QUORUM; + let proven = context + .fetch_quorum_public_key_js( + u32::from(quorum_type), + quorum_hash.to_string(), + target.height, + ) + .await; + // Later tests in the same wasm module use the real fetch. + Reflect::set(&global, &JsValue::from_str("fetch"), &original).expect("restore the fetch"); + + assert_eq!(hex::encode(proven.expect("the proof verifies")), key); + assert_eq!(context.provider().latest_verified_state(), target); + let relayed = Reflect::get(&global, &JsValue::from_str("__relayed")).expect("asked"); + assert_eq!( + relayed.as_string().as_deref(), + Some("https://relay.test/proofs") + ); + } +} diff --git a/packages/wasm-sdk/tests/unit/verified-context.spec.ts b/packages/wasm-sdk/tests/unit/verified-context.spec.ts new file mode 100644 index 00000000000..f11724b7161 --- /dev/null +++ b/packages/wasm-sdk/tests/unit/verified-context.spec.ts @@ -0,0 +1,285 @@ +import { expect } from './helpers/chai.ts'; +import init, * as sdk from '../../dist/sdk.compressed.js'; +import proof from '../../../rs-sdk-trusted-context-provider/tests/data/proof-testnet.json' with { type: 'json' }; + +// RFC 6761 reserved test domain: `fetch` is replaced, so nothing is dialled. +const RELAY = 'https://relay.test'; +// The quorum the testnet fixture proof opens: its type, its hash in RPC display order and +// its key (the Rust test `should_name_the_quorum_the_fixture_proof_opens` reads them from +// the proof). +const QUORUM_TYPE = 6; +const QUORUM_HASH = '0000001b381f694e98ab6efcf1f380aba750bd6b4f70f48f9e8818cff2a64af6'; +const QUORUM_KEY = 'b813d560b9a73db7b01cfe80ee7ab20a4af7ccda4b94a0cd8ceda8f5cb0fa604760430ef8c644e0f823e089aa7839f93'; + +function fromHex(hex: string): Uint8Array { + const bytes = new Uint8Array(hex.length / 2); + for (let i = 0; i < bytes.length; i += 1) { + bytes[i] = parseInt(hex.substr(i * 2, 2), 16); + } + return bytes; +} + +function toHex(bytes: Uint8Array): string { + return Array.from(bytes, (byte) => byte.toString(16).padStart(2, '0')).join(''); +} + +/** The message a promise rejects with; wasm-bindgen rejects with non-Error objects. */ +async function rejection(promise: Promise): Promise { + try { + await promise; + } catch (error) { + return (error as { message?: string })?.message ?? String(error); + } + throw new Error('expected a rejection'); +} + +describe('WasmVerifiedContext', () => { + before(async () => { + await init(); + }); + + describe('with the embedded checkpoint', () => { + it('should start from the embedded checkpoint before any proof', () => { + for (const [factory, network] of [ + [() => sdk.WasmVerifiedContext.testnet(), 1], + [() => sdk.WasmVerifiedContext.mainnet(), 0], + ] as const) { + const context = factory(); + const checkpoint = context.checkpoint(); + expect(checkpoint.network).to.equal(network); + expect(checkpoint.height).to.be.a('number'); + expect(checkpoint.block_hash).to.match(/^[0-9a-f]{64}$/); + expect(context.latestVerifiedState()).to.deep.equal(checkpoint); + expect(context.continuation()).to.deep.equal({ anchor: checkpoint, states: [] }); + context.free(); + } + }); + + it('should ask the network\'s public quorum service unless relays are given', () => { + const context = sdk.WasmVerifiedContext.testnet(); + expect(context.relays()).to.deep.equal(['https://quorums.testnet.networks.dash.org/']); + context.free(); + + const custom = sdk.WasmVerifiedContext.testnet([RELAY]); + expect(custom.relays()).to.deep.equal([`${RELAY}/`]); + custom.free(); + }); + + it('should accept a checkpoint and a continuation in their JSON forms', () => { + const embedded = sdk.WasmVerifiedContext.testnet(); + // What an app would store and read back. + const checkpoint = JSON.parse(JSON.stringify(embedded.checkpoint())); + const fromCheckpoint = sdk.WasmVerifiedContext.testnetWithCheckpoint(checkpoint); + expect(fromCheckpoint.checkpoint()).to.deep.equal(embedded.checkpoint()); + const continuation = JSON.parse(JSON.stringify(embedded.continuation())); + const resumed = sdk.WasmVerifiedContext.testnetFromContinuation(continuation); + expect(resumed.continuation()).to.deep.equal(continuation); + embedded.free(); + fromCheckpoint.free(); + resumed.free(); + }); + }); + + describe('invalid input', () => { + it('should reject a checkpoint that is not an object', () => { + expect(() => sdk.WasmVerifiedContext.testnetWithCheckpoint( + JSON.stringify(proof.checkpoint) as unknown as sdk.QuorumProofState, + )).to.throw(/QuorumProofState object/); + }); + + it('should reject a malformed checkpoint', () => { + expect(() => sdk.WasmVerifiedContext.testnetWithCheckpoint( + { ...proof.checkpoint, block_hash: 'zz' }, + )).to.throw(/Invalid checkpoint/); + expect(() => sdk.WasmVerifiedContext.testnetWithCheckpoint( + { ...proof.checkpoint, extra: 1 } as unknown as sdk.QuorumProofState, + )).to.throw(/Invalid checkpoint/); + }); + + it('should reject a continuation it cannot resume from', () => { + expect(() => sdk.WasmVerifiedContext.testnetFromContinuation( + JSON.stringify({ anchor: proof.checkpoint, states: [] }) as unknown as sdk.QuorumProofContinuation, + )).to.throw(/QuorumProofContinuation object/); + expect(() => sdk.WasmVerifiedContext.testnetFromContinuation( + { anchor: proof.checkpoint } as unknown as sdk.QuorumProofContinuation, + )).to.throw(/Invalid continuation/); + // A state must lie above the anchor. + expect(() => sdk.WasmVerifiedContext.testnetFromContinuation( + { anchor: proof.target, states: [proof.checkpoint] }, + )).to.throw(/not above the anchor/); + }); + + it('should reject a checkpoint of the other network', () => { + expect(() => sdk.WasmVerifiedContext.mainnetWithCheckpoint(proof.checkpoint, [RELAY])) + .to.throw(/checkpoint/); + }); + + // The provider's own tests cover which relays it refuses; this checks the refusal throws. + it('should reject a relay the provider may not ask', () => { + expect(() => sdk.WasmVerifiedContext.testnetWithCheckpoint(proof.checkpoint, ['http://relay.test'])) + .to.throw(/proof source/); + }); + + it('should reject a quorum hash that is not 32 bytes of hex', async () => { + const context = sdk.WasmVerifiedContext.testnet([RELAY]); + expect(await rejection(context.fetchQuorumPublicKey(QUORUM_TYPE, 'abcd', 1))) + .to.match(/quorumHash must be 32 bytes of hex/); + context.free(); + }); + }); + + describe('WasmSdkBuilder.withVerifiedContext()', () => { + it('should refuse to build with a context of another network', () => { + const context = sdk.WasmVerifiedContext.mainnet(); + const builder = sdk.WasmSdkBuilder.testnet().withVerifiedContext(context); + expect(() => builder.build()).to.throw(/Mainnet/); + context.free(); + }); + + it('should refuse to build a verified SDK with proofs turned off', () => { + const context = sdk.WasmVerifiedContext.testnet(); + const builder = sdk.WasmSdkBuilder.testnet().withVerifiedContext(context).withProofs(false); + expect(() => builder.build()).to.throw(/requires proofs/); + context.free(); + }); + + it('should build an SDK with the verified context attached', () => { + const context = sdk.WasmVerifiedContext.testnet(); + const built = sdk.WasmSdkBuilder.testnet().withVerifiedContext(context).build(); + expect(built).to.be.ok(); + built.free(); + // The builder keeps a clone: the caller's context stays usable. + expect(context.latestVerifiedState()).to.deep.equal(context.checkpoint()); + context.free(); + }); + + describe('with a trusted context', () => { + const originalFetch = globalThis.fetch; + let trusted: sdk.WasmTrustedContext; + + before(async () => { + // An empty quorum service, enough to prefetch a trusted context. + globalThis.fetch = async (input: RequestInfo | URL, options?: RequestInit) => { + const request = new Request(input, options); + const data = new URL(request.url).pathname === '/previous' ? { height: 1, quorums: [] } : []; + const response = new Response(JSON.stringify({ success: true, data }), { + headers: { 'content-type': 'application/json' }, + }); + // Synthetic Fetch responses must carry the request URL, as real responses do. + Object.defineProperty(response, 'url', { value: request.url }); + return response; + }; + try { + trusted = await sdk.WasmTrustedContext.prefetchTestnetWithUrl('https://quorums.test', false); + } finally { + globalThis.fetch = originalFetch; + } + }); + + // As in the Rust SDK's builder, the context attached last replaces the + // one before it, of either kind. + it('should build with either kind of context attached after the other', () => { + const context = sdk.WasmVerifiedContext.testnet(); + const verifiedLast = sdk.WasmSdkBuilder.testnet() + .withTrustedContext(trusted) + .withVerifiedContext(context) + .build(); + const trustedLast = sdk.WasmSdkBuilder.testnet() + .withVerifiedContext(context) + .withTrustedContext(trusted) + .build(); + expect(verifiedLast).to.be.an.instanceof(sdk.WasmSdk); + expect(trustedLast).to.be.an.instanceof(sdk.WasmSdk); + verifiedLast.free(); + trustedLast.free(); + context.free(); + }); + }); + }); + + // Goes through the browser's `fetch` end to end: the provider POSTs the proof + // request, reads the streamed body, verifies the proof and opens the key. + describe('fetchQuorumPublicKey() through fetch', () => { + const originalFetch = globalThis.fetch; + let requests: { url: string; method: string; body: Record }[]; + + beforeEach(() => { + requests = []; + globalThis.fetch = async (input: RequestInfo | URL, options?: RequestInit) => { + const request = new Request(input, options); + requests.push({ url: request.url, method: request.method, body: await request.json() }); + return new Response(fromHex(proof.bootstrap_hex), { status: 200 }); + }; + }); + + afterEach(() => { + globalThis.fetch = originalFetch; + }); + + it('should prove the key a relay\'s proof opens and advance the latest verified state', async () => { + const context = sdk.WasmVerifiedContext.testnetWithCheckpoint(proof.checkpoint, [RELAY]); + expect(context.latestVerifiedState()).to.deep.equal(proof.checkpoint); + + const started = performance.now(); + const key = await context.fetchQuorumPublicKey(QUORUM_TYPE, QUORUM_HASH, proof.target.height); + // The proof is verified inside this call, on the event loop (#5706). + // eslint-disable-next-line no-console + console.log(`fetchQuorumPublicKey with a 4-link testnet proof: ${(performance.now() - started).toFixed(1)} ms`); + + expect(toHex(key)).to.equal(QUORUM_KEY); + expect(context.latestVerifiedState()).to.deep.equal(proof.target); + expect(requests).to.have.length(1); + expect(requests[0].url).to.equal(`${RELAY}/proofs`); + expect(requests[0].method).to.equal('POST'); + expect(requests[0].body).to.include({ + height: proof.target.height, + quorumHash: QUORUM_HASH, + llmqType: QUORUM_TYPE, + }); + + // A key already proven for the height is served without another request. + const again = await context.fetchQuorumPublicKey(QUORUM_TYPE, QUORUM_HASH, proof.target.height); + expect(toHex(again)).to.equal(QUORUM_KEY); + expect(requests).to.have.length(1); + context.free(); + }); + + it('should prove the same key at the same height again after resuming from the continuation', async () => { + const first = sdk.WasmVerifiedContext.testnetWithCheckpoint(proof.checkpoint, [RELAY]); + await first.fetchQuorumPublicKey(QUORUM_TYPE, QUORUM_HASH, proof.target.height); + // No response was verified, so the checkpoint anchors; the target is kept above it. + expect(first.continuation()).to.deep.equal({ anchor: proof.checkpoint, states: [proof.target] }); + const saved = JSON.parse(JSON.stringify(first.continuation())); + first.free(); + + const next = sdk.WasmVerifiedContext.testnetFromContinuation(saved, [RELAY]); + const key = await next.fetchQuorumPublicKey(QUORUM_TYPE, QUORUM_HASH, proof.target.height); + + expect(toHex(key)).to.equal(QUORUM_KEY); + expect(requests).to.have.length(2); + expect(requests[1].body).to.include({ + checkpoint: proof.checkpoint.block_hash, + height: proof.target.height, + }); + next.free(); + }); + + it('should not prove a key at the height of a lone saved state', async () => { + // What restarting from `latestVerifiedState()` alone would do. + const stranded = sdk.WasmVerifiedContext.testnetWithCheckpoint(proof.target, [RELAY]); + expect(await rejection(stranded.fetchQuorumPublicKey(QUORUM_TYPE, QUORUM_HASH, proof.target.height))) + .to.match(/no verified state is below/); + expect(requests).to.have.length(0); + stranded.free(); + }); + + it('should report a proof that does not verify', async () => { + globalThis.fetch = async () => new Response(new Uint8Array([1, 2, 3]), { status: 200 }); + const context = sdk.WasmVerifiedContext.testnetWithCheckpoint(proof.checkpoint, [RELAY]); + expect(await rejection(context.fetchQuorumPublicKey(QUORUM_TYPE, QUORUM_HASH, proof.target.height))) + .to.match(/Failed to prove the quorum public key/); + expect(context.latestVerifiedState()).to.deep.equal(proof.checkpoint); + context.free(); + }); + }); +});