From 3b8cf24ffe7c94db2d7de6fefe50a63d0646690a Mon Sep 17 00:00:00 2001 From: Branislav Kontur Date: Thu, 4 Jun 2026 10:21:09 +0200 Subject: [PATCH 01/10] perf(ci): build common artifacts once, run the two test suites in parallel The integration-tests and sc-integration-tests jobs each rebuilt the runtime and provider before running their demos. Factor the shared compilation into a single 'build' job and have both consume its artifact: build Compile the COMMON stuff once: every runtime wasm (looping the runtimes matrix file, for zombienet's chain_spec_command) and the provider node, uploaded as one 'build' artifact. integration-tests Download the artifact, spawn its own chain + inmemory and disk providers, run the L0 / fs / s3 / papi demos. fs/s3 still 'cargo run' their examples (incremental off the restored build cache). sc-integration-tests Download the artifact, install solc/resolc + build the example contracts, spawn its own chain + inmemory provider, run the smart-contract demos. The two test jobs run in parallel and skip the runtime/provider build. Demos stay sequential within each job (drain-tx-pool-then preserved). --- .github/workflows/integration-tests.yml | 127 ++++++++++++++++++------ 1 file changed, 95 insertions(+), 32 deletions(-) diff --git a/.github/workflows/integration-tests.yml b/.github/workflows/integration-tests.yml index b501c6c4..6cf31af4 100644 --- a/.github/workflows/integration-tests.yml +++ b/.github/workflows/integration-tests.yml @@ -33,17 +33,22 @@ jobs: TASKS=$(echo "$TASKS" | jq -c .) echo "runtime=$TASKS" >> $GITHUB_OUTPUT - integration-tests: - name: Integration Tests (${{ matrix.runtime.name }}) + # ── Build once ──────────────────────────────────────────────────────────── + # All Rust + contract compilation happens here, exactly once per runtime, and + # is published as a single artifact the two test jobs consume — so neither + # integration-tests nor sc-integration-tests compiles anything. Includes: + # - the runtime wasm (zombienet's chain_spec_command reads it), + # - the provider node, + # - the fs/s3 ci_integration_test example binaries (run directly instead of + # `cargo run`), + # - the example Solidity contracts (consumed by the sc demos). + build: + name: Build runs-on: parity-large timeout-minutes: 60 - needs: [set-image, runtime-matrix] + needs: [set-image] container: image: ${{ needs.set-image.outputs.CI_IMAGE }} - strategy: - fail-fast: false - matrix: - runtime: ${{ fromJSON(needs.runtime-matrix.outputs.runtime) }} steps: - name: Checkout sources uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 @@ -54,12 +59,61 @@ jobs: - name: Rust cache uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 with: - shared-key: "integration-tests-${{ matrix.runtime.name }}" + shared-key: "integration-tests-build" save-if: ${{ github.ref == 'refs/heads/main' || github.ref == 'refs/heads/dev' }} - name: Install just run: cargo install just --locked || true + # Build every runtime whose build_command the matrix file declares, so the + # single artifact carries a wasm for each runtime the test jobs fan out on. + - name: Build all runtimes + run: | + for cmd in $(jq -r '.[] | select(.integration_tests == true) | .build_command' scripts/runtimes-matrix.json); do + echo "::group::just $cmd" + just "$cmd" + echo "::endgroup::" + done + + - name: Build provider + run: just build-provider + + - name: Upload build artifacts + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: build + retention-days: 1 + if-no-files-found: error + path: | + target/release/storage-provider-node + target/release/wbuild + + # ── L0 / file-system / S3 / PAPI demos ────────────────────────────────────── + # Runs against its OWN chain + inmemory and disk providers. Demos stay + # sequential on the shared chain (drain-tx-pool-then between them), but the + # whole job now runs in parallel with sc-integration-tests and skips the + # build — both consume the prebuilt artifact. + integration-tests: + name: Integration Tests (${{ matrix.runtime.name }}) + runs-on: parity-large + timeout-minutes: 30 + needs: [set-image, runtime-matrix, build] + container: + image: ${{ needs.set-image.outputs.CI_IMAGE }} + strategy: + fail-fast: false + matrix: + runtime: ${{ fromJSON(needs.runtime-matrix.outputs.runtime) }} + steps: + - name: Checkout sources + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + + - name: Load common environment variables via env file + run: cat .github/env >> $GITHUB_ENV + + - name: Install just + run: cargo install just --locked || true + - name: Cache Polkadot SDK binaries uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5 id: sdk-cache @@ -87,19 +141,28 @@ jobs: if: steps.zombienet-cache.outputs.cache-hit != 'true' run: just zombienet_version=${{ env.ZOMBIENET_VERSION }} download-zombienet - - name: Log binary versions - run: | - .bin/zombienet --version - .bin/polkadot --version - .bin/polkadot-omni-node --version - - name: Setup Node.js uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6 with: node-version: ${{ env.NODE_VERSION }} - - name: Build runtime and provider - run: just ${{ matrix.runtime.build_command }} && just build-provider + # Restored before the artifact download so the cached target/ can't clobber + # the freshly downloaded provider + wbuild. save-if false — only the build + # job writes this cache. + - name: Rust cache + uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 + with: + shared-key: "integration-tests-build" + save-if: false + + - name: Download build artifacts + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v6 + with: + name: build + path: . + + - name: Restore executable bits + run: chmod +x target/release/storage-provider-node - name: Start chain run: | @@ -199,15 +262,15 @@ jobs: /tmp/zombie-*/*-plain.json /tmp/zombie-*/*-raw.json - # Smart-contract demos run in a parallel job with their own chain + - # provider — the sequential time of L0 demos + fs/s3 lifecycles + sc demos - # was bumping against the 60-minute job timeout. Parallel splits the - # wall-clock roughly in half; SC tests only need //Alice as a provider. + # ── Smart-contract demos ───────────────────────────────────────────────────── + # Runs against its OWN chain + inmemory provider, in parallel with + # integration-tests. Contracts come prebuilt in the artifact, so this job no + # longer installs solc/resolc or compiles anything. sc-integration-tests: name: SC Integration Tests (${{ matrix.runtime.name }}) runs-on: parity-large - timeout-minutes: 60 - needs: [set-image, runtime-matrix] + timeout-minutes: 30 + needs: [set-image, runtime-matrix, build] container: image: ${{ needs.set-image.outputs.CI_IMAGE }} strategy: @@ -221,12 +284,6 @@ jobs: - name: Load common environment variables via env file run: cat .github/env >> $GITHUB_ENV - - name: Rust cache - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 - with: - shared-key: "sc-integration-tests-${{ matrix.runtime.name }}" - save-if: ${{ github.ref == 'refs/heads/main' || github.ref == 'refs/heads/dev' }} - - name: Install just run: cargo install just --locked || true @@ -262,6 +319,15 @@ jobs: with: node-version: ${{ env.NODE_VERSION }} + - name: Download build artifacts + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v6 + with: + name: build + path: . + + - name: Restore executable bits + run: chmod +x target/release/storage-provider-node + - name: Install solc and resolc run: | for bin_url in \ @@ -286,9 +352,6 @@ jobs: solc --version resolc --version - - name: Build runtime and provider - run: just ${{ matrix.runtime.build_command }} && just build-provider - - name: Build example contracts run: just build-contracts @@ -362,7 +425,7 @@ jobs: integration-tests-complete: name: Integration Tests - needs: [integration-tests, sc-integration-tests] + needs: [build, integration-tests, sc-integration-tests] if: always() runs-on: ubuntu-latest steps: From 5ee9cb63842993fab121191cb7669d060143f256 Mon Sep 17 00:00:00 2001 From: Branislav Kontur Date: Thu, 4 Jun 2026 12:25:36 +0200 Subject: [PATCH 02/10] fix(ci): restore artifact paths and shrink build artifact The build-once artifact uploads both target/release/storage-provider-node and the runtime wasm under target/release/wbuild, so upload-artifact roots the archive at their least common ancestor (target/release) and strips that prefix. The test jobs downloaded into '.', landing the binary at ./storage-provider-node, so 'chmod +x target/release/storage-provider-node' failed with 'No such file or directory'. Download into target/release so the stripped prefix is restored where chmod, the chain_spec_command scripts, and the demos expect it. Also upload only the final *.compact.compressed.wasm per runtime instead of the entire wbuild Cargo scratch tree, cutting the artifact from ~927 MiB / 3453 files to a handful of files and making the download/extract near-instant. LCA stays target/release, so the download fix covers both. --- .github/workflows/integration-tests.yml | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/.github/workflows/integration-tests.yml b/.github/workflows/integration-tests.yml index 6cf31af4..6edea699 100644 --- a/.github/workflows/integration-tests.yml +++ b/.github/workflows/integration-tests.yml @@ -78,6 +78,13 @@ jobs: - name: Build provider run: just build-provider + # Upload only what the test jobs actually consume: the provider binary and + # the final compressed runtime wasm per runtime (the chain_spec_command + # scripts read target/release/wbuild//.compact.compressed.wasm). + # The full wbuild tree is a ~900 MiB / 3400-file Cargo scratch dir — never + # upload it. Both listed paths sit under target/release, so the artifact's + # least-common-ancestor root is target/release; the download step below + # extracts into target/release to put the prefix back. - name: Upload build artifacts uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: @@ -86,7 +93,7 @@ jobs: if-no-files-found: error path: | target/release/storage-provider-node - target/release/wbuild + target/release/wbuild/*/*.compact.compressed.wasm # ── L0 / file-system / S3 / PAPI demos ────────────────────────────────────── # Runs against its OWN chain + inmemory and disk providers. Demos stay @@ -155,11 +162,15 @@ jobs: shared-key: "integration-tests-build" save-if: false + # Extract into target/release so the artifact's target/release-rooted + # contents (provider binary + wbuild wasm) land back at their original + # paths — where the chmod step, the chain_spec_command scripts, and the + # demos all expect them. - name: Download build artifacts uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v6 with: name: build - path: . + path: target/release - name: Restore executable bits run: chmod +x target/release/storage-provider-node @@ -319,11 +330,13 @@ jobs: with: node-version: ${{ env.NODE_VERSION }} + # Extract into target/release so the artifact's target/release-rooted + # contents land back at their original paths (see integration-tests job). - name: Download build artifacts uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v6 with: name: build - path: . + path: target/release - name: Restore executable bits run: chmod +x target/release/storage-provider-node From 445f9a06b72b6699431d14143bf387f7abd472a4 Mon Sep 17 00:00:00 2001 From: Branislav Kontur Date: Thu, 4 Jun 2026 13:07:12 +0200 Subject: [PATCH 03/10] docs(ci): correct build-once artifact comments The header comment claimed the build artifact carries the fs/s3 example binaries and the Solidity contracts and that the test jobs compile nothing. Neither is true: the artifact holds only the provider binary and the compressed runtime wasm, the fs/s3 demos still 'cargo run' their example crates, and the sc job still installs solc/resolc and builds contracts. Rewrite both comments to describe what actually happens and note the test jobs lean on the shared Rust cache. --- .github/workflows/integration-tests.yml | 23 +++++++++++++---------- 1 file changed, 13 insertions(+), 10 deletions(-) diff --git a/.github/workflows/integration-tests.yml b/.github/workflows/integration-tests.yml index 6edea699..3ca16e71 100644 --- a/.github/workflows/integration-tests.yml +++ b/.github/workflows/integration-tests.yml @@ -34,14 +34,16 @@ jobs: echo "runtime=$TASKS" >> $GITHUB_OUTPUT # ── Build once ──────────────────────────────────────────────────────────── - # All Rust + contract compilation happens here, exactly once per runtime, and - # is published as a single artifact the two test jobs consume — so neither - # integration-tests nor sc-integration-tests compiles anything. Includes: - # - the runtime wasm (zombienet's chain_spec_command reads it), - # - the provider node, - # - the fs/s3 ci_integration_test example binaries (run directly instead of - # `cargo run`), - # - the example Solidity contracts (consumed by the sc demos). + # Compiles the heaviest, shared artifacts a single time and publishes them as + # one artifact both test jobs download instead of each rebuilding from + # scratch. The artifact holds: + # - target/release/storage-provider-node — the provider binary, + # - the *.compact.compressed.wasm runtime per integration-tested runtime, + # which the chain_spec_command scripts feed to chain-spec-builder. + # NOTE: this does not eliminate all compilation downstream — the test jobs + # still `cargo run` the fs/s3 ci_integration_test examples and the sc job + # still builds the Solidity contracts. Those lean on the shared Rust cache + # (shared-key "integration-tests-build"), not on this artifact. build: name: Build runs-on: parity-large @@ -275,8 +277,9 @@ jobs: # ── Smart-contract demos ───────────────────────────────────────────────────── # Runs against its OWN chain + inmemory provider, in parallel with - # integration-tests. Contracts come prebuilt in the artifact, so this job no - # longer installs solc/resolc or compiles anything. + # integration-tests. Consumes the prebuilt provider + runtime wasm from the + # build artifact, but still installs solc/resolc and builds the example + # contracts here (they are cheap and not part of the shared artifact). sc-integration-tests: name: SC Integration Tests (${{ matrix.runtime.name }}) runs-on: parity-large From d3de2d9bffaee9322a1acf5bf9da7c96e539d623 Mon Sep 17 00:00:00 2001 From: Branislav Kontur Date: Thu, 4 Jun 2026 13:27:02 +0200 Subject: [PATCH 04/10] use in-block for tests --- examples/papi/common.js | 44 ++++++++++++++--------------------------- 1 file changed, 15 insertions(+), 29 deletions(-) diff --git a/examples/papi/common.js b/examples/papi/common.js index e638d1b1..20ea5c99 100644 --- a/examples/papi/common.js +++ b/examples/papi/common.js @@ -335,15 +335,22 @@ export async function waitForTransaction( /** * Sign + submit a transaction, assert it dispatched successfully, and return - * the PAPI result (`{ ok, events, block, ... }`). + * the in-block PAPI event (`{ ok, events, block, ... }`). * - * PAPI's bare `signAndSubmit` resolves with `{ ok, events, dispatchError }` - * and does NOT throw when dispatch fails — only when the tx is invalid (bad - * signature, low nonce, etc). Without this helper, a failed extrinsic looks - * indistinguishable from a successful one with no events, and the failure - * surfaces later as a confusing "Expected exactly 1 X event, got 0". + * Resolves at best-block INCLUSION, not finalization. On a local zombienet + * parachain, finality lags inclusion by several blocks (~36s vs ~6s per tx); + * waiting for finalization on every extrinsic is what pushed the integration + * demos past the 30-min CI cap. The demos verify outcomes via the emitted + * events, which are already present at inclusion — the only thing given up is + * reorg-safety, which is moot on a single-collator local chain. * - * Bounded by `timeoutMs` so a stuck mempool can't hang the example. + * Like the finalized path it replaces, this asserts dispatch success: PAPI does + * NOT throw when an extrinsic dispatches with an error (only on an invalid tx — + * bad signature, low nonce, etc), so without this a failed extrinsic looks + * indistinguishable from a successful one with no events and surfaces later as + * a confusing "Expected exactly 1 X event, got 0". `waitForTransaction` raises + * on `ok === false` and is bounded by `timeoutMs` so a stuck mempool can't hang + * the example. */ export async function submitTx( tx, @@ -351,28 +358,7 @@ export async function submitTx( label, timeoutMs = DEFAULT_TX_TIMEOUT_MS ) { - let timer; - const timeoutPromise = new Promise((_, reject) => { - timer = setTimeout( - () => - reject( - new Error(`${label}: signAndSubmit timed out after ${timeoutMs}ms`) - ), - timeoutMs - ); - }); - let result; - try { - result = await Promise.race([tx.signAndSubmit(signer), timeoutPromise]); - } finally { - clearTimeout(timer); - } - if (!result.ok) { - throw new Error( - `${label} dispatch failed: ${formatDispatchError(result.dispatchError)}` - ); - } - return result; + return waitForTransaction(tx, signer, label, TX_MODE_IN_BLOCK, timeoutMs); } export async function providerFetch(providerUrl, path, opts = {}) { From 391f8bf9ca7235001e398aafe102eab2e578623c Mon Sep 17 00:00:00 2001 From: Branislav Kontur Date: Thu, 4 Jun 2026 14:54:44 +0200 Subject: [PATCH 05/10] fix(papi): read demo state at best block, centralised via READ_OPTS submitTx now resolves at in-block inclusion rather than finalization, so a demo that reads state right after a write was racing the ~6-block finality lag: storage reads default to the finalized head, so the just-included write wasn't visible yet (e.g. full-flow's fetchChallengeProof failing with "No challenges at deadline"). Point every post-write read in the PAPI demos at the best (non-finalized) block via a single shared READ_OPTS = { at: "best" } exported from common.js -- one switch for the whole suite (flip to "finalized" there to revert). Covers the CI demos (full-flow/api, s3-lifecycle, drive-lifecycle, sc-flow, sc-coverage, sc-token-gated, common helpers) plus the non-CI demos (checkpoint-rewards, checkpoint-missed, bucket-membership, provider-discovery) for consistency. --- examples/papi/api.js | 8 ++++++- examples/papi/bucket-membership.js | 4 +++- examples/papi/checkpoint-missed.js | 22 ++++++++++++----- examples/papi/checkpoint-rewards.js | 19 ++++++++++----- examples/papi/common.js | 37 ++++++++++++++++++++++++----- examples/papi/drive-lifecycle.js | 16 +++++++++---- examples/papi/full-flow.js | 17 ++++++++----- examples/papi/provider-discovery.js | 3 ++- examples/papi/s3-lifecycle.js | 16 ++++++++++--- examples/papi/sc-coverage.js | 18 ++++++++++---- examples/papi/sc-flow.js | 11 +++++---- examples/papi/sc-token-gated.js | 3 ++- 12 files changed, 129 insertions(+), 45 deletions(-) diff --git a/examples/papi/api.js b/examples/papi/api.js index b559b362..f6175d16 100644 --- a/examples/papi/api.js +++ b/examples/papi/api.js @@ -3,6 +3,7 @@ import { blake2b256 } from "@polkadot-labs/hdkd-helpers"; import { hexToBytes, providerFetch, + READ_OPTS, requireOneEvent, submitTx, toHex, @@ -547,8 +548,13 @@ export async function signCheckpointProposal(providerUrl, bucketId, duty, window * from chain state and fetching MMR + chunk proofs from the provider node. */ export async function fetchChallengeProof(api, providerUrl, challengeId) { + // Read at the best (non-finalized) block: submitTx now resolves at in-block + // inclusion, so the challenge-creating extrinsic lives in the best block but + // not yet the finalized one. A default (finalized) read here races the + // ~6-block finality lag and spuriously reports "no challenges". const challenges = await api.query.StorageProvider.Challenges.getValue( - challengeId.deadline + challengeId.deadline, + READ_OPTS ); if (!challenges) throw new Error("No challenges at deadline " + challengeId.deadline); diff --git a/examples/papi/bucket-membership.js b/examples/papi/bucket-membership.js index a6129342..8862ae44 100644 --- a/examples/papi/bucket-membership.js +++ b/examples/papi/bucket-membership.js @@ -22,6 +22,7 @@ import { connect, makeSigner, printBucketMembers, + READ_OPTS, waitForBlockProduction, waitForChainReady, waitForNextBlock, @@ -34,7 +35,8 @@ const READER_SEED = process.argv[5] || "//Charlie"; async function verifyReverseIndex(api, member, bucketId, shouldContain) { const buckets = await api.query.StorageProvider.MemberBuckets.getValue( - member.address + member.address, + READ_OPTS ); console.log(" MemberBuckets[%s] = %o", member.address, buckets); const has = buckets.some((id) => id === bucketId); diff --git a/examples/papi/checkpoint-missed.js b/examples/papi/checkpoint-missed.js index 67262e20..22b19950 100644 --- a/examples/papi/checkpoint-missed.js +++ b/examples/papi/checkpoint-missed.js @@ -33,6 +33,7 @@ import { ensureSoleAcceptingProvider, makeSigner, parseProviderClientArgs, + READ_OPTS, sameAddress, waitForAgreementAcceptance, waitForBlock, @@ -86,7 +87,10 @@ async function main() { await waitForAgreementAcceptance(api, provider.address, bucketId); console.log(" Agreement accepted"); - const bucket = await api.query.StorageProvider.Buckets.getValue(bucketId); + const bucket = await api.query.StorageProvider.Buckets.getValue( + bucketId, + READ_OPTS + ); assert.ok( bucket.primary_providers.some((p) => sameAddress(p, provider.address)), "Provider should be primary after accept" @@ -104,7 +108,7 @@ async function main() { ); console.log("\n=== Step 3: Pick a window and let it elapse without a checkpoint ==="); - const head = Number(await api.query.System.Number.getValue()); + const head = Number(await api.query.System.Number.getValue(READ_OPTS)); const missedWindow = BigInt(Math.floor(head / WINDOW_INTERVAL)); // window_end = (missedWindow + 1) * interval ; need current_block > window_end const windowEnd = (Number(missedWindow) + 1) * WINDOW_INTERVAL; @@ -119,10 +123,12 @@ async function main() { console.log("\n=== Step 4: Record balances, then report_missed_checkpoint ==="); const providerBefore = await api.query.StorageProvider.Providers.getValue( - provider.address + provider.address, + READ_OPTS ); const reporterAcctBefore = await api.query.System.Account.getValue( - client.address + client.address, + READ_OPTS ); console.log(" Provider stake before: %s", providerBefore.stake.toString()); console.log( @@ -145,7 +151,8 @@ async function main() { console.log("\n=== Step 5: Verify slashing + reporter reward ==="); const providerAfter = await api.query.StorageProvider.Providers.getValue( - provider.address + provider.address, + READ_OPTS ); const stakeDelta = providerBefore.stake - providerAfter.stake; console.log(" Provider stake delta: %s", stakeDelta.toString()); @@ -157,7 +164,10 @@ async function main() { // LastCheckpointWindow is updated to prevent re-reporting. const lastWindow = - await api.query.StorageProvider.LastCheckpointWindow.getValue(bucketId); + await api.query.StorageProvider.LastCheckpointWindow.getValue( + bucketId, + READ_OPTS + ); assert.strictEqual( lastWindow, missedWindow, diff --git a/examples/papi/checkpoint-rewards.js b/examples/papi/checkpoint-rewards.js index fb9aa063..1dda0b66 100644 --- a/examples/papi/checkpoint-rewards.js +++ b/examples/papi/checkpoint-rewards.js @@ -37,6 +37,7 @@ import { ensureSoleAcceptingProvider, makeSigner, parseProviderClientArgs, + READ_OPTS, sameAddress, waitForAgreementAcceptance, waitForBlock, @@ -70,7 +71,7 @@ async function runProviderCheckpoint(api, papi, provider, bucketId) { // the call fails with InvalidCheckpointWindow. Require enough headroom // (a few blocks for inclusion under load) before submitting. const HEADROOM_BLOCKS = 15; - let currentBlock = Number(await api.query.System.Number.getValue()); + let currentBlock = Number(await api.query.System.Number.getValue(READ_OPTS)); let windowNum = Math.floor(currentBlock / WINDOW_INTERVAL); let nextWindowStart = (windowNum + 1) * WINDOW_INTERVAL; if (nextWindowStart - currentBlock < HEADROOM_BLOCKS) { @@ -81,7 +82,7 @@ async function runProviderCheckpoint(api, papi, provider, bucketId) { windowNum + 1 ); await waitForBlock(papi, nextWindowStart - 1); - currentBlock = Number(await api.query.System.Number.getValue()); + currentBlock = Number(await api.query.System.Number.getValue(READ_OPTS)); windowNum = Math.floor(currentBlock / WINDOW_INTERVAL); } const window = BigInt(windowNum); @@ -128,7 +129,8 @@ async function claimAndVerify(api, provider, bucketId, expectedReward) { // can drain a provider's pending rewards without scanning every bucket. const pending = await api.query.StorageProvider.CheckpointRewards.getValue( provider.address, - bucketId + bucketId, + READ_OPTS ); console.log(" Pending rewards before claim: %s", pending.toString()); assert.strictEqual( @@ -147,7 +149,8 @@ async function claimAndVerify(api, provider, bucketId, expectedReward) { const after = await api.query.StorageProvider.CheckpointRewards.getValue( provider.address, - bucketId + bucketId, + READ_OPTS ); assert.strictEqual( after, @@ -190,7 +193,10 @@ async function main() { await waitForAgreementAcceptance(api, provider.address, bucketId); console.log(" Agreement accepted (auto by provider node)"); - const bucket = await api.query.StorageProvider.Buckets.getValue(bucketId); + const bucket = await api.query.StorageProvider.Buckets.getValue( + bucketId, + READ_OPTS + ); assert.ok( bucket.primary_providers.some((p) => sameAddress(p, provider.address)), "Provider should be in primary_providers after agreement" @@ -224,7 +230,8 @@ async function main() { fundEvent.amount.toString() ); const balance = await api.query.StorageProvider.CheckpointPool.getValue( - bucketId + bucketId, + READ_OPTS ); console.log(" CheckpointPool[%s] = %s", bucketId, balance.toString()); assert.ok( diff --git a/examples/papi/common.js b/examples/papi/common.js index 20ea5c99..9a47af63 100644 --- a/examples/papi/common.js +++ b/examples/papi/common.js @@ -113,7 +113,7 @@ export async function waitForBlockProduction(api, { timeoutSec = 300 } = {}) { const deadline = Date.now() + timeoutSec * 1_000; while (Date.now() < deadline) { try { - const n = await api.query.System.Number.getValue(); + const n = await api.query.System.Number.getValue(READ_OPTS); const blockNumber = typeof n === "bigint" ? Number(n) : Number(n); if (blockNumber > 0) { console.log(`✅ Chain producing blocks (head=#${blockNumber})`); @@ -149,7 +149,10 @@ export class NonceManager { } /** Read the on-chain nonce for `address` and build a manager from it. */ static async forAccount(api, address) { - const account = await api.query.System.Account.getValue(address); + // Best block, not finalized: with in-block tx submission the on-chain + // nonce advances at the best block; a finalized read returns a stale, + // lower nonce and the manager would collide with already-applied txs. + const account = await api.query.System.Account.getValue(address, READ_OPTS); return new NonceManager(account.nonce); } } @@ -209,6 +212,23 @@ export async function waitForBlock(papi, target, { logEvery = 5 } = {}) { */ export const DEFAULT_TX_TIMEOUT_MS = 180_000; +/** + * Options object for storage reads (`getValue` / `getEntries`). + * + * `submitTx` resolves at in-block INCLUSION, not finalization, so any read that + * must observe a just-submitted write has to target the best (non-finalized) + * block — a default (finalized) read races the ~6-block finality lag and sees + * stale state. Spread this into every storage query in the demos: + * + * api.query.Pallet.Item.getValue(key, READ_OPTS) + * + * This is the single switch for the whole demo suite: flip `at` to "finalized" + * here to make every read observe finalized state instead — e.g. on a chain + * with fast finality where reorg-safety is preferred over read-your-writes + * latency. + */ +export const READ_OPTS = { at: "best" }; + /** * Transaction "doneness" thresholds for `waitForTransaction`. Pick the * loosest one your caller can tolerate — earlier modes return faster but @@ -390,7 +410,8 @@ export async function ensureProviderRegistered(api, provider, providerUrl, { // modules finish initialization before this function ever runs. const { registerProvider, updateProviderSettings } = await import("./api.js"); const existing = await api.query.StorageProvider.Providers.getValue( - provider.address + provider.address, + READ_OPTS ); if (!existing) { console.log(" Registering provider", provider.address); @@ -454,7 +475,8 @@ export async function waitForAgreementAcceptance( while (Date.now() < deadline) { const req = await api.query.StorageProvider.AgreementRequests.getValue( bucketId, - providerAddress + providerAddress, + READ_OPTS ); if (!req) return; await new Promise((r) => setTimeout(r, pollMs)); @@ -467,7 +489,10 @@ export async function waitForAgreementAcceptance( } export async function printBucketMembers(api, bucketId, label = "members") { - const bucket = await api.query.StorageProvider.Buckets.getValue(bucketId); + const bucket = await api.query.StorageProvider.Buckets.getValue( + bucketId, + READ_OPTS + ); console.log(` [${label}] bucket ${bucketId}:`); for (const m of bucket.members) { console.log(` - ${m.account} role=${fmtRole(m.role)}`); @@ -535,7 +560,7 @@ const KNOWN_DEV_SEEDS = [ */ export async function ensureSoleAcceptingProvider(api, keep) { const toggled = []; - const others = await api.query.StorageProvider.Providers.getEntries(); + const others = await api.query.StorageProvider.Providers.getEntries(READ_OPTS); for (const { keyArgs, value: info } of others) { const account = keyArgs[0]; if (sameAddress(account, keep.address)) continue; diff --git a/examples/papi/drive-lifecycle.js b/examples/papi/drive-lifecycle.js index e6a46f54..b104938d 100644 --- a/examples/papi/drive-lifecycle.js +++ b/examples/papi/drive-lifecycle.js @@ -29,6 +29,7 @@ import { fmtRole, makeSigner, printBucketMembers, + READ_OPTS, sameAddress, waitForAgreementAcceptance, waitForBlockProduction, @@ -43,7 +44,7 @@ const OWNER_SEED = process.argv[5] || "//Bob"; const MEMBER_SEED = process.argv[6] || "//Charlie"; async function printDriveInfo(api, owner, driveId, bucketId) { - const drive = await api.query.DriveRegistry.Drives.getValue(driveId); + const drive = await api.query.DriveRegistry.Drives.getValue(driveId, READ_OPTS); console.log(" owner =", drive.owner); console.log(" name =", drive.name ? drive.name.asText() : "(none)"); console.log(" max_capacity =", drive.max_capacity); @@ -52,7 +53,8 @@ async function printDriveInfo(api, owner, driveId, bucketId) { console.log(" payment locked =", drive.payment); const userDrives = await api.query.DriveRegistry.UserDrives.getValue( - owner.address + owner.address, + READ_OPTS ); console.log(" UserDrives[owner] =", userDrives); assert.ok( @@ -61,7 +63,8 @@ async function printDriveInfo(api, owner, driveId, bucketId) { ); const driveIdForBucket = await api.query.DriveRegistry.BucketToDrive.getValue( - bucketId + bucketId, + READ_OPTS ); assert.strictEqual( driveIdForBucket, @@ -71,7 +74,7 @@ async function printDriveInfo(api, owner, driveId, bucketId) { } async function getFree(api, who) { - const acc = await api.query.System.Account.getValue(who.address); + const acc = await api.query.System.Account.getValue(who.address, READ_OPTS); return acc.data.free; } @@ -159,7 +162,10 @@ async function main() { console.log(" owner free delta =", ownerAfter - ownerBefore); console.log(" provider free delta =", providerAfter - providerBefore); - const driveAfter = await api.query.DriveRegistry.Drives.getValue(driveId); + const driveAfter = await api.query.DriveRegistry.Drives.getValue( + driveId, + READ_OPTS + ); assert.strictEqual(driveAfter, undefined, "Drive should be gone after delete"); console.log("PASSED: drive lifecycle complete"); } catch (err) { diff --git a/examples/papi/full-flow.js b/examples/papi/full-flow.js index 7e161170..47ffb63b 100644 --- a/examples/papi/full-flow.js +++ b/examples/papi/full-flow.js @@ -36,6 +36,7 @@ import { ensureProviderRegistered, makeSigner, parseProviderClientArgs, + READ_OPTS, waitForBlock, waitForBlockProduction, waitForChainReady, @@ -52,7 +53,8 @@ const { async function setupAgreement(api, client, provider, bucketId) { const existing = await api.query.StorageProvider.StorageAgreements.getValue( bucketId, - provider.address + provider.address, + READ_OPTS ); if (existing) { console.log(" Agreement already exists"); @@ -99,21 +101,24 @@ async function uploadAndVerify(bucketId) { async function claimPaymentAfterExpiry(api, papi, provider, client, bucketId) { const agreement = await api.query.StorageProvider.StorageAgreements.getValue( bucketId, - provider.address + provider.address, + READ_OPTS ); const expiresAt = Number(agreement.expires_at); console.log(" Agreement expires at block:", expiresAt); - const freeBefore = (await api.query.System.Account.getValue(provider.address)) - .data.free; + const freeBefore = ( + await api.query.System.Account.getValue(provider.address, READ_OPTS) + ).data.free; console.log(" Provider balance before:", freeBefore.toString()); console.log(" Waiting for agreement to expire..."); await waitForBlock(papi, expiresAt); await endAgreement(api, client, provider, bucketId, "Pay"); - const freeAfter = (await api.query.System.Account.getValue(provider.address)) - .data.free; + const freeAfter = ( + await api.query.System.Account.getValue(provider.address, READ_OPTS) + ).data.free; const earned = freeAfter - freeBefore; console.log(" Provider balance after:", freeAfter.toString()); console.log(" Earned from agreement:", earned.toString()); diff --git a/examples/papi/provider-discovery.js b/examples/papi/provider-discovery.js index 79197723..81d7653d 100644 --- a/examples/papi/provider-discovery.js +++ b/examples/papi/provider-discovery.js @@ -23,6 +23,7 @@ import { connect, + READ_OPTS, waitForBlockProduction, waitForChainReady, waitForNextBlock, @@ -70,7 +71,7 @@ function scoreProvider(info, req) { } async function fetchAndRankProviders(api, req) { - const entries = await api.query.StorageProvider.Providers.getEntries(); + const entries = await api.query.StorageProvider.Providers.getEntries(READ_OPTS); const ranked = entries.map(({ keyArgs, value }) => ({ address: keyArgs[0], info: value, diff --git a/examples/papi/s3-lifecycle.js b/examples/papi/s3-lifecycle.js index 3ae7ac5f..c25868ab 100644 --- a/examples/papi/s3-lifecycle.js +++ b/examples/papi/s3-lifecycle.js @@ -33,6 +33,7 @@ import { ensureSoleAcceptingProvider, makeSigner, parseProviderClientArgs, + READ_OPTS, sameAddress, toHex, waitForAgreementAcceptance, @@ -56,7 +57,10 @@ const OBJECT_KEYS = { }; async function listObjects(api, s3BucketId) { - const entries = await api.query.S3Registry.Objects.getEntries(s3BucketId); + const entries = await api.query.S3Registry.Objects.getEntries( + s3BucketId, + READ_OPTS + ); console.log(" bucket contains %d object(s):", entries.length); for (const { keyArgs, value } of entries) { const key = new TextDecoder().decode(keyArgs[1].asBytes()); @@ -68,7 +72,10 @@ async function listObjects(api, s3BucketId) { new TextDecoder().decode(value.content_type.asBytes()) ); } - const bucketInfo = await api.query.S3Registry.S3Buckets.getValue(s3BucketId); + const bucketInfo = await api.query.S3Registry.S3Buckets.getValue( + s3BucketId, + READ_OPTS + ); console.log( " bucket stats: object_count=%s total_size=%s", bucketInfo.object_count, @@ -182,7 +189,10 @@ async function main() { for (const key of Object.values(OBJECT_KEYS)) { await deleteObjectMetadata(api, client, s3BucketId, key); } - const afterDelete = await api.query.S3Registry.Objects.getEntries(s3BucketId); + const afterDelete = await api.query.S3Registry.Objects.getEntries( + s3BucketId, + READ_OPTS + ); assert.strictEqual(afterDelete.length, 0, "Expected empty bucket after delete"); console.log(" Bucket is empty"); diff --git a/examples/papi/sc-coverage.js b/examples/papi/sc-coverage.js index 6329dd29..a7b110b7 100644 --- a/examples/papi/sc-coverage.js +++ b/examples/papi/sc-coverage.js @@ -37,6 +37,7 @@ import { hexToBytes, makeSigner, parseProviderClientArgs, + READ_OPTS, sameAddress, toHex, waitForBlockProduction, @@ -118,7 +119,8 @@ async function main() { // 1. createBucket ----------------------------------------------------- console.log("\n[1] IWeb3Storage.createBucket(1)"); - let nextBucketBefore = await api.query.StorageProvider.NextBucketId.getValue(); + let nextBucketBefore = + await api.query.StorageProvider.NextBucketId.getValue(READ_OPTS); let r = await callPrecompile(api, client, WEB3_STORAGE_ADDR, iWeb3, "createBucket", [1]); const created = assertEvent(r.events, "StorageProvider", "BucketCreated", "createBucket"); const bucketA = created.bucket_id; @@ -133,7 +135,10 @@ async function main() { 1, // Writer ]); assertEvent(r.events, "StorageProvider", "MemberSet", "setMember"); - let bucket = await api.query.StorageProvider.Buckets.getValue(bucketA); + let bucket = await api.query.StorageProvider.Buckets.getValue( + bucketA, + READ_OPTS + ); assert.ok( bucket.members.some((m) => sameAddress(m.account, member.address)), "Charlie should be in bucket members after setMember" @@ -200,7 +205,8 @@ async function main() { // MILLIUNIT = 1e9 atomic). 10% of `1MiB × 100k blocks × 1` ≈ 1e10 atomic, // comfortably above ED. console.log("\n[8] IWeb3Storage.createBucketWithStorage(1MiB, 100k blocks, maxPrice=10) [burn-sized]"); - nextBucketBefore = await api.query.StorageProvider.NextBucketId.getValue(); + nextBucketBefore = + await api.query.StorageProvider.NextBucketId.getValue(READ_OPTS); r = await callPrecompile( api, client, @@ -230,7 +236,8 @@ async function main() { // challenge. The agreement is left open and is not ended; settlement // happens through chain-driven expiry, not this test. console.log("\n[10] IWeb3Storage.createBucketWithStorage(2KiB, 100, maxPrice=10) [freeze/challenge target]"); - nextBucketBefore = await api.query.StorageProvider.NextBucketId.getValue(); + nextBucketBefore = + await api.query.StorageProvider.NextBucketId.getValue(READ_OPTS); r = await callPrecompile( api, client, @@ -275,7 +282,8 @@ async function main() { // 12. createDrive ----------------------------------------------------- console.log("\n[12] IDriveRegistry.createDrive(\"cov\", 1MiB, 50 blocks, 1 UNIT, default-providers)"); - const nextDriveBefore = await api.query.DriveRegistry.NextDriveId.getValue(); + const nextDriveBefore = + await api.query.DriveRegistry.NextDriveId.getValue(READ_OPTS); r = await callPrecompile(api, client, DRIVE_REGISTRY_ADDR, iDrive, "createDrive", [ "cov", 1n << 20n, // 1 MiB diff --git a/examples/papi/sc-flow.js b/examples/papi/sc-flow.js index 113701e6..9c6ecfaa 100644 --- a/examples/papi/sc-flow.js +++ b/examples/papi/sc-flow.js @@ -39,6 +39,7 @@ import { ensureSoleAcceptingProvider, makeSigner, parseProviderClientArgs, + READ_OPTS, requireOneEvent, toHex, waitForBlockProduction, @@ -182,16 +183,18 @@ async function main() { bucketId, toHex(providerBytes32), ]); - const freeBefore = (await api.query.System.Account.getValue(provider.address)) - .data.free; + const freeBefore = ( + await api.query.System.Account.getValue(provider.address, READ_OPTS) + ).data.free; const endResult = await callContract( api, client, deployed.addressBytes, endData ); - const freeAfter = (await api.query.System.Account.getValue(provider.address)) - .data.free; + const freeAfter = ( + await api.query.System.Account.getValue(provider.address, READ_OPTS) + ).data.free; const earned = freeAfter - freeBefore; console.log(" provider earned:", earned.toString(), "atomic units"); assert.ok(earned > 0n, `expected provider to earn tokens, got ${earned}`); diff --git a/examples/papi/sc-token-gated.js b/examples/papi/sc-token-gated.js index fdc1c212..ce4fd858 100644 --- a/examples/papi/sc-token-gated.js +++ b/examples/papi/sc-token-gated.js @@ -32,6 +32,7 @@ import { hexToBytes, makeSigner, parseProviderClientArgs, + READ_OPTS, requireOneEvent, waitForBlockProduction, waitForChainReady, @@ -106,7 +107,7 @@ async function main() { // Bucket name: 3-63 chars, lowercase alphanumeric + hyphens. Append the // block number so the name is unique across reruns on the same chain // (`pallet_s3_registry` enforces global name uniqueness). - const blockHead = await api.query.System.Number.getValue(); + const blockHead = await api.query.System.Number.getValue(READ_OPTS); const bucketName = `cov-bucket-${Number(blockHead)}`; console.log(`\n[2/6] initialize{value: 5 UNIT}('${bucketName}', 1MiB, 50 blocks, …)`); const initData = encodeCall(abi, "initialize", [bucketName, 1n << 20n, 50, 2n * UNIT]); From 18e1416cd95ef53255d0ee6fa788d95f90912f32 Mon Sep 17 00:00:00 2001 From: Branislav Kontur Date: Thu, 4 Jun 2026 15:30:12 +0200 Subject: [PATCH 06/10] fix(papi): count ChallengeDefended from tx events, not finalized watch full-flow's verification used api.event...ChallengeDefended.watch(), which only observes FINALIZED blocks. With in-block tx submission the two defenses land in best (not-yet-finalized) blocks, so a count taken right after (even behind a 3s sleep) read 0 -> "Expected 2 ChallengeDefended events, got 0". The respond_to_challenge dispatch emits ChallengeDefended in the same block the tx lands in, so extract it from each response's in-block events via requireOneEvent instead. Deterministic and race-free; drops the sleep. --- examples/papi/full-flow.js | 61 ++++++++++++++++++++++++++------------ 1 file changed, 42 insertions(+), 19 deletions(-) diff --git a/examples/papi/full-flow.js b/examples/papi/full-flow.js index 47ffb63b..1dd57ce3 100644 --- a/examples/papi/full-flow.js +++ b/examples/papi/full-flow.js @@ -37,6 +37,7 @@ import { makeSigner, parseProviderClientArgs, READ_OPTS, + requireOneEvent, waitForBlock, waitForBlockProduction, waitForChainReady, @@ -126,18 +127,23 @@ async function claimPaymentAfterExpiry(api, papi, provider, client, bucketId) { console.log("PASSED: Provider received payment!"); } -function watchDefendedEvents(api) { - const events = []; - const sub = api.event.StorageProvider.ChallengeDefended.watch().subscribe( - (event) => { - console.log(" >> ChallengeDefended event:", { - deadline: event.payload.challenge_id.deadline, - index: event.payload.challenge_id.index, - }); - events.push(event); - } +// Pull the ChallengeDefended event straight from the `respond_to_challenge` +// transaction's in-block events. A background `api.event...watch()` only sees +// FINALIZED blocks, which lag in-block submission by ~6 blocks — so a count +// taken right after responding would read 0. The dispatch emits the event in +// the same block the tx lands in, so the tx result is the authoritative, +// race-free source. +function recordDefended(api, result, label) { + const event = requireOneEvent( + result.events, + api.event.StorageProvider.ChallengeDefended, + label ); - return { events, unsubscribe: () => sub.unsubscribe() }; + console.log(" >> ChallengeDefended event:", { + deadline: event.challenge_id.deadline, + index: event.challenge_id.index, + }); + return event; } async function main() { @@ -153,7 +159,7 @@ async function main() { await waitForChainReady(api); await waitForBlockProduction(api); await waitForNextBlock(papi); - const defended = watchDefendedEvents(api); + const defendedEvents = []; try { console.log("\n=== Step 1: Setup ==="); @@ -181,7 +187,15 @@ async function main() { console.log("\n=== Step 4: Respond to off-chain challenge ==="); const offchainProof = await fetchChallengeProof(api, PROVIDER_URL, offchainId); - await respondToChallenge(api, provider, offchainId, offchainProof); + const offchainResp = await respondToChallenge( + api, + provider, + offchainId, + offchainProof + ); + defendedEvents.push( + recordDefended(api, offchainResp, "ChallengeDefended (offchain)") + ); console.log(" Challenge defended"); console.log("\n=== Step 5: Submit checkpoint ==="); @@ -211,16 +225,26 @@ async function main() { PROVIDER_URL, checkpointId ); - await respondToChallenge(api, provider, checkpointId, checkpointProof); + const checkpointResp = await respondToChallenge( + api, + provider, + checkpointId, + checkpointProof + ); + defendedEvents.push( + recordDefended(api, checkpointResp, "ChallengeDefended (checkpoint)") + ); console.log(" Challenge defended"); console.log("\n=== Verifying challenge results ==="); - await new Promise((r) => setTimeout(r, 3000)); - console.log("ChallengeDefended events: %d (expected: 2)", defended.events.length); + console.log( + "ChallengeDefended events: %d (expected: 2)", + defendedEvents.length + ); assert.strictEqual( - defended.events.length, + defendedEvents.length, 2, - `Expected 2 ChallengeDefended events, got ${defended.events.length}` + `Expected 2 ChallengeDefended events, got ${defendedEvents.length}` ); console.log("PASSED: Both challenges were defended!"); @@ -231,7 +255,6 @@ async function main() { if (err.stack) console.error(err.stack); process.exitCode = 1; } finally { - defended.unsubscribe(); papi.destroy(); } } From 2bd19cf06a815cb6f444cf5903b27f98d27b83ae Mon Sep 17 00:00:00 2001 From: Branislav Kontur Date: Thu, 4 Jun 2026 16:14:49 +0200 Subject: [PATCH 07/10] fix(papi): finalize challenge-creating txs so respond can't hit ChallengeNotFound MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A challenge's id embeds the block height it was created at, and respond_to_challenge references that exact id. With in-block submission the creating tx sits in a best block that — even with a single collator — can be reorged before it's relay-backed/finalized, rolling the challenge out from under the response (seen as 'respond_to_challenge dispatch failed: ChallengeNotFound' when the response lands a few blocks later, as with the slower disk provider). Add submitTxFinalized (waits for a finalized block) and use it only for the challenge creators: challengeOffchain + challengeCheckpoint (api.js), and the challengeCheckpoint precompile call in sc-coverage via a new callContract { finalized: true } opt. Everything else stays in-block. Two extra finality waits per affected demo, still far under the 30-min cap. --- examples/papi/api.js | 10 ++++++++-- examples/papi/common.js | 37 ++++++++++++++++++++++++++++++++++-- examples/papi/sc-api.js | 22 ++++++++++++++++++--- examples/papi/sc-coverage.js | 17 +++++++++++------ 4 files changed, 73 insertions(+), 13 deletions(-) diff --git a/examples/papi/api.js b/examples/papi/api.js index f6175d16..acab7c53 100644 --- a/examples/papi/api.js +++ b/examples/papi/api.js @@ -6,6 +6,7 @@ import { READ_OPTS, requireOneEvent, submitTx, + submitTxFinalized, toHex, } from "./common.js"; @@ -135,7 +136,10 @@ export async function submitClientCheckpoint(api, client, provider, bucketId, ck } export async function challengeOffchain(api, client, provider, bucketId, upload) { - const result = await submitTx( + // Finalized, not in-block: the returned challenge_id embeds the creation + // block height; respond_to_challenge references it. A best-block reorg of the + // creating tx would invalidate the id (-> ChallengeNotFound on respond). + const result = await submitTxFinalized( api.tx.StorageProvider.challenge_offchain({ bucket_id: bucketId, provider: provider.address, @@ -159,7 +163,9 @@ export async function challengeOffchain(api, client, provider, bucketId, upload) } export async function challengeCheckpoint(api, client, provider, bucketId, leafIndex) { - const result = await submitTx( + // Finalized, not in-block: see challengeOffchain — the challenge_id must + // survive to the respond_to_challenge that references it. + const result = await submitTxFinalized( api.tx.StorageProvider.challenge_checkpoint({ bucket_id: bucketId, provider: provider.address, diff --git a/examples/papi/common.js b/examples/papi/common.js index 9a47af63..3ff31674 100644 --- a/examples/papi/common.js +++ b/examples/papi/common.js @@ -361,8 +361,15 @@ export async function waitForTransaction( * parachain, finality lags inclusion by several blocks (~36s vs ~6s per tx); * waiting for finalization on every extrinsic is what pushed the integration * demos past the 30-min CI cap. The demos verify outcomes via the emitted - * events, which are already present at inclusion — the only thing given up is - * reorg-safety, which is moot on a single-collator local chain. + * events, which are already present at inclusion. + * + * Caveat: best blocks are NOT reorg-proof. Even with a single collator the + * parachain's best block runs ahead of the relay-backed/finalized block and + * can be discarded before inclusion, rolling back the tx. That's fine for a + * self-contained tx the demo only reads events from, but NOT for a tx whose + * on-chain effect a LATER tx references by id (e.g. creating a challenge then + * responding to it): use `submitTxFinalized` for the creating tx so the id is + * stable. See its doc below. * * Like the finalized path it replaces, this asserts dispatch success: PAPI does * NOT throw when an extrinsic dispatches with an error (only on an invalid tx — @@ -381,6 +388,32 @@ export async function submitTx( return waitForTransaction(tx, signer, label, TX_MODE_IN_BLOCK, timeoutMs); } +/** + * Like `submitTx`, but resolves only once the tx is in a FINALIZED block. + * + * Slower (~6 blocks of finality lag) — use sparingly, ONLY where a subsequent + * tx depends on this one's on-chain effect by id. The motivating case: a + * challenge's id embeds the block height it was created at, and responding to + * it must reference that exact id. If the creating tx is merely in-block, a + * best-block reorg can roll it back, leaving the response to fail with + * `ChallengeNotFound`. Finalizing the creation pins the id so the response + * (which can stay in-block) always finds it. + */ +export async function submitTxFinalized( + tx, + signer, + label, + timeoutMs = DEFAULT_TX_TIMEOUT_MS +) { + return waitForTransaction( + tx, + signer, + label, + TX_MODE_FINALIZED_BLOCK, + timeoutMs + ); +} + export async function providerFetch(providerUrl, path, opts = {}) { const url = new URL(path, providerUrl); if (opts.params) { diff --git a/examples/papi/sc-api.js b/examples/papi/sc-api.js index 39fe13eb..72273e66 100644 --- a/examples/papi/sc-api.js +++ b/examples/papi/sc-api.js @@ -13,7 +13,13 @@ import { Binary } from "@polkadot-api/substrate-bindings"; import { decodeEventLog, encodeFunctionData, keccak256 } from "viem"; -import { hexToBytes, requireOneEvent, submitTx, toHex } from "./common.js"; +import { + hexToBytes, + requireOneEvent, + submitTx, + submitTxFinalized, + toHex, +} from "./common.js"; /** * Compute the EVM-side H160 of a substrate account via `AccountId32Mapper`'s @@ -113,7 +119,16 @@ export async function callContract( signer, contractAddressBytes, data, - { value = 0n, gasLimit = DEFAULT_GAS_LIMIT, storageDepositLimit = DEFAULT_STORAGE_DEPOSIT_LIMIT } = {} + { + value = 0n, + gasLimit = DEFAULT_GAS_LIMIT, + storageDepositLimit = DEFAULT_STORAGE_DEPOSIT_LIMIT, + // Set when a later tx references this call's on-chain effect by id (e.g. a + // precompile that creates a challenge, before respond_to_challenge). Waits + // for finalization so a best-block reorg can't roll the id out from under + // the dependent tx. See submitTxFinalized. + finalized = false, + } = {} ) { const tx = api.tx.Revive.call({ dest: Binary.fromBytes(contractAddressBytes), @@ -122,7 +137,8 @@ export async function callContract( storage_deposit_limit: storageDepositLimit, data: Binary.fromBytes(data), }); - return submitTx(tx, signer.signer, "Revive.call"); + const submit = finalized ? submitTxFinalized : submitTx; + return submit(tx, signer.signer, "Revive.call"); } /** diff --git a/examples/papi/sc-coverage.js b/examples/papi/sc-coverage.js index a7b110b7..cbc4d188 100644 --- a/examples/papi/sc-coverage.js +++ b/examples/papi/sc-coverage.js @@ -258,12 +258,17 @@ async function main() { await submitClientCheckpoint(api, client, provider, bucketC, ck); console.log("\n[10a] IWeb3Storage.challengeCheckpoint(bucketC, provider, leafIdx, chunkIdx=0)"); - r = await callPrecompile(api, client, WEB3_STORAGE_ADDR, iWeb3, "challengeCheckpoint", [ - bucketC, - toHex(providerBytes32), - BigInt(upload.commit.leaf_indices[0]), - 0n, - ]); + // Finalize the challenge-creating call: respond_to_challenge below + // references the challenge_id, which a best-block reorg could invalidate. + r = await callPrecompile( + api, + client, + WEB3_STORAGE_ADDR, + iWeb3, + "challengeCheckpoint", + [bucketC, toHex(providerBytes32), BigInt(upload.commit.leaf_indices[0]), 0n], + { finalized: true } + ); const challenge = assertEvent(r.events, "StorageProvider", "ChallengeCreated", "challengeCheckpoint"); console.log(" [substrate] respondToChallenge"); From 42d64c742cfa3ccd170d7eeda642afe6a45cd56c Mon Sep 17 00:00:00 2001 From: Branislav Kontur Date: Thu, 4 Jun 2026 16:15:58 +0200 Subject: [PATCH 08/10] demo(papi): shorten full-flow agreement duration 50 -> 15 blocks Smaller requested duration shortens the 'wait for agreement expiry' step (expires_at = accept_block + duration). 15 stays >= the provider's min_duration (10), and max_payment scales automatically off duration. --- examples/papi/full-flow.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/papi/full-flow.js b/examples/papi/full-flow.js index 1dd57ce3..a943951a 100644 --- a/examples/papi/full-flow.js +++ b/examples/papi/full-flow.js @@ -62,7 +62,7 @@ async function setupAgreement(api, client, provider, bucketId) { return; } const maxBytes = 1_073_741_824n; // 1 GiB - const duration = 50; + const duration = 15; console.log( " Requesting agreement (%s), duration=%d blocks...", client.seed, From 7a3742122815c4ce0fe412f8583b73b68de34dde Mon Sep 17 00:00:00 2001 From: Branislav Kontur Date: Thu, 4 Jun 2026 23:57:06 +0200 Subject: [PATCH 09/10] ci(ui-e2e): make the required check always report via an aggregate gate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ui-e2e.yml filtered at the WORKFLOW level (on.*.paths), so on PRs that touch none of those paths the whole workflow was skipped and the required status check 'UI E2E Tests (chain + provider + UIs)' never reported — leaving such PRs permanently BLOCKED in branch protection (e.g. CI-only / PAPI-demo changes). Adopt the same shape ui-checks.yml already uses: drop the top-level paths filter, detect relevant changes in a 'changes' job, gate the heavy e2e job (now 'UI E2E run') on it, and add an always-running aggregate job that carries the required context name and is green on skip, red on real failure/cancellation. --- .github/workflows/ui-e2e.yml | 61 ++++++++++++++++++++++++++---------- 1 file changed, 45 insertions(+), 16 deletions(-) diff --git a/.github/workflows/ui-e2e.yml b/.github/workflows/ui-e2e.yml index 06d6ed26..0ebe5071 100644 --- a/.github/workflows/ui-e2e.yml +++ b/.github/workflows/ui-e2e.yml @@ -1,24 +1,15 @@ name: UI E2E +# No top-level `paths:` filter so the `UI E2E Tests (chain + provider + UIs)` +# gate always reports and is safe to require in branch protection. The +# expensive e2e job skips (via the `changes` job) when nothing relevant is +# touched, instead of the whole workflow being filtered out — which would leave +# the required check permanently pending and block every unrelated PR. on: push: branches: [main, dev] - paths: - - "user-interfaces/**" - - "pallet/**" - - "runtime/**" - - "storage-interfaces/**" - - "provider-node/**" - - ".github/workflows/ui-e2e.yml" pull_request: branches: [main, dev] - paths: - - "user-interfaces/**" - - "pallet/**" - - "runtime/**" - - "storage-interfaces/**" - - "provider-node/**" - - ".github/workflows/ui-e2e.yml" workflow_dispatch: concurrency: @@ -26,14 +17,36 @@ concurrency: cancel-in-progress: true jobs: + changes: + name: Detect e2e-relevant changes + runs-on: ubuntu-latest + outputs: + e2e: ${{ steps.filter.outputs.e2e }} + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + - uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3 + id: filter + with: + filters: | + e2e: + - 'user-interfaces/**' + - 'pallet/**' + - 'runtime/**' + - 'storage-interfaces/**' + - 'provider-node/**' + - '.github/workflows/ui-e2e.yml' + set-image: + needs: changes + if: needs.changes.outputs.e2e == 'true' uses: ./.github/workflows/set-image.yml ui-e2e: - name: UI E2E Tests (chain + provider + UIs) + name: UI E2E run runs-on: parity-large timeout-minutes: 60 - needs: [set-image] + needs: [changes, set-image] + if: needs.changes.outputs.e2e == 'true' container: image: ${{ needs.set-image.outputs.CI_IMAGE }} steps: @@ -187,3 +200,19 @@ jobs: /tmp/provider.log /tmp/zombie-*/*.log /tmp/zombie-*/**/*.log + + # Aggregate gate — the stable check to require in branch protection. Always + # reports (so the required context is never permanently pending), stays green + # when nothing e2e-relevant is touched (skips aren't failures), red on real + # failure or cancellation. + ui-e2e-gate: + name: UI E2E Tests (chain + provider + UIs) + needs: [changes, set-image, ui-e2e] + if: always() + runs-on: ubuntu-latest + steps: + - name: Decide outcome + if: contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled') + run: | + echo "UI E2E failed or was cancelled" + exit 1 From 189b3acc7164072f99d159f1ce33662a74c0c02e Mon Sep 17 00:00:00 2001 From: Branislav Kontur Date: Fri, 5 Jun 2026 00:05:45 +0200 Subject: [PATCH 10/10] docs: trim the explanatory comments added in this PR Condense the comment blocks across the integration-tests/ui-e2e workflows and the PAPI demos (READ_OPTS, submitTx/submitTxFinalized, challenge-finalize and event-read notes) to one or two lines each, keeping the 'why' and dropping the restated mechanics. Comments only; no behaviour change. --- .github/workflows/integration-tests.yml | 51 +++++++------------- .github/workflows/ui-e2e.yml | 14 ++---- examples/papi/api.js | 13 ++--- examples/papi/common.js | 64 +++++++------------------ examples/papi/full-flow.js | 9 ++-- examples/papi/sc-api.js | 6 +-- examples/papi/sc-coverage.js | 3 +- 7 files changed, 48 insertions(+), 112 deletions(-) diff --git a/.github/workflows/integration-tests.yml b/.github/workflows/integration-tests.yml index 3ca16e71..e6559441 100644 --- a/.github/workflows/integration-tests.yml +++ b/.github/workflows/integration-tests.yml @@ -34,16 +34,9 @@ jobs: echo "runtime=$TASKS" >> $GITHUB_OUTPUT # ── Build once ──────────────────────────────────────────────────────────── - # Compiles the heaviest, shared artifacts a single time and publishes them as - # one artifact both test jobs download instead of each rebuilding from - # scratch. The artifact holds: - # - target/release/storage-provider-node — the provider binary, - # - the *.compact.compressed.wasm runtime per integration-tested runtime, - # which the chain_spec_command scripts feed to chain-spec-builder. - # NOTE: this does not eliminate all compilation downstream — the test jobs - # still `cargo run` the fs/s3 ci_integration_test examples and the sc job - # still builds the Solidity contracts. Those lean on the shared Rust cache - # (shared-key "integration-tests-build"), not on this artifact. + # Build the shared artifacts once (provider binary + one compressed wasm per + # tested runtime) so both test jobs download instead of rebuilding. The test + # jobs still compile the fs/s3 examples and sc contracts via the shared cache. build: name: Build runs-on: parity-large @@ -67,8 +60,7 @@ jobs: - name: Install just run: cargo install just --locked || true - # Build every runtime whose build_command the matrix file declares, so the - # single artifact carries a wasm for each runtime the test jobs fan out on. + # One wasm per runtime the test jobs fan out on. - name: Build all runtimes run: | for cmd in $(jq -r '.[] | select(.integration_tests == true) | .build_command' scripts/runtimes-matrix.json); do @@ -80,13 +72,10 @@ jobs: - name: Build provider run: just build-provider - # Upload only what the test jobs actually consume: the provider binary and - # the final compressed runtime wasm per runtime (the chain_spec_command - # scripts read target/release/wbuild//.compact.compressed.wasm). - # The full wbuild tree is a ~900 MiB / 3400-file Cargo scratch dir — never - # upload it. Both listed paths sit under target/release, so the artifact's - # least-common-ancestor root is target/release; the download step below - # extracts into target/release to put the prefix back. + # Upload only what the test jobs consume: provider binary + the final + # compressed wasm per runtime. NOT the full wbuild tree (~900 MiB Cargo + # scratch). Both paths sit under target/release, so the artifact is rooted + # there (LCA) and the download steps extract back into target/release. - name: Upload build artifacts uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: @@ -98,10 +87,8 @@ jobs: target/release/wbuild/*/*.compact.compressed.wasm # ── L0 / file-system / S3 / PAPI demos ────────────────────────────────────── - # Runs against its OWN chain + inmemory and disk providers. Demos stay - # sequential on the shared chain (drain-tx-pool-then between them), but the - # whole job now runs in parallel with sc-integration-tests and skips the - # build — both consume the prebuilt artifact. + # Own chain + inmemory/disk providers; demos sequential on the shared chain. + # Runs in parallel with sc-integration-tests off the prebuilt artifact. integration-tests: name: Integration Tests (${{ matrix.runtime.name }}) runs-on: parity-large @@ -155,19 +142,16 @@ jobs: with: node-version: ${{ env.NODE_VERSION }} - # Restored before the artifact download so the cached target/ can't clobber - # the freshly downloaded provider + wbuild. save-if false — only the build - # job writes this cache. + # Before the download so the cached target/ can't clobber it. save-if + # false — only the build job writes this cache. - name: Rust cache uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 with: shared-key: "integration-tests-build" save-if: false - # Extract into target/release so the artifact's target/release-rooted - # contents (provider binary + wbuild wasm) land back at their original - # paths — where the chmod step, the chain_spec_command scripts, and the - # demos all expect them. + # Extract into target/release so the artifact's contents land back at + # their original paths (chmod, chain_spec_command scripts, demos). - name: Download build artifacts uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v6 with: @@ -276,10 +260,9 @@ jobs: /tmp/zombie-*/*-raw.json # ── Smart-contract demos ───────────────────────────────────────────────────── - # Runs against its OWN chain + inmemory provider, in parallel with - # integration-tests. Consumes the prebuilt provider + runtime wasm from the - # build artifact, but still installs solc/resolc and builds the example - # contracts here (they are cheap and not part of the shared artifact). + # Own chain + inmemory provider, parallel with integration-tests off the + # prebuilt artifact. Still installs solc/resolc and builds the example + # contracts here (cheap, not in the shared artifact). sc-integration-tests: name: SC Integration Tests (${{ matrix.runtime.name }}) runs-on: parity-large diff --git a/.github/workflows/ui-e2e.yml b/.github/workflows/ui-e2e.yml index 0ebe5071..2f0789de 100644 --- a/.github/workflows/ui-e2e.yml +++ b/.github/workflows/ui-e2e.yml @@ -1,10 +1,8 @@ name: UI E2E -# No top-level `paths:` filter so the `UI E2E Tests (chain + provider + UIs)` -# gate always reports and is safe to require in branch protection. The -# expensive e2e job skips (via the `changes` job) when nothing relevant is -# touched, instead of the whole workflow being filtered out — which would leave -# the required check permanently pending and block every unrelated PR. +# No top-level `paths:` filter: the required gate must always report. The +# expensive e2e job skips (via `changes`) when nothing relevant is touched; a +# workflow-level filter would instead leave the required check pending forever. on: push: branches: [main, dev] @@ -201,10 +199,8 @@ jobs: /tmp/zombie-*/*.log /tmp/zombie-*/**/*.log - # Aggregate gate — the stable check to require in branch protection. Always - # reports (so the required context is never permanently pending), stays green - # when nothing e2e-relevant is touched (skips aren't failures), red on real - # failure or cancellation. + # Aggregate gate — the check to require in branch protection. Always reports: + # green when the e2e job is skipped, red on real failure/cancellation. ui-e2e-gate: name: UI E2E Tests (chain + provider + UIs) needs: [changes, set-image, ui-e2e] diff --git a/examples/papi/api.js b/examples/papi/api.js index acab7c53..0731932b 100644 --- a/examples/papi/api.js +++ b/examples/papi/api.js @@ -136,9 +136,8 @@ export async function submitClientCheckpoint(api, client, provider, bucketId, ck } export async function challengeOffchain(api, client, provider, bucketId, upload) { - // Finalized, not in-block: the returned challenge_id embeds the creation - // block height; respond_to_challenge references it. A best-block reorg of the - // creating tx would invalidate the id (-> ChallengeNotFound on respond). + // Finalized: the challenge_id must survive to the respond that references it + // (a best-block reorg would invalidate it -> ChallengeNotFound). const result = await submitTxFinalized( api.tx.StorageProvider.challenge_offchain({ bucket_id: bucketId, @@ -163,8 +162,7 @@ export async function challengeOffchain(api, client, provider, bucketId, upload) } export async function challengeCheckpoint(api, client, provider, bucketId, leafIndex) { - // Finalized, not in-block: see challengeOffchain — the challenge_id must - // survive to the respond_to_challenge that references it. + // Finalized: see challengeOffchain. const result = await submitTxFinalized( api.tx.StorageProvider.challenge_checkpoint({ bucket_id: bucketId, @@ -554,10 +552,7 @@ export async function signCheckpointProposal(providerUrl, bucketId, duty, window * from chain state and fetching MMR + chunk proofs from the provider node. */ export async function fetchChallengeProof(api, providerUrl, challengeId) { - // Read at the best (non-finalized) block: submitTx now resolves at in-block - // inclusion, so the challenge-creating extrinsic lives in the best block but - // not yet the finalized one. A default (finalized) read here races the - // ~6-block finality lag and spuriously reports "no challenges". + // Best block: a finalized read would lag the just-created challenge. const challenges = await api.query.StorageProvider.Challenges.getValue( challengeId.deadline, READ_OPTS diff --git a/examples/papi/common.js b/examples/papi/common.js index 3ff31674..2f5c61f8 100644 --- a/examples/papi/common.js +++ b/examples/papi/common.js @@ -149,9 +149,7 @@ export class NonceManager { } /** Read the on-chain nonce for `address` and build a manager from it. */ static async forAccount(api, address) { - // Best block, not finalized: with in-block tx submission the on-chain - // nonce advances at the best block; a finalized read returns a stale, - // lower nonce and the manager would collide with already-applied txs. + // Best block: a finalized read lags and returns a stale (too-low) nonce. const account = await api.query.System.Account.getValue(address, READ_OPTS); return new NonceManager(account.nonce); } @@ -213,19 +211,10 @@ export async function waitForBlock(papi, target, { logEvery = 5 } = {}) { export const DEFAULT_TX_TIMEOUT_MS = 180_000; /** - * Options object for storage reads (`getValue` / `getEntries`). - * - * `submitTx` resolves at in-block INCLUSION, not finalization, so any read that - * must observe a just-submitted write has to target the best (non-finalized) - * block — a default (finalized) read races the ~6-block finality lag and sees - * stale state. Spread this into every storage query in the demos: - * - * api.query.Pallet.Item.getValue(key, READ_OPTS) - * - * This is the single switch for the whole demo suite: flip `at` to "finalized" - * here to make every read observe finalized state instead — e.g. on a chain - * with fast finality where reorg-safety is preferred over read-your-writes - * latency. + * Options for storage reads (`getValue` / `getEntries`). `submitTx` returns at + * in-block inclusion, so reads must target the best block to see a just-written + * value (the default finalized head lags by ~6 blocks). Single switch for the + * suite: flip to "finalized" here to trade read-your-writes for reorg-safety. */ export const READ_OPTS = { at: "best" }; @@ -354,30 +343,14 @@ export async function waitForTransaction( } /** - * Sign + submit a transaction, assert it dispatched successfully, and return - * the in-block PAPI event (`{ ok, events, block, ... }`). + * Sign + submit a tx, assert it dispatched OK, return the in-block event + * (`{ ok, events, ... }`). Resolves at inclusion, not finalization — ~6x faster + * per tx, and the demos read what they need from the events. PAPI doesn't throw + * on a dispatch error (only on an invalid tx), so the `ok === false` check in + * `waitForTransaction` is what surfaces failures here. * - * Resolves at best-block INCLUSION, not finalization. On a local zombienet - * parachain, finality lags inclusion by several blocks (~36s vs ~6s per tx); - * waiting for finalization on every extrinsic is what pushed the integration - * demos past the 30-min CI cap. The demos verify outcomes via the emitted - * events, which are already present at inclusion. - * - * Caveat: best blocks are NOT reorg-proof. Even with a single collator the - * parachain's best block runs ahead of the relay-backed/finalized block and - * can be discarded before inclusion, rolling back the tx. That's fine for a - * self-contained tx the demo only reads events from, but NOT for a tx whose - * on-chain effect a LATER tx references by id (e.g. creating a challenge then - * responding to it): use `submitTxFinalized` for the creating tx so the id is - * stable. See its doc below. - * - * Like the finalized path it replaces, this asserts dispatch success: PAPI does - * NOT throw when an extrinsic dispatches with an error (only on an invalid tx — - * bad signature, low nonce, etc), so without this a failed extrinsic looks - * indistinguishable from a successful one with no events and surfaces later as - * a confusing "Expected exactly 1 X event, got 0". `waitForTransaction` raises - * on `ok === false` and is bounded by `timeoutMs` so a stuck mempool can't hang - * the example. + * Best blocks can be reorged, so for a tx whose effect a LATER tx references by + * id (e.g. create a challenge, then respond to it) use `submitTxFinalized`. */ export async function submitTx( tx, @@ -389,15 +362,10 @@ export async function submitTx( } /** - * Like `submitTx`, but resolves only once the tx is in a FINALIZED block. - * - * Slower (~6 blocks of finality lag) — use sparingly, ONLY where a subsequent - * tx depends on this one's on-chain effect by id. The motivating case: a - * challenge's id embeds the block height it was created at, and responding to - * it must reference that exact id. If the creating tx is merely in-block, a - * best-block reorg can roll it back, leaving the response to fail with - * `ChallengeNotFound`. Finalizing the creation pins the id so the response - * (which can stay in-block) always finds it. + * Like `submitTx` but waits for FINALIZATION (~6 blocks slower). Use only when + * a later tx references this one's effect by id: a challenge id embeds its + * creation block, so an in-block creation that gets reorged makes the response + * fail with `ChallengeNotFound`. Finalizing pins the id. */ export async function submitTxFinalized( tx, diff --git a/examples/papi/full-flow.js b/examples/papi/full-flow.js index a943951a..47043437 100644 --- a/examples/papi/full-flow.js +++ b/examples/papi/full-flow.js @@ -127,12 +127,9 @@ async function claimPaymentAfterExpiry(api, papi, provider, client, bucketId) { console.log("PASSED: Provider received payment!"); } -// Pull the ChallengeDefended event straight from the `respond_to_challenge` -// transaction's in-block events. A background `api.event...watch()` only sees -// FINALIZED blocks, which lag in-block submission by ~6 blocks — so a count -// taken right after responding would read 0. The dispatch emits the event in -// the same block the tx lands in, so the tx result is the authoritative, -// race-free source. +// Read ChallengeDefended from the respond tx's own in-block events. A +// background `api.event...watch()` only sees finalized blocks, so a count taken +// right after responding would read 0. function recordDefended(api, result, label) { const event = requireOneEvent( result.events, diff --git a/examples/papi/sc-api.js b/examples/papi/sc-api.js index 72273e66..f0041a3d 100644 --- a/examples/papi/sc-api.js +++ b/examples/papi/sc-api.js @@ -123,10 +123,8 @@ export async function callContract( value = 0n, gasLimit = DEFAULT_GAS_LIMIT, storageDepositLimit = DEFAULT_STORAGE_DEPOSIT_LIMIT, - // Set when a later tx references this call's on-chain effect by id (e.g. a - // precompile that creates a challenge, before respond_to_challenge). Waits - // for finalization so a best-block reorg can't roll the id out from under - // the dependent tx. See submitTxFinalized. + // Wait for finalization when a later tx references this call's effect by id + // (e.g. a precompile that creates a challenge). See submitTxFinalized. finalized = false, } = {} ) { diff --git a/examples/papi/sc-coverage.js b/examples/papi/sc-coverage.js index cbc4d188..d905f4fd 100644 --- a/examples/papi/sc-coverage.js +++ b/examples/papi/sc-coverage.js @@ -258,8 +258,7 @@ async function main() { await submitClientCheckpoint(api, client, provider, bucketC, ck); console.log("\n[10a] IWeb3Storage.challengeCheckpoint(bucketC, provider, leafIdx, chunkIdx=0)"); - // Finalize the challenge-creating call: respond_to_challenge below - // references the challenge_id, which a best-block reorg could invalidate. + // Finalized: respondToChallenge below references the challenge_id. r = await callPrecompile( api, client,