diff --git a/client/src/challenger.rs b/client/src/challenger.rs index 97b6a7f6..23b24f7b 100644 --- a/client/src/challenger.rs +++ b/client/src/challenger.rs @@ -9,7 +9,7 @@ //! - Automated challenge strategies use crate::base::{BaseClient, ClientConfig, ClientError, ClientResult}; -use crate::substrate::{extrinsics, storage, SubstrateClient}; +use crate::substrate::{extrinsics, fetch_current_anchor_block, storage, SubstrateClient}; use sp_runtime::AccountId32; use storage_primitives::{BucketId, ChunkLocation, Commitment}; use subxt::dynamic::At; @@ -382,12 +382,10 @@ impl ChallengerClient { // Compute checkpoint age from bucket snapshot let last_checkpoint_age = { - let current_block = chain - .api() - .at_current_block() - .await - .map_err(|e| ClientError::Chain(format!("Failed to get latest block: {e}")))? - .block_number() as u32; + // `checkpoint_block` is on the pallet's anchor clock (relay + // blocks), so measure the age on that clock, not the parachain + // height. + let anchor_block = fetch_current_anchor_block(&at).await?; let (addr, keys) = storage::bucket_info(bucket_id); let bucket_thunk = at @@ -413,7 +411,7 @@ impl ChallengerClient { .and_then(|v| v.as_u128()) .unwrap_or(0) as u32; - current_block.saturating_sub(checkpoint_block) + anchor_block.saturating_sub(checkpoint_block) } else { 0 } diff --git a/client/src/substrate.rs b/client/src/substrate.rs index 53e2c370..48ae43bb 100644 --- a/client/src/substrate.rs +++ b/client/src/substrate.rs @@ -862,6 +862,30 @@ pub mod storage { // Helper functions for common operations +/// The pallet's anchor block — the clock every on-chain duration is measured +/// against — via the `StorageProviderApi::current_anchor_block` runtime API. +/// Compare anchor-denominated values (deadlines, expiries, `checkpoint_block`) +/// against this, never the parachain height. +pub async fn fetch_current_anchor_block( + at: &subxt::client::ClientAtBlock, +) -> Result +where + C: subxt::client::OnlineClientAtBlockT, +{ + use codec::Decode; + // Raw `state_call` + manual decode so the API needn't be in the node's + // metadata snapshot. + let bytes = at + .runtime_apis() + .call_raw("StorageProviderApi_current_anchor_block", None) + .await + .map_err(|e| { + ClientError::Chain(format!("current_anchor_block runtime API call failed: {e}")) + })?; + u32::decode(&mut &bytes[..]) + .map_err(|e| ClientError::Chain(format!("Failed to decode anchor block: {e}"))) +} + /// Parse a hex string to H256. pub fn parse_h256(hex: &str) -> Result { let hex = hex.strip_prefix("0x").unwrap_or(hex); diff --git a/docs/design/EXECUTION_FLOWS.md b/docs/design/EXECUTION_FLOWS.md index 001fbf0f..c75f14c6 100644 --- a/docs/design/EXECUTION_FLOWS.md +++ b/docs/design/EXECUTION_FLOWS.md @@ -538,9 +538,11 @@ sequenceDiagram participant C as Chain (Pallet) participant B as Balances - Note over C: on_finalize(block_number) hook + Note over C: on_initialize(n) slash sweep - C->>C: expired = Challenges::take(block_number) + Note over C: Deadlines are relay-chain blocks. Drain every deadline key in
(LastSweptChallengeBlock, previous relay parent], budget-capped. + + C->>C: expired = Challenges::drain_prefix(deadline_key) loop For each expired challenge Note over C: Provider failed to respond! diff --git a/examples/papi/checkpoint-missed.ts b/examples/papi/checkpoint-missed.ts index c4e3139b..59f2778e 100644 --- a/examples/papi/checkpoint-missed.ts +++ b/examples/papi/checkpoint-missed.ts @@ -26,6 +26,7 @@ import assert from "node:assert"; import { configureCheckpointWindow, connect, + currentRelayBlock, ensureProviderRegistered, establishStorageAgreement, makeSigner, @@ -33,7 +34,7 @@ import { READ_OPTS, reportMissedCheckpoint, sameAddress, - waitForBlock, + waitForRelayBlock, waitForBlockProduction, waitForChainReady, waitForNextBlock, @@ -107,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(READ_OPTS)); + const head = await currentRelayBlock(api); 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; @@ -118,7 +119,7 @@ async function main() { windowEnd, windowEnd ); - await waitForBlock(papi, windowEnd); + await waitForRelayBlock(papi, api, windowEnd); console.log("\n=== Step 4: Record balances, then report_missed_checkpoint ==="); const providerBefore = (await api.query.StorageProvider.Providers.getValue( diff --git a/examples/papi/e2e/02-agreement-lifecycle.ts b/examples/papi/e2e/02-agreement-lifecycle.ts index b7c37fcc..f96c7ae4 100644 --- a/examples/papi/e2e/02-agreement-lifecycle.ts +++ b/examples/papi/e2e/02-agreement-lifecycle.ts @@ -24,7 +24,7 @@ import { negotiateTerms, READ_OPTS, sameAddress, - waitForBlock, + waitForRelayBlock, } from "@web3-storage/sdk"; import { getFree, @@ -90,7 +90,7 @@ async function main() { ))!; const expiresAt = Number(agreement.expires_at); console.log(" Waiting for expiry at block %d...", expiresAt); - await waitForBlock(papi, expiresAt); + await waitForRelayBlock(papi, api, expiresAt); const provBefore = await getFree(api, provider); await endAgreement(api, client, provider, bucketId, "Pay"); const provAfter = await getFree(api, provider); @@ -117,7 +117,7 @@ async function main() { READ_OPTS ))!; console.log(" Waiting for expiry at block %d...", Number(agreement.expires_at)); - await waitForBlock(papi, Number(agreement.expires_at)); + await waitForRelayBlock(papi, api, Number(agreement.expires_at)); const result = await endAgreement(api, client, provider, bucketId, "Burn", { burn_percent: 100, }); diff --git a/examples/papi/e2e/04-data-upload-and-retrieval.ts b/examples/papi/e2e/04-data-upload-and-retrieval.ts index 7708474b..cc863782 100644 --- a/examples/papi/e2e/04-data-upload-and-retrieval.ts +++ b/examples/papi/e2e/04-data-upload-and-retrieval.ts @@ -13,6 +13,7 @@ import assert from "node:assert"; import { blake2b256 } from "@polkadot-labs/hdkd-helpers"; import { + currentRelayBlock, downloadChunk, ensureProviderRegistered, makeSigner, @@ -55,7 +56,7 @@ async function main() { name: "4.1 Small chunk (100 bytes)", fn: async () => { const data = "x".repeat(100); - const nonce = Number(await api.query.System.Number.getValue()); + const nonce = await currentRelayBlock(api); const { hash, commit } = await uploadChunk(PROVIDER_URL, bucketId, data, nonce); assert.ok(commit.mmr_root, "Should return mmr_root"); const downloaded = await downloadChunk(PROVIDER_URL, hash); @@ -67,7 +68,7 @@ async function main() { name: "4.2 Medium chunk (64 KB)", fn: async () => { const data = randomBytes(64 * 1024); - const nonce = Number(await api.query.System.Number.getValue()); + const nonce = await currentRelayBlock(api); const { hash } = await uploadChunk(PROVIDER_URL, bucketId, data, nonce); const downloaded = await downloadChunk(PROVIDER_URL, hash); assert.deepStrictEqual(new Uint8Array(downloaded), data, "64KB roundtrip integrity"); @@ -78,7 +79,7 @@ async function main() { name: "4.3 Max chunk size (256 KB)", fn: async () => { const data = randomBytes(256 * 1024); - const nonce = Number(await api.query.System.Number.getValue()); + const nonce = await currentRelayBlock(api); const { hash } = await uploadChunk(PROVIDER_URL, bucketId, data, nonce); const downloaded = await downloadChunk(PROVIDER_URL, hash); assert.deepStrictEqual(new Uint8Array(downloaded), data, "256KB roundtrip integrity"); @@ -89,7 +90,7 @@ async function main() { name: "4.4 Multiple sequential uploads", fn: async () => { const uploads = []; - const nonce = Number(await api.query.System.Number.getValue()); + const nonce = await currentRelayBlock(api); for (let i = 0; i < 5; i++) { const data = `sequential upload #${i} @ ${Date.now()}`; const result = await uploadChunk(PROVIDER_URL, bucketId, data, nonce); @@ -207,7 +208,7 @@ async function main() { name: "4.10 Upload binary (non-UTF8) data", fn: async () => { const binary = randomBytes(512); - const nonce = Number(await api.query.System.Number.getValue()); + const nonce = await currentRelayBlock(api); const { hash } = await uploadChunk(PROVIDER_URL, bucketId, binary, nonce); const downloaded = await downloadChunk(PROVIDER_URL, hash); assert.deepStrictEqual(new Uint8Array(downloaded), binary, "Binary roundtrip should match"); @@ -218,7 +219,7 @@ async function main() { name: "4.11 Upload identical content twice — different MMR leaves", fn: async () => { const data = "duplicate content for e2e"; - const nonce = Number(await api.query.System.Number.getValue()); + const nonce = await currentRelayBlock(api); const first = await uploadChunk(PROVIDER_URL, bucketId, data, nonce); const second = await uploadChunk(PROVIDER_URL, bucketId, data, nonce); assert.strictEqual(first.hash, second.hash, "Same data should produce same hash"); @@ -236,7 +237,7 @@ async function main() { const data = "verify hash computation"; const bytes = new TextEncoder().encode(data); const expectedHash = toHex(blake2b256(bytes)); - const nonce = Number(await api.query.System.Number.getValue()); + const nonce = await currentRelayBlock(api); const { hash } = await uploadChunk(PROVIDER_URL, bucketId, data, nonce); assert.strictEqual(hash, expectedHash, "Provider hash should match local blake2-256"); }, diff --git a/examples/papi/e2e/05-checkpoint-and-challenges.ts b/examples/papi/e2e/05-checkpoint-and-challenges.ts index ada5fc3e..83c1e030 100644 --- a/examples/papi/e2e/05-checkpoint-and-challenges.ts +++ b/examples/papi/e2e/05-checkpoint-and-challenges.ts @@ -16,6 +16,7 @@ import { challengeOffchain, claimCheckpointRewards, configureCheckpointWindow, + currentRelayBlock, ensureProviderRegistered, fetchChallengeProof, fetchCheckpointDuty, @@ -28,7 +29,7 @@ import { submitClientCheckpoint, submitProviderCheckpoint, uploadChunk, - waitForBlock, + waitForRelayBlock, } from "@web3-storage/sdk"; import { ensureSoleAcceptingProvider } from "../support.js"; import { negotiateAndEstablish, runSuite, submitTxExpectFailure, setupChain } from "./helpers.js"; @@ -57,7 +58,7 @@ async function main() { }); const payload = `checkpoint-test @ ${Date.now()}`; - const uploadNonce = Number(await api.query.System.Number.getValue()); + const uploadNonce = await currentRelayBlock(api); const upload = await uploadChunk(PROVIDER_URL, bucketId, payload, uploadNonce); const uploadInfo = { leafIndex: upload.commit.leaf_indices[0], @@ -75,7 +76,7 @@ async function main() { tests.push({ name: "5.1 Client checkpoint", fn: async () => { - const ckNonce = Number(await api.query.System.Number.getValue()); + const ckNonce = await currentRelayBlock(api); const ck = await fetchCheckpointSignature(PROVIDER_URL, bucketId, ckNonce); assert.ok(ck.mmr_root, "Checkpoint should have mmr_root"); const result = await submitClientCheckpoint(api, client, provider, bucketId, ck); @@ -131,12 +132,12 @@ async function main() { // Compute current window with headroom. const HEADROOM = 8; - let currentBlock = Number(await api.query.System.Number.getValue(READ_OPTS)); + let currentBlock = await currentRelayBlock(api); let windowNum = Math.floor(currentBlock / WINDOW_INTERVAL); let nextWindowStart = (windowNum + 1) * WINDOW_INTERVAL; if (nextWindowStart - currentBlock < HEADROOM) { - await waitForBlock(papi, nextWindowStart - 1); - currentBlock = Number(await api.query.System.Number.getValue(READ_OPTS)); + await waitForRelayBlock(papi, api, nextWindowStart - 1); + currentBlock = await currentRelayBlock(api); windowNum = Math.floor(currentBlock / WINDOW_INTERVAL); } const window = BigInt(windowNum); diff --git a/examples/papi/e2e/10-edge-cases-and-adversarial.ts b/examples/papi/e2e/10-edge-cases-and-adversarial.ts index 45cb51c6..98045f1b 100644 --- a/examples/papi/e2e/10-edge-cases-and-adversarial.ts +++ b/examples/papi/e2e/10-edge-cases-and-adversarial.ts @@ -15,6 +15,7 @@ import { Enum } from "polkadot-api"; import { blake2b256 } from "@polkadot-labs/hdkd-helpers"; import { endAgreement, + currentRelayBlock, ensureProviderRegistered, fetchCheckpointSignature, freezeBucket, @@ -24,7 +25,7 @@ import { submitClientCheckpoint, toHex, uploadChunk, - waitForBlock, + waitForRelayBlock, } from "@web3-storage/sdk"; import { ensureSoleAcceptingProvider } from "../support.js"; import { @@ -108,7 +109,7 @@ async function main() { provider.address, READ_OPTS ))!; - await waitForBlock(papi, Number(agreement.expires_at)); + await waitForRelayBlock(papi, api, Number(agreement.expires_at)); await endAgreement(api, bob, provider, bucketId, "Pay"); const infoAfter = (await api.query.StorageProvider.Providers.getValue( provider.address, @@ -131,7 +132,7 @@ async function main() { duration: 100, }); // freeze_bucket requires a snapshot (checkpoint) to exist. - const nonce = Number(await api.query.System.Number.getValue()); + const nonce = await currentRelayBlock(api); await uploadChunk(PROVIDER_URL, bucketId, "data for snapshot", nonce); const ck = await fetchCheckpointSignature(PROVIDER_URL, bucketId, nonce); await submitClientCheckpoint(api, bob, provider, bucketId, ck); @@ -153,7 +154,7 @@ async function main() { duration: 100, }); // Upload some data. - const nonce1 = Number(await api.query.System.Number.getValue()); + const nonce1 = await currentRelayBlock(api); await uploadChunk(PROVIDER_URL, bucketId, "pre-freeze data", nonce1); // Checkpoint before freeze. const ck1 = await fetchCheckpointSignature(PROVIDER_URL, bucketId, nonce1); @@ -161,7 +162,7 @@ async function main() { // Freeze. await freezeBucket(api, bob, bucketId); // Upload more data. - const nonce2 = Number(await api.query.System.Number.getValue()); + const nonce2 = await currentRelayBlock(api); await uploadChunk(PROVIDER_URL, bucketId, "post-freeze data", nonce2); // Checkpoint after freeze — should still work (captures frozen_start_seq). const ck2 = await fetchCheckpointSignature(PROVIDER_URL, bucketId, nonce2); @@ -213,7 +214,7 @@ async function main() { const data = "integrity check data for blake2-256"; const bytes = new TextEncoder().encode(data); const expectedHash = toHex(blake2b256(bytes)); - const nonce = Number(await api.query.System.Number.getValue()); + const nonce = await currentRelayBlock(api); const { hash } = await uploadChunk(PROVIDER_URL, bucketId, data, nonce); assert.strictEqual(hash, expectedHash, "Provider hash should match local blake2-256"); }, @@ -227,7 +228,7 @@ async function main() { duration: 100, }); const data = "identical content for dedup test"; - const nonce = Number(await api.query.System.Number.getValue()); + const nonce = await currentRelayBlock(api); const r1 = await uploadChunk(PROVIDER_URL, bucketId, data, nonce); const r2 = await uploadChunk(PROVIDER_URL, bucketId, data, nonce); assert.strictEqual(r1.hash, r2.hash, "Hashes should match for identical content"); diff --git a/examples/papi/full-flow.ts b/examples/papi/full-flow.ts index b9b86673..372e8f3e 100644 --- a/examples/papi/full-flow.ts +++ b/examples/papi/full-flow.ts @@ -24,6 +24,7 @@ import { challengeCheckpoint, challengeOffchain, connect, + currentRelayBlock, downloadChunk, endAgreement, ensureProviderRegistered, @@ -37,7 +38,7 @@ import { respondToChallenge, submitClientCheckpoint, uploadChunk, - waitForBlock, + waitForRelayBlock, waitForBlockProduction, waitForChainReady, waitForNextBlock, @@ -65,7 +66,10 @@ async function setupAgreement( provider: ChainSigner ): Promise { const maxBytes = 1_073_741_824n; // 1 GiB - const duration = 15; + // Relay-chain blocks (6s each). The relay clock keeps ticking while the + // parachain onboards or skips slots, so this needs headroom for steps 2-7 + // before the step-8 expiry wait. + const duration = 40; console.log( " Negotiating signed terms with provider (max_bytes=%s, duration=%d)...", maxBytes, @@ -92,7 +96,7 @@ async function setupAgreement( async function uploadAndVerify(api: ParachainApi, bucketId: bigint) { const payload = `Hello, Web3 Storage! [${new Date().toISOString()}] provider=${PROVIDER_SEED}`; - const nonce = Number(await api.query.System.Number.getValue()); + const nonce = await currentRelayBlock(api); const { hash, data, commit } = await uploadChunk(PROVIDER_URL, bucketId, payload, nonce); console.log(" Uploaded %d bytes, mmr_root=%s", data.length, commit.mmr_root); @@ -129,7 +133,7 @@ async function claimPaymentAfterExpiry(api: ParachainApi, papi: PolkadotClient, console.log(" Provider balance before:", freeBefore.toString()); console.log(" Waiting for agreement to expire..."); - await waitForBlock(papi, expiresAt); + await waitForRelayBlock(papi, api, expiresAt); await endAgreement(api, client, provider, bucketId, "Pay"); const freeAfter = ( @@ -209,7 +213,7 @@ async function main() { console.log(" Challenge defended"); console.log("\n=== Step 5: Submit checkpoint ==="); - const ckNonce = Number(await api.query.System.Number.getValue()); + const ckNonce = await currentRelayBlock(api); const ck = await fetchCheckpointSignature(PROVIDER_URL, bucketId, ckNonce); console.log(" Checkpoint mmr_root:", ck.mmr_root); console.log(" Checkpoint leaf_count:", ck.leaf_count); diff --git a/examples/papi/sc-coverage.ts b/examples/papi/sc-coverage.ts index 7ac9483c..4305720e 100644 --- a/examples/papi/sc-coverage.ts +++ b/examples/papi/sc-coverage.ts @@ -27,6 +27,7 @@ import { fileURLToPath } from "node:url"; import { connect, + currentRelayBlock, ensureProviderRegistered, fetchChallengeProof, fetchCheckpointSignature, @@ -265,9 +266,9 @@ async function main() { console.log(" bucketC =", bucketC.toString()); console.log(" preconditions: uploadChunk + submitClientCheckpoint"); - const uploadNonce = Number(await api.query.System.Number.getValue()); + const uploadNonce = await currentRelayBlock(api); const upload = await uploadChunk(providerUrl, bucketC, "coverage-test", uploadNonce); - const ckNonce = Number(await api.query.System.Number.getValue()); + const ckNonce = await currentRelayBlock(api); const ck = await fetchCheckpointSignature(providerUrl, bucketC, ckNonce); await submitClientCheckpoint(api, client, provider, bucketC, ck); diff --git a/examples/papi/sc-flow.ts b/examples/papi/sc-flow.ts index 0fd02e40..2215fd81 100644 --- a/examples/papi/sc-flow.ts +++ b/examples/papi/sc-flow.ts @@ -31,6 +31,7 @@ import { fileURLToPath } from "node:url"; import { challengeOffchain, connect, + currentRelayBlock, downloadChunk, ensureProviderRegistered, fetchChallengeProof, @@ -167,7 +168,7 @@ async function main() { // 5) Off-chain ops are unchanged — they bypass the contract entirely. console.log("\n[5/6] Off-chain upload + challenge round-trip…"); const payload = `Hello via SC! ${new Date().toISOString()}`; - const uploadNonce = Number(await api.query.System.Number.getValue()); + const uploadNonce = await currentRelayBlock(api); const upload = await uploadChunk(PROVIDER_URL, bucketId, payload, uploadNonce); const downloaded = await downloadChunk(PROVIDER_URL, upload.hash); assert.deepStrictEqual( diff --git a/packages/layer0/src/provider-http.ts b/packages/layer0/src/provider-http.ts index 699d0cdc..0c7eddf2 100644 --- a/packages/layer0/src/provider-http.ts +++ b/packages/layer0/src/provider-http.ts @@ -127,9 +127,10 @@ export async function uploadChunk( children: null, }, }); - // `nonce` is the block at which the caller intends to submit the extrinsic - // consuming the resulting `provider_signature`. The pallet rejects signatures - // whose nonce is older than `MaxNonceAge`, so thread it into /commit. + // `nonce` is the relay-chain block (see `currentRelayBlock`) at which the + // caller intends to submit the extrinsic consuming the resulting + // `provider_signature`. The pallet rejects signatures whose nonce is older + // than `MaxNonceAge`, so thread it into /commit. const commit = await providerFetch(providerUrl, "/commit", { method: "POST", body: { bucket_id: Number(bucketId), data_roots: [hash], nonce: Number(nonce) }, @@ -152,8 +153,9 @@ export async function fetchCheckpointSignature( bucketId: bigint | number, nonce: bigint | number, ): Promise { - // The provider signs the CommitmentPayload including `nonce`; pass the block - // the caller will submit at so the on-chain recency check passes. + // The provider signs the CommitmentPayload including `nonce`; pass the + // relay block (see `currentRelayBlock`) the caller will submit at so the + // on-chain recency check passes. return providerFetch(providerUrl, "/checkpoint-signature", { params: { bucket_id: bucketId, nonce: Number(nonce) }, }); diff --git a/packages/layer0/src/waits.ts b/packages/layer0/src/waits.ts index bb75f31e..e35a899e 100644 --- a/packages/layer0/src/waits.ts +++ b/packages/layer0/src/waits.ts @@ -11,7 +11,7 @@ */ import type { PolkadotClient } from "polkadot-api"; -import { filter, firstValueFrom, map, timeout, TimeoutError } from "rxjs"; +import { filter, firstValueFrom, map, switchMap, timeout, TimeoutError } from "rxjs"; import type { Observable } from "rxjs"; import type { ParachainApi } from "./address.js"; @@ -129,3 +129,48 @@ export async function waitForBlock( { timeoutMs: timeoutMs ?? null, description: `best block > ${target}` }, ); } + +/** + * Current relay-chain block number, read from + * `ParachainSystem.LastRelayChainBlockNumber` at the best block. + * + * Every on-chain duration (timeouts, checkpoint windows, `valid_until`, + * `CommitmentPayload.nonce` recency) is measured in relay-chain blocks, not + * parachain blocks — snapshot this, never `System.Number`, when building a + * nonce or window-timed extrinsic. + */ +export async function currentRelayBlock(api: ParachainApi): Promise { + return Number( + await api.query.ParachainSystem.LastRelayChainBlockNumber.getValue(READ_OPTS), + ); +} + +/** + * Wait until the relay-chain block anchored to the parachain's best head is + * strictly greater than `target`. + * + * The relay-block counterpart of [`waitForBlock`]: checkpoint windows and + * timeouts elapse on the relay clock, so window-timed extrinsics must wait on + * this one. + */ +export async function waitForRelayBlock( + papi: PolkadotClient, + api: ParachainApi, + target: number, + { logEvery = 5, timeoutMs }: { logEvery?: number; timeoutMs?: number } = {}, +): Promise { + await firstMatch( + papi.bestBlocks$.pipe( + switchMap(() => currentRelayBlock(api)), + map((n) => { + if (logEvery > 0 && n % logEvery === 0) { + console.log(" relay=#%d (target > %d)", n, target); + } + return n; + }), + ), + (n) => n > target, + // No default timeout: waiting out a long block window is the point here. + { timeoutMs: timeoutMs ?? null, description: `relay block > ${target}` }, + ); +} diff --git a/pallet/src/benchmarking.rs b/pallet/src/benchmarking.rs index 725a517c..50e5e2e2 100644 --- a/pallet/src/benchmarking.rs +++ b/pallet/src/benchmarking.rs @@ -19,6 +19,15 @@ use storage_primitives::{ const SEED: u32 = 0; +/// Advance the clock the pallet logic reads. `System` and the configured +/// [`Config::BlockNumberProvider`] are distinct clocks in a real runtime, so +/// benchmarks must advance both. +fn set_block_number(n: BlockNumberFor) { + use sp_runtime::traits::BlockNumberProvider; + System::::set_block_number(n); + T::BlockNumberProvider::set_block_number(n); +} + /// Key type used by the benchmarking keystore for provider signing material. const KEY_TYPE: sp_core::crypto::KeyTypeId = sp_core::crypto::KeyTypeId(*b"bnch"); @@ -79,7 +88,8 @@ fn build_primary_terms( max_bytes, duration, price_per_byte: 1u32.into(), - valid_until: System::::block_number().saturating_add(T::RequestTimeout::get()), + valid_until: StorageProvider::::current_anchor_block() + .saturating_add(T::RequestTimeout::get()), nonce, bucket_id: None, replica_params: None, @@ -99,7 +109,8 @@ fn build_replica_terms( max_bytes, duration, price_per_byte: 1u32.into(), - valid_until: System::::block_number().saturating_add(T::RequestTimeout::get()), + valid_until: StorageProvider::::current_anchor_block() + .saturating_add(T::RequestTimeout::get()), nonce, bucket_id: Some(bucket_id), replica_params: Some(ReplicaTerms { @@ -176,9 +187,9 @@ fn add_primary_to_bucket( bucket_id: BucketId, max_bytes: u64, ) { - let current_block = System::::block_number(); + let anchor_block = StorageProvider::::current_anchor_block(); let duration: BlockNumberFor = 100u32.into(); - let expires_at = current_block.saturating_add(duration); + let expires_at = anchor_block.saturating_add(duration); Buckets::::mutate(bucket_id, |maybe| { if let Some(b) = maybe { @@ -194,7 +205,7 @@ fn add_primary_to_bucket( expires_at, extensions_blocked: false, role: ProviderRole::Primary, - started_at: current_block, + started_at: anchor_block, }; StorageAgreements::::insert(bucket_id, provider, agreement); @@ -322,9 +333,9 @@ mod benchmarks { let _ = Pallet::::deregister_provider(RawOrigin::Signed(provider.clone()).into()); // Advance past `DeregisterAnnouncementPeriod` so completion is allowed. - let announce_block = System::::block_number(); + let announce_block = StorageProvider::::current_anchor_block(); let complete_after = announce_block.saturating_add(T::DeregisterAnnouncementPeriod::get()); - System::::set_block_number(complete_after); + set_block_number::(complete_after); #[extrinsic_call] complete_deregister(RawOrigin::Signed(provider)); @@ -602,13 +613,13 @@ mod benchmarks { // Advance block past agreement expiry + settlement timeout let agreement = StorageProvider::::storage_agreements(bucket_id, &provider).unwrap(); - let current_block = System::::block_number(); + let anchor_block = StorageProvider::::current_anchor_block(); let target_block: BlockNumberFor = agreement .expires_at .saturating_add(T::SettlementTimeout::get()) - .saturating_add(current_block) + .saturating_add(anchor_block) .saturating_add(1u32.into()); - System::::set_block_number(target_block); + set_block_number::(target_block); #[extrinsic_call] claim_expired_agreement(RawOrigin::Signed(provider), bucket_id); @@ -773,7 +784,7 @@ mod benchmarks { let target_block: BlockNumberFor = window_start .saturating_add(grace_period) .saturating_add(1u32.into()); - System::::set_block_number(target_block); + set_block_number::(target_block); // Sign the CheckpointProposal with all s providers. let proposal = @@ -832,13 +843,13 @@ mod benchmarks { let provider = create_provider::(0); let bucket_id = setup_primary_agreement::(&admin, &provider, 0); - // Report window 1 — must satisfy current_block > window_start_block(window+1, interval). + // Report window 1 — must satisfy anchor_block > window_start_block(window+1, interval). // Target: interval * (window + 1) + 1 let window = 1u64; let interval = T::DefaultCheckpointInterval::get(); let next_window_start = interval.saturating_mul((window + 1).saturated_into()); let target_block: BlockNumberFor = next_window_start.saturating_add(1u32.into()); - System::::set_block_number(target_block); + set_block_number::(target_block); #[extrinsic_call] report_missed_checkpoint(RawOrigin::Signed(admin), bucket_id, window); @@ -1234,15 +1245,29 @@ mod benchmarks { ); } - /// `on_finalize` slash sweep: drains and slashes every challenge expiring - /// at a deadline. Linear in the challenge count `c`; each entry is drained, - /// its pending counters decremented, and its provider slashed. The cost is - /// charged up front in `on_initialize` via - /// `WeightInfo::on_initialize_slash_challenges(c)`. The upper bound is the - /// runtime cap `MaxChallengesPerDeadline` itself, so the linear fit covers - /// the true worst case rather than extrapolating to it. + /// `on_initialize` slash sweep: drains and slashes every challenge expiring + /// at a single deadline key. Linear in the challenge count `c`; each entry + /// is drained, its pending counters decremented, and its provider slashed. + /// The upper bound is the effective per-block slash budget + /// `min(MaxChallengesPerDeadline, MAX_SWEEP_SLASH_BUDGET)` — the most the + /// sweep ever slashes for one key in a block — so the linear fit covers the + /// true worst case rather than extrapolating to it. The sweep applies this + /// per key, so a small fixed hook overhead is counted here and again in the + /// hook's base weight — conservative. #[benchmark] - fn on_initialize_slash_challenges(c: Linear<0, { T::MaxChallengesPerDeadline::get() as u32 }>) { + fn on_initialize_slash_challenges( + c: Linear< + 0, + { + let cap = T::MaxChallengesPerDeadline::get() as u32; + if cap < crate::pallet::MAX_SWEEP_SLASH_BUDGET { + cap + } else { + crate::pallet::MAX_SWEEP_SLASH_BUDGET + } + }, + >, + ) { let deadline: BlockNumberFor = 200u32.into(); let deposit: BalanceOf = 100u32.into(); for i in 0..c { @@ -1272,10 +1297,25 @@ mod benchmarks { } NextChallengeIndex::::insert(deadline, c as u16); + // Drive the real sweep over exactly one key. Anchor the cursor one + // below `deadline`, then set the relay clock so the sweepable range + // (keys < previous relay parent) is exactly `{deadline}`: + // `sweepable = current_anchor_block() - 1 = deadline`, `end = deadline`. + LastSweptChallengeBlock::::put(deadline.saturating_sub(1u32.into())); + let now = deadline.saturating_add(1u32.into()); + set_block_number::(now); + #[block] { - StorageProvider::::on_finalize(deadline); + StorageProvider::::on_initialize(now); } + + // Guard against the sweep silently no-op'ing (the bug this benchmark + // had while it still called the dropped `on_finalize`): every challenge + // at the deadline must have been drained. (At the worst-case component + // `c == MaxChallengesPerDeadline` the slash budget is exactly spent, so + // the cursor parks at `deadline - 1` and carries over — expected.) + assert_eq!(Challenges::::iter_prefix(deadline).count(), 0); } impl_benchmark_test_suite!(Pallet, crate::mock::new_test_ext(), crate::mock::Test); diff --git a/pallet/src/impls/agreements.rs b/pallet/src/impls/agreements.rs index 7ef97848..0b16d0da 100644 --- a/pallet/src/impls/agreements.rs +++ b/pallet/src/impls/agreements.rs @@ -166,11 +166,11 @@ impl Pallet { ensure!(terms.max_bytes > 0, Error::::InvalidMaxBytesRequest); // Quote must not be stale and must not exceed the chain-enforced window. - // `terms.valid_until` must in range [current_block, current_block + RequestTimeout] - let current_block = frame_system::Pallet::::block_number(); - ensure!(terms.valid_until >= current_block, Error::::TermsExpired); + // `terms.valid_until` must in range [anchor_block, anchor_block + RequestTimeout] + let anchor_block = Self::current_anchor_block(); + ensure!(terms.valid_until >= anchor_block, Error::::TermsExpired); ensure!( - terms.valid_until <= current_block.saturating_add(T::RequestTimeout::get()), + terms.valid_until <= anchor_block.saturating_add(T::RequestTimeout::get()), Error::::TermsValidityTooLong ); @@ -233,7 +233,7 @@ impl Pallet { // `BucketCreated` for us. let bucket_id = Self::create_bucket_internal(owner, 1, Some(provider))?; - let expires_at = current_block.saturating_add(terms.duration); + let expires_at = anchor_block.saturating_add(terms.duration); let agreement = StorageAgreement { owner: owner.clone(), max_bytes: terms.max_bytes, @@ -242,7 +242,7 @@ impl Pallet { expires_at, extensions_blocked: false, role: ProviderRole::Primary, - started_at: current_block, + started_at: anchor_block, }; Providers::::mutate(provider, |maybe_provider| { @@ -297,10 +297,10 @@ impl Pallet { ensure!(terms.max_bytes > 0, Error::::InvalidMaxBytesRequest); // Quote must not be stale and must not exceed the chain-enforced window. - let current_block = frame_system::Pallet::::block_number(); - ensure!(terms.valid_until >= current_block, Error::::TermsExpired); + let anchor_block = Self::current_anchor_block(); + ensure!(terms.valid_until >= anchor_block, Error::::TermsExpired); ensure!( - terms.valid_until <= current_block.saturating_add(T::RequestTimeout::get()), + terms.valid_until <= anchor_block.saturating_add(T::RequestTimeout::get()), Error::::TermsValidityTooLong ); @@ -381,7 +381,7 @@ impl Pallet { .ok_or(Error::::ArithmeticOverflow)?; T::Currency::reserve(owner, total_lock)?; - let expires_at = current_block.saturating_add(terms.duration); + let expires_at = anchor_block.saturating_add(terms.duration); let agreement = StorageAgreement { owner: owner.clone(), max_bytes: terms.max_bytes, @@ -395,7 +395,7 @@ impl Pallet { min_sync_interval: replica_terms.min_sync_interval, last_sync: None, }, - started_at: current_block, + started_at: anchor_block, }; Providers::::mutate(provider, |maybe_provider| { diff --git a/pallet/src/impls/buckets.rs b/pallet/src/impls/buckets.rs index dc72f6b6..82a8eb62 100644 --- a/pallet/src/impls/buckets.rs +++ b/pallet/src/impls/buckets.rs @@ -44,8 +44,8 @@ impl Pallet { for (provider, agreement) in agreements { // Calculate prorated refund based on remaining time - let current_block = frame_system::Pallet::::block_number(); - let remaining_blocks = agreement.expires_at.saturating_sub(current_block); + let anchor_block = Self::current_anchor_block(); + let remaining_blocks = agreement.expires_at.saturating_sub(anchor_block); // If there's remaining time, calculate prorated refund let refund_to_owner = if remaining_blocks > Zero::zero() { diff --git a/pallet/src/impls/challenges.rs b/pallet/src/impls/challenges.rs index 64120c50..a1db8615 100644 --- a/pallet/src/impls/challenges.rs +++ b/pallet/src/impls/challenges.rs @@ -28,8 +28,8 @@ impl Pallet { T::Currency::reserve(&challenger, deposit)?; - let current_block = frame_system::Pallet::::block_number(); - let deadline = current_block.saturating_add(T::ChallengeTimeout::get()); + let anchor_block = Self::current_anchor_block(); + let deadline = anchor_block.saturating_add(T::ChallengeTimeout::get()); let challenge = Challenge { bucket_id, diff --git a/pallet/src/impls/checkpoints.rs b/pallet/src/impls/checkpoints.rs index 90692cc7..95772637 100644 --- a/pallet/src/impls/checkpoints.rs +++ b/pallet/src/impls/checkpoints.rs @@ -10,10 +10,10 @@ use storage_primitives::{BucketId, HISTORICAL_ROOT_PRIMES}; impl Pallet { pub(crate) fn update_historical_roots( bucket: &mut Bucket, - current_block: BlockNumberFor, + anchor_block: BlockNumberFor, mmr_root: H256, ) { - let block_num: u32 = current_block.try_into().unwrap_or(0u32); + let block_num: u32 = anchor_block.try_into().unwrap_or(0u32); for (i, &prime) in HISTORICAL_ROOT_PRIMES.iter().enumerate() { let quotient = block_num / prime; @@ -103,14 +103,14 @@ impl Pallet { }) } - /// Check if the current block is within the grace period for a window. + /// Check if the anchor block is within the grace period for a window. pub(crate) fn is_within_grace_period( - current_block: BlockNumberFor, + anchor_block: BlockNumberFor, window: u64, config: &storage_primitives::CheckpointWindowConfig>, ) -> bool { let window_start = Self::window_start_block(window, config.interval); let grace_end = window_start.saturating_add(config.grace_period); - current_block <= grace_end + anchor_block <= grace_end } } diff --git a/pallet/src/impls/providers.rs b/pallet/src/impls/providers.rs index b50340d3..3dbd0aec 100644 --- a/pallet/src/impls/providers.rs +++ b/pallet/src/impls/providers.rs @@ -86,7 +86,7 @@ impl Pallet { // Reserve stake T::Currency::reserve(who, stake)?; - let current_block = frame_system::Pallet::::block_number(); + let anchor_block = Self::current_anchor_block(); let provider_info = ProviderInfo { multiaddr, @@ -95,7 +95,7 @@ impl Pallet { committed_bytes: 0, settings, stats: ProviderStats { - registered_at: current_block, + registered_at: anchor_block, ..Default::default() }, deregister_at: None, diff --git a/pallet/src/impls/queries.rs b/pallet/src/impls/queries.rs index 5ab6cf72..f75ad85c 100644 --- a/pallet/src/impls/queries.rs +++ b/pallet/src/impls/queries.rs @@ -62,6 +62,27 @@ fn agreement_to_response( } impl Pallet { + /// The anchor block: the clock every pallet duration (timeouts, expiries, + /// `valid_until`, nonce age) is measured against. Whether that is a relay + /// chain block, parachain block, or something else is a + /// [`Config::BlockNumberProvider`] detail — callers (including off-chain + /// consumers via the `current_anchor_block` runtime API) need not care. + /// The relay chain in production, `System` in tests. + /// + /// During `on_initialize` this returns the *previous* block's anchor value + /// (the validation-data inherent has not run yet); everywhere else it is + /// the current block's. + pub fn current_anchor_block() -> BlockNumberFor { + ::current_block_number() + } + + /// Milliseconds per anchor block; pairs with + /// [`Self::current_anchor_block`] so off-chain consumers can humanize + /// anchor-denominated durations. + pub fn anchor_block_time_millis() -> u64 { + T::AnchorBlockTimeMillis::get() + } + /// Query provider information. pub fn query_provider_info( provider: &T::AccountId, diff --git a/pallet/src/impls/signatures.rs b/pallet/src/impls/signatures.rs index f98e9cfd..9d4c7d86 100644 --- a/pallet/src/impls/signatures.rs +++ b/pallet/src/impls/signatures.rs @@ -14,16 +14,16 @@ impl Pallet { /// /// Returns Error::InvalidSignature if verification fails. /// Reject a `CommitmentPayload::nonce` that is too far behind (or ahead - /// of) the current block. This prevents an attacker who captures one + /// of) the anchor block. This prevents an attacker who captures one /// signed commitment from replaying it forever. pub(crate) fn ensure_recent_nonce(nonce: u64) -> DispatchResult { - let current: u64 = frame_system::Pallet::::block_number().saturated_into(); + let anchor_block: u64 = Self::current_anchor_block().saturated_into(); let max_age: u64 = T::MaxNonceAge::get().saturated_into(); // Future-dated nonces are nonsensical — the signer can only know - // the current block at sign-time. Allow exact equality. - ensure!(nonce <= current, Error::::CommitmentNonceTooOld); + // the anchor block at sign-time. Allow exact equality. + ensure!(nonce <= anchor_block, Error::::CommitmentNonceTooOld); ensure!( - current.saturating_sub(nonce) <= max_age, + anchor_block.saturating_sub(nonce) <= max_age, Error::::CommitmentNonceTooOld ); Ok(()) diff --git a/pallet/src/impls/try_state.rs b/pallet/src/impls/try_state.rs index d4ea7a1d..acd29c6a 100644 --- a/pallet/src/impls/try_state.rs +++ b/pallet/src/impls/try_state.rs @@ -20,6 +20,7 @@ impl Pallet { Self::check_timing_config()?; Self::check_committed_bytes()?; Self::check_buckets_and_membership()?; + Self::check_challenge_sweep_cursor()?; Ok(()) } @@ -37,6 +38,25 @@ impl Pallet { Ok(()) } + /// P1.5: no unresolved challenge sits at or below the swept cursor. The + /// `on_initialize` sweep drains every deadline key up to its cursor (parking + /// one below a key it only partly drained), and `create_challenge` always + /// sets `deadline = now + ChallengeTimeout`, above the cursor — so anything + /// at or below it must already have been drained. A violation means a + /// challenge was stranded unslashed (e.g. an upgrade that left + /// parachain-denominated keys below the anchor). + fn check_challenge_sweep_cursor() -> Result<(), TryRuntimeError> { + if let Some(cursor) = LastSweptChallengeBlock::::get() { + for (deadline, _index, _challenge) in Challenges::::iter() { + ensure!( + deadline > cursor, + "unresolved challenge sits at or below the swept cursor" + ); + } + } + Ok(()) + } + /// P1.1: each provider's `committed_bytes` equals the sum of `max_bytes` /// over all its storage agreements (the accounting that gates capacity and /// required stake). diff --git a/pallet/src/lib.rs b/pallet/src/lib.rs index 9359706f..a417807b 100644 --- a/pallet/src/lib.rs +++ b/pallet/src/lib.rs @@ -56,7 +56,7 @@ pub mod pallet { use frame_system::pallet_prelude::BlockNumberFor; use frame_system::pallet_prelude::*; use sp_core::H256; - use sp_runtime::traits::{Bounded, CheckedAdd, Saturating, Zero}; + use sp_runtime::traits::{Bounded, CheckedAdd, One, Saturating, Zero}; #[cfg(feature = "try-runtime")] use sp_runtime::TryRuntimeError; use storage_primitives::{ @@ -84,51 +84,125 @@ pub mod pallet { #[pallet::storage_version(STORAGE_VERSION)] pub struct Pallet(_); + /// Maximum deadline keys the slash sweep probes per block. Relay block + /// numbers can jump by more than one per parachain block, so the sweep + /// covers a range; this caps the probing and the remainder carries over via + /// [`LastSweptChallengeBlock`]. Slashing is bounded separately by + /// [`MAX_SWEEP_SLASH_BUDGET`]. + const MAX_SWEEP_SPAN: u32 = 32; + + /// Maximum challenges the slash sweep slashes per block, across all deadline + /// keys it touches. Decoupled from [`Config::MaxChallengesPerDeadline`] (up + /// to 1000) because slashing that many in one block would consume the whole + /// block's PoV (~5 KB each). A fully loaded deadline instead drains over + /// several blocks via the [`LastSweptChallengeBlock`] carry-over. The + /// effective budget is `min(MaxChallengesPerDeadline, MAX_SWEEP_SLASH_BUDGET)`, + /// so runtimes with a smaller per-deadline cap (e.g. tests) are unaffected. + pub(crate) const MAX_SWEEP_SLASH_BUDGET: u32 = 100; + #[pallet::hooks] impl Hooks> for Pallet { - /// Reserve weight for the bounded challenge sweep that `on_finalize(n)` - /// performs this block. + /// Slash providers whose challenges expired unanswered. /// - /// The sweep drains every challenge whose deadline is `n` and slashes - /// its provider. Every challenge targeting deadline `n` is created - /// strictly before block `n` (`deadline = created_at + ChallengeTimeout` - /// and `ChallengeTimeout > 0`), so `NextChallengeIndex(n)` — the count - /// of challenges ever allocated for that deadline — is already final - /// here and is capped at `MaxChallengesPerDeadline` by - /// `create_challenge`. That makes the sweep's work bounded and lets us - /// account for it up front instead of doing unbounded work in - /// `on_finalize` without a weight charge. - fn on_initialize(n: BlockNumberFor) -> Weight { - // `NextChallengeIndex(n)` is the final count of challenges expiring - // at `n` (every such challenge was created strictly before `n` and - // is capped at `MaxChallengesPerDeadline`). Charge the benchmarked - // cost of the `on_finalize` slash sweep up front, since - // `on_finalize` cannot return weight. - let count = NextChallengeIndex::::get(n); - T::WeightInfo::on_initialize_slash_challenges(count as u32) - } - - /// Process expired challenges at the end of each block. - fn on_finalize(n: BlockNumberFor) { - // Drain every challenge expiring at this block by its stable - // per-deadline index and slash the provider for failing to - // respond. `drain_prefix` removes the entries as it iterates. - for (index, challenge) in Challenges::::drain_prefix(n) { - let challenge_id = ChallengeId { deadline: n, index }; - // Timeout is a resolution: decrement the pending counters - // exactly once here, mirroring the increment in - // `create_challenge`. (The slash helper is shared with the - // invalid-response path, so it must NOT touch the counters.) - Self::decrement_pending(challenge.bucket_id, &challenge.provider); - Self::slash_provider_for_failed_challenge( - &challenge, - challenge_id, - SlashReason::Timeout, - ); + /// Deadlines are relay-chain blocks ([`Config::BlockNumberProvider`]), + /// which can jump by more than one per parachain block, so this drains a + /// *range* of deadline keys, tracking progress in + /// [`LastSweptChallengeBlock`] rather than probing the single key `n`. + /// + /// - **Which keys are final.** In `on_initialize` the validation-data + /// inherent has not run, so [`Pallet::current_anchor_block`] is the relay + /// parent `p` of the *previous* parachain block. A challenge with + /// deadline `d` stays respondable while some block has relay parent + /// `<= d`; every future block has relay parent `>= p`; so keys `< p` + /// are unrespondable and draining them cannot race a valid response. + /// Cost: a one-block lag — the slash lands the block after `p` passes + /// `d`. Escape hatches are unaffected; they gate on the + /// [`PendingChallenges`] counters, not on the sweep. + /// - **Budget.** [`MAX_SWEEP_SPAN`] caps keys probed per block; + /// [`MAX_SWEEP_SLASH_BUDGET`] caps slashes per block so one maturing + /// deadline cannot eat the block's PoV. On exhaustion the cursor parks + /// just below the partly drained key; the rest carries over. + /// - **Why `on_initialize`.** Work done is returned as weight instead of + /// pre-reserved, which `on_finalize` cannot do. + fn on_initialize(_n: BlockNumberFor) -> Weight { + // Provider read + cursor read/write. + let mut weight = T::DbWeight::get().reads_writes(2, 1); + + let now = Self::current_anchor_block(); + if now.is_zero() { + return weight; + } + let sweepable = now.saturating_sub(One::one()); + let last = match LastSweptChallengeBlock::::get() { + Some(last) => last, + None => { + // First run: anchor the cursor here instead of scanning + // up from zero (relay numbers start in the millions on + // live networks). On a fresh chain nothing can be pending + // below it; upgrading a live chain with in-flight + // parachain-denominated challenges instead needs a + // migration re-keying them above the anchor, or they are + // stranded below the cursor and never swept. + LastSweptChallengeBlock::::put(sweepable); + return weight; + } + }; + if sweepable <= last { + return weight; + } + let end = sweepable.min(last.saturating_add(MAX_SWEEP_SPAN.into())); + // Per-block slash budget, capped so one maturing deadline cannot eat + // the whole block's PoV. Lower bound of 1 so a (nonsensical) zero + // cap cannot park the cursor forever. + let mut budget = + u32::from(T::MaxChallengesPerDeadline::get()).clamp(1, MAX_SWEEP_SLASH_BUDGET); + let mut key = last.saturating_add(One::one()); + while key <= end { + let mut count: u32 = 0; + // Drain every challenge expiring at this key by its stable + // per-deadline index and slash the provider for failing to + // respond. `drain_prefix` removes the entries as it iterates. + for (index, challenge) in Challenges::::drain_prefix(key) { + let challenge_id = ChallengeId { + deadline: key, + index, + }; + // Timeout is a resolution: decrement the pending counters + // exactly once here, mirroring the increment in + // `create_challenge`. (The slash helper is shared with the + // invalid-response path, so it must NOT touch the counters.) + Self::decrement_pending(challenge.bucket_id, &challenge.provider); + Self::slash_provider_for_failed_challenge( + &challenge, + challenge_id, + SlashReason::Timeout, + ); + count = count.saturating_add(1); + if count >= budget { + break; + } + } + budget = budget.saturating_sub(count); + // Benchmarked for a single key's drain; applying it per key + // over-charges the fixed overhead. Conservative. + weight = + weight.saturating_add(T::WeightInfo::on_initialize_slash_challenges(count)); + if budget == 0 { + // Budget exhausted, possibly mid-key (`drain_prefix` + // removed the entries already visited). Keep the key's + // `NextChallengeIndex` allocator and park the cursor just + // below it; the remainder and the allocator cleanup drain + // on later blocks. + LastSweptChallengeBlock::::put(key.saturating_sub(One::one())); + return weight; + } + // Clear the per-deadline index allocator now the deadline has + // passed; no further challenges can target this key. + NextChallengeIndex::::remove(key); + key = key.saturating_add(One::one()); } - // Clear the per-deadline index allocator now the deadline has - // passed; no further challenges can target this block. - NextChallengeIndex::::remove(n); + LastSweptChallengeBlock::::put(end); + weight } fn integrity_test() { @@ -152,6 +226,11 @@ pub mod pallet { challenge created at the announcement block matures while the \ provider is still slashable" ); + assert!( + T::AnchorBlockTimeMillis::get() > 0, + "AnchorBlockTimeMillis must be non-zero; off-chain consumers \ + convert anchor-denominated durations to wall-clock time with it" + ); } #[cfg(feature = "try-runtime")] @@ -193,7 +272,8 @@ pub mod pallet { #[pallet::constant] type MaxChunkSize: Get; - /// Timeout for challenge response (e.g., ~48 hours in blocks). + /// Timeout for challenge response (e.g., ~48 hours in relay chain + /// blocks). #[pallet::constant] type ChallengeTimeout: Get>; @@ -207,26 +287,32 @@ pub mod pallet { #[pallet::constant] type ChallengeDeposit: Get>; - /// Maximum age of a `CommitmentPayload::nonce` (in blocks) the pallet - /// will accept on inbound signatures. The nonce is the block number - /// at which the signer signed; values older than this are rejected - /// to prevent indefinite signature replay. + /// Maximum age of a `CommitmentPayload::nonce` (in relay chain + /// blocks) the pallet will accept on inbound signatures. The nonce is + /// the relay chain block number (per + /// [`Config::BlockNumberProvider`]) at which the signer signed; + /// values older than this are rejected to prevent indefinite + /// signature replay. #[pallet::constant] type MaxNonceAge: Get>; - /// Settlement window after agreement expiry for owner to call end_agreement. + /// Settlement window (in relay chain blocks) after agreement expiry + /// for owner to call end_agreement. #[pallet::constant] type SettlementTimeout: Get>; - /// Maximum duration for agreement requests before expiry. + /// Maximum duration (in relay chain blocks) for agreement requests + /// before expiry. #[pallet::constant] type RequestTimeout: Get>; - /// Default interval between provider-initiated checkpoints (e.g., 100 blocks). + /// Default interval between provider-initiated checkpoints (e.g., 100 + /// relay chain blocks). #[pallet::constant] type DefaultCheckpointInterval: Get>; - /// Default grace period for checkpoint leader (e.g., 20 blocks). + /// Default grace period for checkpoint leader (e.g., 20 relay chain + /// blocks). #[pallet::constant] type DefaultCheckpointGrace: Get>; @@ -242,24 +328,45 @@ pub mod pallet { #[pallet::constant] type MaxBucketsPerMember: Get; - /// Minimum number of blocks between announcing a deregistration and - /// being allowed to complete it. Must be `> ChallengeTimeout` so any + /// Minimum number of relay chain blocks between announcing a + /// deregistration and being allowed to complete it. Must be + /// `> ChallengeTimeout` so any /// challenge against this provider that was created up to the /// announcement block matures while the provider is still slashable. #[pallet::constant] type DeregisterAnnouncementPeriod: Get>; - /// Maximum number of challenges that may share a single deadline block. + /// Maximum number of challenges that may share a single deadline + /// (relay chain block), and the per-block slash budget of the + /// `on_initialize` timeout sweep. /// - /// Bounds the per-deadline challenge count so the `on_finalize` slash - /// sweep — which drains and slashes every challenge expiring at a given - /// block — does a bounded amount of work whose weight `on_initialize` - /// can reserve up front. Only challenges created in the *same* block - /// share a deadline (`deadline = created_at + ChallengeTimeout`), so a - /// generous value still cannot be exceeded under honest load. + /// Bounds the per-deadline challenge count at creation, and the sweep + /// never slashes more than this many challenges per block regardless + /// of how many deadline keys a gap matured at once — so the worst + /// case per block equals one fully-loaded deadline. Note that + /// consecutive parachain blocks can share a relay parent, so + /// challenges created in different parachain blocks may share a + /// deadline; the bound is this explicit cap, not block co-location. #[pallet::constant] type MaxChallengesPerDeadline: Get; + /// Source of the block number every timeout, expiry and interval in + /// this pallet is measured against. Production runtimes supply the + /// relay chain block number + /// (`cumulus_pallet_parachain_system::RelaychainDataProvider`) so + /// durations stay independent of parachain block time; tests supply + /// `frame_system::Pallet`. + type BlockNumberProvider: sp_runtime::traits::BlockNumberProvider< + BlockNumber = BlockNumberFor, + >; + + /// Milliseconds per anchor block — the tick of + /// [`Config::BlockNumberProvider`]. Exposed via the + /// `anchor_block_time_millis` runtime API so off-chain consumers can + /// humanize anchor-denominated durations. + #[pallet::constant] + type AnchorBlockTimeMillis: Get; + /// Weight information for extrinsics in this pallet. type WeightInfo: WeightInfo; } @@ -328,10 +435,16 @@ pub mod pallet { pub type NextChallengeIndex = StorageMap<_, Blake2_128Concat, BlockNumberFor, u16, ValueQuery>; + /// Highest deadline key the `on_initialize` slash sweep has drained. Each + /// block it sweeps up to (but excluding) the previous block's relay parent. + /// `None` until the first block after genesis/upgrade anchors it. + #[pallet::storage] + pub type LastSweptChallengeBlock = StorageValue<_, BlockNumberFor, OptionQuery>; + /// Number of unresolved challenges currently outstanding against a /// provider, summed across every bucket. Incremented in `create_challenge` /// and decremented exactly once per resolution (defended/invalid-response - /// in `respond_to_challenge`, or timeout in `on_finalize`). Gates + /// in `respond_to_challenge`, or timeout in the `on_initialize` sweep). Gates /// `complete_deregister`: a provider cannot exit while still slashable for /// a pending challenge. #[pallet::storage] @@ -1007,8 +1120,8 @@ pub mod pallet { /// resolves (defended, slashed, or timed out). AgreementHasPendingChallenge, /// `MaxChallengesPerDeadline` challenges have already been allocated - /// for the deadline this challenge would land on. Bounds the - /// `on_finalize` slash sweep so it stays within its reserved weight. + /// for the deadline this challenge would land on. Caps the total the + /// `on_initialize` sweep must eventually drain for a single key. TooManyChallengesThisBlock, // Checkpoint errors @@ -1161,9 +1274,9 @@ pub mod pallet { #[pallet::weight(T::WeightInfo::deregister_provider())] pub fn deregister_provider(origin: OriginFor) -> DispatchResult { let who = ensure_signed(origin)?; - let current_block = frame_system::Pallet::::block_number(); + let anchor_block = Self::current_anchor_block(); let complete_after = - current_block.saturating_add(T::DeregisterAnnouncementPeriod::get()); + anchor_block.saturating_add(T::DeregisterAnnouncementPeriod::get()); Providers::::try_mutate(&who, |maybe_provider| -> DispatchResult { let provider = maybe_provider @@ -1214,9 +1327,9 @@ pub mod pallet { let deregister_at = provider .deregister_at .ok_or(Error::::DeregisterNotAnnounced)?; - let current_block = frame_system::Pallet::::block_number(); + let anchor_block = Self::current_anchor_block(); ensure!( - current_block >= deregister_at, + anchor_block >= deregister_at, Error::::DeregisterPeriodNotElapsed ); ensure!( @@ -1370,7 +1483,7 @@ pub mod pallet { Error::::ProviderNotFound ); - let current_block = frame_system::Pallet::::block_number(); + let anchor_block = Self::current_anchor_block(); StorageAgreements::::try_mutate( bucket_id, @@ -1380,7 +1493,7 @@ pub mod pallet { .as_mut() .ok_or(Error::::AgreementNotFound)?; ensure!( - current_block < agreement.expires_at, + anchor_block < agreement.expires_at, Error::::AgreementExpired ); agreement.extensions_blocked = blocked; @@ -1698,9 +1811,9 @@ pub mod pallet { Error::::AgreementHasPendingChallenge ); - let current_block = frame_system::Pallet::::block_number(); + let anchor_block = Self::current_anchor_block(); - let is_early_termination = current_block < agreement.expires_at; + let is_early_termination = anchor_block < agreement.expires_at; if is_early_termination { // Only admin can early-terminate, and only primaries @@ -1719,7 +1832,7 @@ pub mod pallet { .expires_at .saturating_add(T::SettlementTimeout::get()); ensure!( - current_block <= settlement_deadline, + anchor_block <= settlement_deadline, Error::::SettlementWindowPassed ); } @@ -1753,10 +1866,10 @@ pub mod pallet { Error::::AgreementHasPendingChallenge ); - let current_block = frame_system::Pallet::::block_number(); + let anchor_block = Self::current_anchor_block(); ensure!( - current_block > agreement.expires_at, + anchor_block > agreement.expires_at, Error::::AgreementNotExpired ); @@ -1764,7 +1877,7 @@ pub mod pallet { .expires_at .saturating_add(T::SettlementTimeout::get()); ensure!( - current_block > settlement_deadline, + anchor_block > settlement_deadline, Error::::AgreementNotExpired ); @@ -1801,9 +1914,9 @@ pub mod pallet { ensure!(agreement.owner == who, Error::::NotAgreementOwner); - let current_block = frame_system::Pallet::::block_number(); - let remaining_duration = if current_block < agreement.expires_at { - agreement.expires_at.saturating_sub(current_block) + let anchor_block = Self::current_anchor_block(); + let remaining_duration = if anchor_block < agreement.expires_at { + agreement.expires_at.saturating_sub(anchor_block) } else { return Err(Error::::AgreementExpired.into()); }; @@ -1905,7 +2018,7 @@ pub mod pallet { // Validate duration Self::validate_duration(&provider_info.settings, additional_duration)?; - let current_block = frame_system::Pallet::::block_number(); + let anchor_block = Self::current_anchor_block(); // Check if price increased let price_increased = @@ -1918,9 +2031,9 @@ pub mod pallet { // If price same or decreased, anyone can extend (permissionless persistence) // Settle current period - let elapsed = current_block.saturating_sub(agreement.started_at); - let _remaining = if current_block < agreement.expires_at { - agreement.expires_at.saturating_sub(current_block) + let elapsed = anchor_block.saturating_sub(agreement.started_at); + let _remaining = if anchor_block < agreement.expires_at { + agreement.expires_at.saturating_sub(anchor_block) } else { Zero::zero() }; @@ -1963,8 +2076,8 @@ pub mod pallet { T::Currency::reserve(&who, extension_payment)?; // Update agreement - agreement.expires_at = current_block.saturating_add(additional_duration); - agreement.started_at = current_block; + agreement.expires_at = anchor_block.saturating_add(additional_duration); + agreement.started_at = anchor_block; agreement.price_per_byte = provider_info.settings.price_per_byte; // For replicas, also update sync_price and handle sync_balance @@ -2079,14 +2192,14 @@ pub mod pallet { Error::::InsufficientSignatures ); - let current_block = frame_system::Pallet::::block_number(); + let anchor_block = Self::current_anchor_block(); // Update historical roots - Self::update_historical_roots(bucket, current_block, commitment.mmr_root); + Self::update_historical_roots(bucket, anchor_block, commitment.mmr_root); bucket.snapshot = Some(BucketSnapshot { commitment, - checkpoint_block: current_block, + checkpoint_block: anchor_block, primary_signers, commitment_nonce: nonce, }); @@ -2223,8 +2336,8 @@ pub mod pallet { ensure!(config.enabled, Error::::ProviderCheckpointsDisabled); // Get current block and calculate current window - let current_block = frame_system::Pallet::::block_number(); - let current_window = Self::calculate_window(current_block, config.interval); + let anchor_block = Self::current_anchor_block(); + let current_window = Self::calculate_window(anchor_block, config.interval); // Validate window ensure!( @@ -2251,7 +2364,7 @@ pub mod pallet { .ok_or(Error::::ProviderNotInSnapshot)?; // Check caller authorization - let within_grace = Self::is_within_grace_period(current_block, window, &config); + let within_grace = Self::is_within_grace_period(anchor_block, window, &config); if within_grace { // Only leader can submit during grace period ensure!(&who == expected_leader, Error::::NotCheckpointLeader); @@ -2309,7 +2422,7 @@ pub mod pallet { ); // Update historical roots - Self::update_historical_roots(bucket, current_block, mmr_root); + Self::update_historical_roots(bucket, anchor_block, mmr_root); // Update bucket snapshot. // @@ -2323,7 +2436,7 @@ pub mod pallet { // two schemes. bucket.snapshot = Some(BucketSnapshot { commitment, - checkpoint_block: current_block, + checkpoint_block: anchor_block, primary_signers, commitment_nonce: 0, }); @@ -2420,8 +2533,8 @@ pub mod pallet { ensure!(config.enabled, Error::::ProviderCheckpointsDisabled); // Get current window - let current_block = frame_system::Pallet::::block_number(); - let current_window = Self::calculate_window(current_block, config.interval); + let anchor_block = Self::current_anchor_block(); + let current_window = Self::calculate_window(anchor_block, config.interval); // Can only report past windows ensure!(window < current_window, Error::::InvalidCheckpointWindow); @@ -2433,7 +2546,7 @@ pub mod pallet { // Ensure we're past the grace period of the reported window let window_end = Self::window_start_block(window.saturating_add(1), config.interval); - ensure!(current_block > window_end, Error::::WithinGracePeriod); + ensure!(anchor_block > window_end, Error::::WithinGracePeriod); // Calculate leader for the missed window let num_providers = bucket.primary_providers.len() as u32; @@ -2587,7 +2700,7 @@ pub mod pallet { let agreement = StorageAgreements::::get(bucket_id, &provider) .ok_or(Error::::AgreementNotFound)?; ensure!( - frame_system::Pallet::::block_number() < agreement.expires_at, + Self::current_anchor_block() < agreement.expires_at, Error::::AgreementExpired ); @@ -2648,7 +2761,7 @@ pub mod pallet { let agreement = StorageAgreements::::get(bucket_id, &provider) .ok_or(Error::::AgreementNotFound)?; ensure!( - frame_system::Pallet::::block_number() < agreement.expires_at, + Self::current_anchor_block() < agreement.expires_at, Error::::AgreementExpired ); @@ -2697,7 +2810,7 @@ pub mod pallet { // Challengeability tracks genuine obligation: only while the // agreement is live (not into the settlement window). ensure!( - frame_system::Pallet::::block_number() < agreement.expires_at, + Self::current_anchor_block() < agreement.expires_at, Error::::AgreementExpired ); @@ -2753,9 +2866,9 @@ pub mod pallet { ensure!(challenge.provider == who, Error::::NotChallengeProvider); - let current_block = frame_system::Pallet::::block_number(); + let anchor_block = Self::current_anchor_block(); ensure!( - current_block <= challenge_id.deadline, + anchor_block <= challenge_id.deadline, Error::::ChallengeExpired ); @@ -2876,7 +2989,7 @@ pub mod pallet { let challenge_created_at = challenge_id .deadline .saturating_sub(T::ChallengeTimeout::get()); - let response_time = current_block.saturating_sub(challenge_created_at); + let response_time = anchor_block.saturating_sub(challenge_created_at); // Calculate cost split based on response time // Per design: @@ -2992,12 +3105,12 @@ pub mod pallet { ProviderRole::Primary => return Err(Error::::NotReplica.into()), }; - let current_block = frame_system::Pallet::::block_number(); + let anchor_block = Self::current_anchor_block(); // Check sync interval if let Some(record) = last_sync { let min_next_block = record.block.saturating_add(*min_sync_interval); - ensure!(current_block >= min_next_block, Error::::SyncTooFrequent); + ensure!(anchor_block >= min_next_block, Error::::SyncTooFrequent); } // Find matching root position @@ -3045,7 +3158,7 @@ pub mod pallet { start_seq, leaf_count, }, - block: current_block, + block: anchor_block, }); // Transfer sync payment to provider diff --git a/pallet/src/mock.rs b/pallet/src/mock.rs index 5362865b..a777c6c1 100644 --- a/pallet/src/mock.rs +++ b/pallet/src/mock.rs @@ -103,6 +103,8 @@ impl pallet_storage_provider::Config for Test { // Small cap so the cap-enforcement test can hit it without creating // thousands of challenges. type MaxChallengesPerDeadline = ConstU16<5>; + type BlockNumberProvider = System; + type AnchorBlockTimeMillis = ConstU64<6000>; type WeightInfo = (); } @@ -184,12 +186,10 @@ pub fn new_test_ext_with_genesis_providers( pub fn run_to_block(n: u64) { while System::block_number() < n { let current = System::block_number(); - if current > 1 { - >::on_finalize(current); - } >::on_finalize(current); System::set_block_number(current + 1); >::on_initialize(current + 1); + >::on_initialize(current + 1); } } @@ -388,7 +388,7 @@ pub fn setup_replica_agreement( /// a fresh single-primary bucket, so multi-primary shapes are synthesized. #[allow(dead_code)] pub fn add_primary_to_bucket(provider: u64, owner: u64, bucket_id: u64, max_bytes: u64) { - let current_block = System::block_number(); + let anchor_block = System::block_number(); crate::Buckets::::mutate(bucket_id, |maybe_bucket| { if let Some(bucket) = maybe_bucket { let _ = bucket.primary_providers.try_push(provider); @@ -402,10 +402,10 @@ pub fn add_primary_to_bucket(provider: u64, owner: u64, bucket_id: u64, max_byte max_bytes, payment_locked: 0, price_per_byte: 0, - expires_at: current_block + 200, + expires_at: anchor_block + 200, extensions_blocked: false, role: storage_primitives::ProviderRole::Primary, - started_at: current_block, + started_at: anchor_block, }, ); crate::Providers::::mutate(provider, |maybe_p| { diff --git a/pallet/src/runtime_api.rs b/pallet/src/runtime_api.rs index 459babab..58d7a7df 100644 --- a/pallet/src/runtime_api.rs +++ b/pallet/src/runtime_api.rs @@ -191,5 +191,16 @@ sp_api::decl_runtime_apis! { /// Get providers with sufficient capacity for the given bytes (paginated). fn providers_with_capacity(bytes_needed: u64, offset: u32, limit: u32) -> Vec<(AccountId, ProviderInfoResponse)>; + + /// The anchor block every on-chain duration (timeouts, expiries, + /// `valid_until`, nonce age) is measured against. Off-chain actors read + /// this instead of a specific storage item so they need not know whether + /// the anchor is a relay, parachain, or other block number. + fn current_anchor_block() -> BlockNumber; + + /// Milliseconds per anchor block. Pairs with `current_anchor_block` so + /// off-chain consumers can humanize anchor-denominated durations + /// without knowing which clock the pallet measures them on. + fn anchor_block_time_millis() -> u64; } } diff --git a/pallet/src/tests/challenge.rs b/pallet/src/tests/challenge.rs index 09f582bb..42f4df36 100644 --- a/pallet/src/tests/challenge.rs +++ b/pallet/src/tests/challenge.rs @@ -1031,10 +1031,10 @@ fn remove_slashed_reindexes_snapshot_bitfield() { } /// The per-deadline challenge count is capped by `MaxChallengesPerDeadline` -/// (5 in this mock) so the `on_finalize` slash sweep stays bounded. All -/// challenges created in the same block share a deadline, so once the cap is -/// reached in block 1 the next `challenge_checkpoint` for that deadline must be -/// rejected with `TooManyChallengesThisBlock`. +/// (5 in this mock) so the `on_initialize` slash sweep stays bounded. All +/// challenges created at the same clock reading share a deadline, so once the +/// cap is reached in block 1 the next `challenge_checkpoint` for that deadline +/// must be rejected with `TooManyChallengesThisBlock`. #[test] fn challenge_count_per_deadline_is_capped() { new_test_ext().execute_with(|| { @@ -1721,6 +1721,216 @@ mod challenge_tests { }); } + /// The sweep drains a range of deadline keys, so a deadline skipped over + /// by a block-number jump (relay numbers can advance by more than one + /// between parachain blocks) is still slashed. + #[test] + fn challenge_timeout_sweep_catches_skipped_deadlines() { + new_test_ext().execute_with(|| { + System::set_block_number(1); + let (mmr_root, _, _) = single_chunk_proof(b"chunk-0"); + setup_primary_with_snapshot(mmr_root, 0, 1); + assert_ok!(StorageProvider::challenge_checkpoint( + RuntimeOrigin::signed(3), + 0, + 2, + ChunkLocation { + leaf_index: 0, + chunk_index: 0, + }, + )); + + // Walk close to the deadline (101), then jump well past it in a + // single step and run one on_initialize. + advance_to(100); + assert_eq!( + Providers::::get(2).unwrap().stats.challenges_failed, + 0 + ); + System::set_block_number(110); + >::on_initialize(110); + + // Deadline 101 was never the "current" block, yet the range sweep + // (cursor 99 → 109) drained and slashed it. + let provider = Providers::::get(2).unwrap(); + assert_eq!(provider.stats.challenges_failed, 1); + assert!(Challenges::::get(101, 0).is_none()); + assert_eq!(LastSweptChallengeBlock::::get(), Some(109)); + }); + } + + /// A single sweep drains at most `MAX_SWEEP_SPAN` (32) keys; the + /// remainder carries over via the cursor and drains on later blocks, so + /// a huge gap cannot blow one block but slashes still land. + #[test] + fn challenge_timeout_sweep_is_capped_and_carries_over() { + new_test_ext().execute_with(|| { + System::set_block_number(1); + let (mmr_root, _, _) = single_chunk_proof(b"chunk-0"); + setup_primary_with_snapshot(mmr_root, 0, 1); + assert_ok!(StorageProvider::challenge_checkpoint( + RuntimeOrigin::signed(3), + 0, + 2, + ChunkLocation { + leaf_index: 0, + chunk_index: 0, + }, + )); + + // Anchor the cursor at 1, then jump far past the deadline (101). + advance_to(2); + assert_eq!(LastSweptChallengeBlock::::get(), Some(1)); + System::set_block_number(500); + + // Each sweep advances the cursor by at most 32 keys: 33, 65, 97 — + // deadline 101 still pending after three sweeps. + for expected_cursor in [33, 65, 97] { + >::on_initialize(500); + assert_eq!( + LastSweptChallengeBlock::::get(), + Some(expected_cursor) + ); + } + assert!(Challenges::::get(101, 0).is_some()); + assert_eq!( + Providers::::get(2).unwrap().stats.challenges_failed, + 0 + ); + + // Fourth sweep covers 98..=129 and slashes. + >::on_initialize(500); + assert_eq!(LastSweptChallengeBlock::::get(), Some(129)); + assert!(Challenges::::get(101, 0).is_none()); + assert_eq!( + Providers::::get(2).unwrap().stats.challenges_failed, + 1 + ); + }); + } + + /// The sweep never slashes more than `MaxChallengesPerDeadline` (5 in + /// this mock) challenges per block even when a gap matured several + /// populated deadlines at once; on exhaustion it parks the cursor below + /// the partially drained key and finishes on the next block. + #[test] + fn challenge_timeout_sweep_budget_carries_over_mid_key() { + new_test_ext().execute_with(|| { + System::set_block_number(1); + let (mmr_root, _, _) = single_chunk_proof(b"chunk-0"); + setup_primary_with_snapshot(mmr_root, 0, 1); + + let challenge = || { + assert_ok!(StorageProvider::challenge_checkpoint( + RuntimeOrigin::signed(3), + 0, + 2, + ChunkLocation { + leaf_index: 0, + chunk_index: 0, + }, + )); + }; + + // Load two consecutive deadlines with 3 challenges each: 199 + // (created at block 99) and 200 (created at block 100). + run_to_block(99); + for _ in 0..3 { + challenge(); + } + run_to_block(100); + for _ in 0..3 { + challenge(); + } + assert_eq!(PendingChallenges::::get(2), 6); + + // Jump far past both deadlines. The span cap (32 keys/sweep) + // takes three sweeps to walk the empty range up to key 195. + System::set_block_number(300); + for _ in 0..3 { + >::on_initialize(300); + } + assert_eq!(LastSweptChallengeBlock::::get(), Some(195)); + assert_eq!( + Providers::::get(2).unwrap().stats.challenges_failed, + 0 + ); + + // Fourth sweep reaches the loaded keys: drains all 3 at 199, + // then only 2 of 3 at 200 before the budget (5) is exhausted — + // cursor parks below the partially drained key, whose index + // allocator survives for the carry-over. + >::on_initialize(300); + assert_eq!( + Providers::::get(2).unwrap().stats.challenges_failed, + 5 + ); + assert_eq!(LastSweptChallengeBlock::::get(), Some(199)); + assert_eq!(Challenges::::iter_prefix(200).count(), 1); + assert_eq!(NextChallengeIndex::::get(200), 3); + assert_eq!(PendingChallenges::::get(2), 1); + + // Fifth sweep finishes the key and cleans up its allocator. + >::on_initialize(300); + assert_eq!( + Providers::::get(2).unwrap().stats.challenges_failed, + 6 + ); + assert_eq!(Challenges::::iter_prefix(200).count(), 0); + assert_eq!(NextChallengeIndex::::get(200), 0); + assert_eq!(PendingChallenges::::get(2), 0); + assert_eq!(LastSweptChallengeBlock::::get(), Some(231)); + }); + } + + /// Re-running the sweep at an unchanged block number (several parachain + /// blocks can share one relay parent) is a no-op: no double slash, no + /// counter underflow. + #[test] + fn challenge_timeout_sweep_idempotent_at_same_block() { + new_test_ext().execute_with(|| { + System::set_block_number(1); + let (mmr_root, _, _) = single_chunk_proof(b"chunk-0"); + setup_primary_with_snapshot(mmr_root, 0, 1); + assert_ok!(StorageProvider::challenge_checkpoint( + RuntimeOrigin::signed(3), + 0, + 2, + ChunkLocation { + leaf_index: 0, + chunk_index: 0, + }, + )); + + advance_to(102); + let provider = Providers::::get(2).unwrap(); + assert_eq!(provider.stats.challenges_failed, 1); + assert_eq!(PendingChallenges::::get(2), 0); + let cursor = LastSweptChallengeBlock::::get(); + + >::on_initialize(102); + + let provider = Providers::::get(2).unwrap(); + assert_eq!(provider.stats.challenges_failed, 1); + assert_eq!(PendingChallenges::::get(2), 0); + assert_eq!(LastSweptChallengeBlock::::get(), cursor); + }); + } + + /// The very first sweep anchors the cursor at the current clock instead + /// of scanning up from zero (live relay numbers start in the millions). + #[test] + fn challenge_timeout_sweep_anchors_cursor_on_first_run() { + new_test_ext().execute_with(|| { + System::set_block_number(1_000_000); + assert_eq!(LastSweptChallengeBlock::::get(), None); + + >::on_initialize(1_000_000); + + assert_eq!(LastSweptChallengeBlock::::get(), Some(999_999)); + }); + } + // ───────────────────────────────────────────────────────────────────────── // challenge_replica // ───────────────────────────────────────────────────────────────────────── diff --git a/pallet/src/tests/try_state.rs b/pallet/src/tests/try_state.rs index 705b2125..6694b6d5 100644 --- a/pallet/src/tests/try_state.rs +++ b/pallet/src/tests/try_state.rs @@ -67,3 +67,34 @@ fn try_state_detects_member_index_gap() { assert!(StorageProvider::do_try_state().is_err()); }); } + +/// P1.5: a challenge still pending at a deadline the sweep cursor claims to have +/// passed is caught — it means the challenge was stranded unslashed. +#[test] +fn try_state_detects_challenge_below_sweep_cursor() { + new_test_ext().execute_with(|| { + assert_ok!(StorageProvider::do_try_state()); + + let deadline = 100u64; + Challenges::::insert( + deadline, + 0u16, + Challenge:: { + bucket_id: 0, + provider: 2, + challenger: 1, + mmr_root: sp_core::H256::zero(), + start_seq: 0, + target: storage_primitives::ChunkLocation { + leaf_index: 0, + chunk_index: 0, + }, + deposit: 0, + }, + ); + // Cursor claims everything up to `deadline` is swept, yet a challenge is + // still pending there. + LastSweptChallengeBlock::::put(deadline); + assert!(StorageProvider::do_try_state().is_err()); + }); +} diff --git a/provider-node/src/api.rs b/provider-node/src/api.rs index caab577d..adef9bf4 100644 --- a/provider-node/src/api.rs +++ b/provider-node/src/api.rs @@ -897,8 +897,8 @@ async fn get_historical_roots( /// /// Returns one of several `503`s when a prerequisite is missing: /// - `signing_unavailable` — no `--keyfile`. -/// - `chain_state_not_ready` — `current_block` and `request_timeout` are not both -/// known from the chain yet. +/// - `chain_state_not_ready` — `current_anchor_block` and `request_timeout` are +/// not both known from the chain yet. /// - `provider_info_unavailable` — provider not registered on chain yet; the /// chain-state coordinator clears this automatically once registration lands, no /// restart needed. @@ -912,11 +912,11 @@ async fn negotiate_terms( ) -> Result, Error> { let keypair = state.keypair.as_ref().ok_or(Error::SigningUnavailable)?; - // Both current_block and RequestTimeout must be known before we can sign — - // otherwise we'd emit unbounded or already-expired terms. - let current_block = state + // Both the anchor block and RequestTimeout must be known before we can sign + // — otherwise we'd emit unbounded or already-expired terms. + let anchor_block = state .chain_state - .current_block + .current_anchor_block .load(std::sync::atomic::Ordering::Relaxed); let request_timeout = state .chain_state @@ -925,7 +925,7 @@ async fn negotiate_terms( .as_ref() .map(|c| c.request_timeout) .unwrap_or(0); - if current_block == 0 || request_timeout == 0 { + if anchor_block == 0 || request_timeout == 0 { return Err(Error::ChainStateNotReady); } @@ -966,7 +966,7 @@ async fn negotiate_terms( max_bytes: req.max_bytes, duration: req.duration, price_per_byte: info.price_per_byte, - valid_until: current_block.saturating_add(request_timeout), + valid_until: anchor_block.saturating_add(request_timeout), nonce: nonce_counter.next(), bucket_id: req.bucket_id, replica_params: req.replica_params, diff --git a/provider-node/src/chain_state_coordinator.rs b/provider-node/src/chain_state_coordinator.rs index 1622783e..80127ceb 100644 --- a/provider-node/src/chain_state_coordinator.rs +++ b/provider-node/src/chain_state_coordinator.rs @@ -5,7 +5,8 @@ //! //! [`ChainState`] is the single source of truth for all on-chain state the //! provider node needs at runtime: -//! - [`ChainState::current_block`] — latest finalized block height. +//! - [`ChainState::current_anchor_block`] — the pallet's anchor block (the +//! clock all on-chain durations use), read via its runtime API. //! - [`ChainState::constants`] — pallet constants fetched once on connect. //! - [`ChainState::provider_info`] — full provider registration info. //! - [`ChainState::nonce_counter`] — nonce counter bootstrapped from the @@ -41,8 +42,13 @@ const PALLET_NAME: &str = "StorageProvider"; /// Held behind `Arc` inside [`crate::ProviderState`] so the coordinator can hold /// its own handle without a back-reference to the whole node state. pub struct ChainState { - /// Latest finalized block height. `0` means not yet known. - pub current_block: AtomicU32, + /// The pallet's anchor block — the clock all on-chain durations (timeouts, + /// `valid_until`, nonce age) are measured against — read via the + /// `StorageProviderApi::current_anchor_block` runtime API at the latest + /// finalized block. Whether that anchor is a relay, parachain, or other + /// block number is the pallet's concern, not the provider's. `0` means not + /// yet known. + pub current_anchor_block: AtomicU32, /// Pallet constants fetched once per connection. `None` until the first /// successful fetch; `/negotiate` returns 503 until this is `Some`. pub constants: RwLock>, @@ -64,7 +70,7 @@ pub struct ChainState { impl Default for ChainState { fn default() -> Self { Self { - current_block: AtomicU32::new(0), + current_anchor_block: AtomicU32::new(0), constants: RwLock::new(None), provider_info: RwLock::new(None), nonce_counter: RwLock::new(None), @@ -318,22 +324,35 @@ impl ChainStateCoordinator { let block_number = block.number() as u32; tracing::debug!("Finalized block: {}", block_number); - self.chain_state - .current_block - .store(block_number, std::sync::atomic::Ordering::Relaxed); - - let events = async { - let at = block - .at() - .await - .map_err(|e| Error::Internal(format!("Failed to get block: {e}")))?; - at.events() - .fetch() - .await - .map_err(|e| Error::Internal(format!("Failed to fetch events: {e}"))) + + // One block-scoped handle drives both reads below. + let at = match block.at().await { + Ok(at) => at, + Err(e) => { + tracing::warn!( + "chain-state coordinator: failed to get block handle for {block_number}: {e}" + ); + continue; + } + }; + + // Track the pallet's anchor block (the clock all on-chain durations + // are measured against) at this finalized block, via its runtime + // API — so the provider never needs to know which block notion the + // pallet uses. + match crate::subxt_client::fetch_current_anchor_block(&at).await { + Ok(anchor_block) => { + self.chain_state + .current_anchor_block + .store(anchor_block, std::sync::atomic::Ordering::Relaxed); + } + Err(e) => tracing::warn!( + "chain-state coordinator: failed to fetch anchor block for block \ + {block_number}: {e}; keeping previous value" + ), } - .await; - let parsed = match events { + + let parsed = match at.events().fetch().await { Ok(events) => parse_provider_lifecycle_events(&events), Err(e) => { tracing::warn!( @@ -705,17 +724,17 @@ mod tests { #[test] fn chain_state_defaults_to_unknown() { let cs = ChainState::default(); - assert_eq!(cs.current_block.load(Ordering::Relaxed), 0); + assert_eq!(cs.current_anchor_block.load(Ordering::Relaxed), 0); assert!(cs.constants.read().is_none()); assert!(cs.provider_info.read().is_none()); assert!(cs.nonce_counter.read().is_none()); } #[test] - fn chain_state_current_block_round_trips() { + fn chain_state_current_anchor_block_round_trips() { let cs = ChainState::default(); - cs.current_block.store(42, Ordering::Relaxed); - assert_eq!(cs.current_block.load(Ordering::Relaxed), 42); + cs.current_anchor_block.store(42, Ordering::Relaxed); + assert_eq!(cs.current_anchor_block.load(Ordering::Relaxed), 42); } #[test] @@ -1058,6 +1077,9 @@ mod tests { "Metadata_metadata_versions" => vec![u32::from(METADATA[4])].encode(), "Metadata_metadata_at_version" => Some(METADATA.to_vec()).encode(), "Metadata_metadata" => METADATA.to_vec().encode(), + // Anchor block the coordinator reads per finalized block; + // matches the mocked header number below. + "StorageProviderApi_current_anchor_block" => 42u32.encode(), // sp_version::RuntimeVersion, field by field. "Core_version" => ( "test".to_string(), // spec_name @@ -1234,7 +1256,7 @@ mod tests { .await .expect("follow runs to stream end"); - assert_eq!(chain_state.current_block.load(Ordering::Relaxed), 42); + assert_eq!(chain_state.current_anchor_block.load(Ordering::Relaxed), 42); let info = chain_state.provider_info.read(); let info = info.as_ref().expect("provider info synced from chain"); assert_eq!(info.stake, 1_000); diff --git a/provider-node/src/checkpoint_coordinator.rs b/provider-node/src/checkpoint_coordinator.rs index 15965927..45406f21 100644 --- a/provider-node/src/checkpoint_coordinator.rs +++ b/provider-node/src/checkpoint_coordinator.rs @@ -355,7 +355,7 @@ impl CheckpointCoordinator { return Ok(None); } - let current_block = self.chain_client.get_current_block().await?; + let anchor_block = self.chain_client.get_current_block().await?; let (interval, grace_period) = self .chain_client @@ -364,7 +364,7 @@ impl CheckpointCoordinator { .unwrap_or((100u32, 20u32)); let window = if interval > 0 { - current_block / interval as u64 + anchor_block / interval as u64 } else { 0 }; @@ -372,7 +372,7 @@ impl CheckpointCoordinator { tracing::info!( "Checkpoint duty: bucket={} block={} interval={} window={} mmr_root=0x{} leaves={}", bucket_id, - current_block, + anchor_block, interval, window, hex::encode(&bucket.mmr_root.as_bytes()[..4]), diff --git a/provider-node/src/command.rs b/provider-node/src/command.rs index b06ed715..9bb1afc5 100644 --- a/provider-node/src/command.rs +++ b/provider-node/src/command.rs @@ -166,13 +166,14 @@ fn configure_state(state: ProviderState, cli: &Cli) -> ProviderState { state } -/// Start the chain-state coordinator, which keeps `chain_state.current_block` -/// and `chain_state.provider_info` in sync with the chain. +/// Start the chain-state coordinator, which keeps +/// `chain_state.current_anchor_block` and `chain_state.provider_info` in sync +/// with the chain. /// /// Returns `None` only when the provider id isn't a valid account. The /// coordinator itself never fails to start: it connects in the background and -/// retries with a backoff if the chain is unreachable, so `current_block` is -/// populated as soon as the chain comes up. +/// retries with a backoff if the chain is unreachable, so `current_anchor_block` +/// is populated as soon as the chain comes up. fn start_chain_state_coordinator( cli: &Cli, state: Arc, diff --git a/provider-node/src/error.rs b/provider-node/src/error.rs index a3295993..b9277768 100644 --- a/provider-node/src/error.rs +++ b/provider-node/src/error.rs @@ -103,7 +103,9 @@ pub enum Error { #[error("Provider is deregistering; not accepting new agreements")] ProviderDeregistering, - #[error("Chain state not ready: current_block and request_timeout must both be non-zero")] + #[error( + "Chain state not ready: current_anchor_block and request_timeout must both be non-zero" + )] ChainStateNotReady, #[error("Storage agreement requested 0 byte")] @@ -338,7 +340,7 @@ impl IntoResponse for Error { ErrorResponse { error: "chain_state_not_ready".to_string(), details: Some(serde_json::json!({ - "message": "current_block or request_timeout is 0; \ + "message": "current_anchor_block or request_timeout is 0; \ the node has not yet synced with the chain" })), }, diff --git a/provider-node/src/lib.rs b/provider-node/src/lib.rs index 3aef0ab2..2c65bac3 100644 --- a/provider-node/src/lib.rs +++ b/provider-node/src/lib.rs @@ -92,7 +92,7 @@ pub struct ProviderState { /// permissive policy; `Some(list)` restricts to exactly those origins. pub cors_allowed_origins: Option>, /// Live chain state kept in sync by the chain-state coordinator — the single - /// writer for `current_block`, `constants`, `provider_info`, and + /// writer for `current_anchor_block`, `constants`, `provider_info`, and /// `nonce_counter`. `/negotiate` gates on all four before signing. pub chain_state: Arc, } @@ -308,7 +308,13 @@ mod tests { fn provider_state_chain_defaults_on_new() { use std::sync::atomic::Ordering; let state = ProviderState::with_provider_id(test_storage(), "test-provider".to_string()); - assert_eq!(state.chain_state.current_block.load(Ordering::Relaxed), 0); + assert_eq!( + state + .chain_state + .current_anchor_block + .load(Ordering::Relaxed), + 0 + ); assert!(state.chain_state.provider_info.read().is_none()); } } diff --git a/provider-node/src/negotiate.rs b/provider-node/src/negotiate.rs index 88da8973..0569c2bf 100644 --- a/provider-node/src/negotiate.rs +++ b/provider-node/src/negotiate.rs @@ -13,7 +13,7 @@ //! out-of-range reuse). //! 2. Builds [`AgreementTerms`] from the request, the provider's current //! `price_per_byte` setting (read from chain), and -//! `valid_until = current_block + valid_until_offset`. +//! `valid_until = current_anchor_block + valid_until_offset`. //! 3. Signs `blake2_256(TERM_CONTEXT | SCALE(terms))` with the provider's //! existing sr25519 checkpoint key (the same one used to sign //! commitments). The context is `primary-term-v1:` or diff --git a/provider-node/src/replica_sync_coordinator.rs b/provider-node/src/replica_sync_coordinator.rs index fe4be14a..a609ffa8 100644 --- a/provider-node/src/replica_sync_coordinator.rs +++ b/provider-node/src/replica_sync_coordinator.rs @@ -430,7 +430,7 @@ impl ReplicaSyncCoordinator { pub async fn get_active_replica_duties(&self) -> Result, Error> { let mut duties = Vec::new(); - let current_block = self.chain_client.get_current_block().await?; + let anchor_block = self.chain_client.get_current_block().await?; let local_buckets: Vec = self .state @@ -459,7 +459,7 @@ impl ReplicaSyncCoordinator { } if let Some((_, last_block)) = agreement.last_sync { - let elapsed = current_block.saturating_sub(last_block); + let elapsed = anchor_block.saturating_sub(last_block); if elapsed < agreement.min_sync_interval { tracing::debug!( "Bucket {} sync interval not elapsed: {} < {}", diff --git a/provider-node/src/subxt_client.rs b/provider-node/src/subxt_client.rs index 53e9caf8..08d6fd11 100644 --- a/provider-node/src/subxt_client.rs +++ b/provider-node/src/subxt_client.rs @@ -24,6 +24,37 @@ use storage_primitives::BucketId; use subxt::dynamic::Value; use subxt::ext::scale_value::value; +/// Query the pallet's `StorageProviderApi::current_anchor_block` runtime API — +/// the block every on-chain duration (timeouts, expiries, `valid_until`, nonce +/// age) is measured against. Reading it through the runtime API keeps the +/// provider agnostic to whether the anchor is a relay, parachain, or other +/// block number: the pallet decides via its `BlockNumberProvider`, and the +/// provider no longer reaches into a specific storage item. +/// +/// Kept here (rather than in `storage-client`) so the provider node stays +/// dependency-light (see #275). +pub(crate) async fn fetch_current_anchor_block( + at: &subxt::client::ClientAtBlock, +) -> Result +where + C: subxt::client::OnlineClientAtBlockT, +{ + use codec::Decode; + // Invoke by the runtime API's `state_call` name and decode the raw SCALE + // response directly as the block number. Decoding by hand (rather than + // through the dynamic value path) avoids depending on this API being + // present in the node's metadata snapshot. + let bytes = at + .runtime_apis() + .call_raw("StorageProviderApi_current_anchor_block", None) + .await + .map_err(|e| { + Error::Internal(format!("current_anchor_block runtime API call failed: {e}")) + })?; + u32::decode(&mut &bytes[..]) + .map_err(|e| Error::Internal(format!("Failed to decode anchor block: {e}"))) +} + /// Production implementation that talks to the chain via subxt. /// /// Cloning is cheap: `OnlineClient` shares one connection behind an `Arc`, and @@ -61,16 +92,18 @@ impl SubxtChainClient { Ok(Self { api, signer }) } - /// Get the current (latest) block number. + /// Get the current anchor block (the clock all on-chain durations, in + /// particular checkpoint windows, are measured against), read at the latest + /// finalized state via the pallet's runtime API. /// /// Backs `get_current_block` on both the checkpoint and replica-sync traits. - async fn current_block(&self) -> Result { + async fn current_anchor_block(&self) -> Result { let at = self .api .at_current_block() .await - .map_err(|e| Error::Internal(format!("Failed to get latest block: {e}")))?; - Ok(at.block_number()) + .map_err(|e| Error::Internal(format!("Failed to get current block: {e}")))?; + fetch_current_anchor_block(&at).await.map(u64::from) } /// Convert a multiaddr string to an HTTP endpoint. @@ -428,7 +461,7 @@ impl SubxtChainClient { #[async_trait::async_trait] impl CheckpointChainClient for SubxtChainClient { async fn get_current_block(&self) -> Result { - self.current_block().await + self.current_anchor_block().await } async fn fetch_checkpoint_config( @@ -535,7 +568,7 @@ impl CheckpointChainClient for SubxtChainClient { #[async_trait::async_trait] impl ReplicaSyncChainClient for SubxtChainClient { async fn get_current_block(&self) -> Result { - self.current_block().await + self.current_anchor_block().await } async fn fetch_replica_agreements( diff --git a/provider-node/src/types.rs b/provider-node/src/types.rs index fbed0388..329d612c 100644 --- a/provider-node/src/types.rs +++ b/provider-node/src/types.rs @@ -102,7 +102,7 @@ pub struct CommitRequest { pub bucket_id: BucketId, /// Data roots to add to the MMR pub data_roots: Vec, - /// `CommitmentPayload` nonce — block at which the caller expects to + /// `CommitmentPayload` nonce — relay-chain block at which the caller expects to /// submit the resulting signature on-chain. The provider signs over /// this value so the pallet's recency check passes. pub nonce: u64, diff --git a/provider-node/tests/chain_state_integration.rs b/provider-node/tests/chain_state_integration.rs index 9e3b553f..c408959c 100644 --- a/provider-node/tests/chain_state_integration.rs +++ b/provider-node/tests/chain_state_integration.rs @@ -77,7 +77,7 @@ async fn coordinator_leaves_state_at_defaults_while_chain_unreachable() { // A chain it can't reach must never produce state: every field stays at the // default that makes `/negotiate` return 503. - assert_eq!(chain_state.current_block.load(Ordering::Relaxed), 0); + assert_eq!(chain_state.current_anchor_block.load(Ordering::Relaxed), 0); assert!(chain_state.constants.read().is_none()); assert!(chain_state.provider_info.read().is_none()); assert!(chain_state.nonce_counter.read().is_none()); @@ -140,7 +140,7 @@ async fn coordinator_keeps_retrying_without_panicking() { // dirties state. If the task had panicked, `stop()`'s join would surface it. for _ in 0..3 { tokio::time::sleep(Duration::from_millis(60)).await; - assert_eq!(chain_state.current_block.load(Ordering::Relaxed), 0); + assert_eq!(chain_state.current_anchor_block.load(Ordering::Relaxed), 0); assert!(chain_state.provider_info.read().is_none()); } @@ -300,7 +300,7 @@ async fn refresh_clears_info_and_counter_when_not_registered() { // provider is not (or no longer) registered — both fields are dropped so // `/negotiate` reports `provider_info_unavailable`. let cs = ChainState::default(); - cs.current_block.store(100, Ordering::Relaxed); + cs.current_anchor_block.store(100, Ordering::Relaxed); *cs.constants.write() = Some(PalletConstants { request_timeout: 200, }); @@ -315,7 +315,7 @@ async fn refresh_clears_info_and_counter_when_not_registered() { assert!(cs.provider_info.read().is_none()); assert!(cs.nonce_counter.read().is_none()); // Per-connection constants and the block height are not tied to registration. - assert_eq!(cs.current_block.load(Ordering::Relaxed), 100); + assert_eq!(cs.current_anchor_block.load(Ordering::Relaxed), 100); assert_eq!(cs.constants.read().as_ref().unwrap().request_timeout, 200); } diff --git a/provider-node/tests/coordinators/checkpoint.rs b/provider-node/tests/coordinators/checkpoint.rs index 8eaae975..4db4c04a 100644 --- a/provider-node/tests/coordinators/checkpoint.rs +++ b/provider-node/tests/coordinators/checkpoint.rs @@ -134,6 +134,50 @@ async fn test_duty_found_submit_ok() { assert_eq!(submitted[0], (1, 5)); } +/// `get_current_block` returns the relay-chain block number since the +/// relay-clock migration; the on-chain window is `relay_block / interval`, +/// so the coordinator must compute duties on that scale or its +/// `provider_checkpoint` submissions target the wrong window. +#[tokio::test] +async fn duty_window_derives_from_relay_scale_block() { + let mock = Arc::new(MockCheckpointChainClient::new(29_123_456)); + let state = test_state_with_bucket(1); + let config = CheckpointCoordinatorConfig::default(); + let coordinator = CheckpointCoordinator::new(config, state, Box::new(Arc::clone(&mock))); + + let duty = coordinator.get_checkpoint_duty(1).await.unwrap().unwrap(); + assert_eq!(duty.window, 291_234); // 29_123_456 / 100 + assert_eq!(duty.interval, 100); + assert_eq!(duty.grace_period, 20); +} + +/// The relay parent can repeat across consecutive parachain blocks +/// (velocity > 1) and advances within a window without changing it: the +/// duty window must move only when the relay clock crosses an interval +/// boundary, never on a mere re-read. +#[tokio::test] +async fn duty_window_moves_only_on_interval_boundary() { + let mock = Arc::new(MockCheckpointChainClient::new(29_123_456)); + let state = test_state_with_bucket(1); + let config = CheckpointCoordinatorConfig::default(); + let coordinator = CheckpointCoordinator::new(config, state, Box::new(Arc::clone(&mock))); + + // Repeated relay parent → identical duty. + let first = coordinator.get_checkpoint_duty(1).await.unwrap().unwrap(); + let repeat = coordinator.get_checkpoint_duty(1).await.unwrap().unwrap(); + assert_eq!(first.window, repeat.window); + + // Advancing within the window keeps it. + *mock.block_number.lock().unwrap() = 29_123_499; + let same_window = coordinator.get_checkpoint_duty(1).await.unwrap().unwrap(); + assert_eq!(same_window.window, first.window); + + // Crossing the interval boundary moves it by one. + *mock.block_number.lock().unwrap() = 29_123_500; + let next_window = coordinator.get_checkpoint_duty(1).await.unwrap().unwrap(); + assert_eq!(next_window.window, first.window + 1); +} + #[tokio::test] async fn test_submit_fails() { let mock = Arc::new( diff --git a/provider-node/tests/negotiate_integration.rs b/provider-node/tests/negotiate_integration.rs index 92e34544..5476c7fa 100644 --- a/provider-node/tests/negotiate_integration.rs +++ b/provider-node/tests/negotiate_integration.rs @@ -43,7 +43,7 @@ impl TestServer { // Together these satisfy every `/negotiate` prerequisite. state .chain_state - .current_block + .current_anchor_block .store(100, std::sync::atomic::Ordering::Relaxed); *state.chain_state.constants.write() = Some(PalletConstants { request_timeout: 200, @@ -168,6 +168,75 @@ async fn negotiate_returns_signed_terms_with_valid_signature() { ); } +#[tokio::test] +async fn negotiate_valid_until_is_anchor_block_plus_request_timeout() { + // `current_anchor_block` is the pallet's anchor clock — the block the pallet + // checks `valid_until` against. Seed a Paseo-scale value to prove the + // validity window is anchored to it (a parachain-height-based window + // would be rejected on-chain as already expired). + let state = ProviderState::with_seed(Arc::new(Storage::new()), PROVIDER_SEED).unwrap(); + state + .chain_state + .current_anchor_block + .store(29_123_456, std::sync::atomic::Ordering::Relaxed); + *state.chain_state.constants.write() = Some(PalletConstants { + request_timeout: 3_600, + }); + let counter = std::sync::Arc::new(NonceCounter::new(1)); + counter.bootstrap_from_hsn(0); + *state.chain_state.nonce_counter.write() = Some(counter); + *state.chain_state.provider_info.write() = Some(provider_info()); + let server = TestServer::serve(Arc::new(state)).await; + + let resp = server.negotiate(&primary_request()).await; + assert_eq!(resp.status(), StatusCode::OK); + let signed: SignedTerms = resp.json().await.unwrap(); + assert_eq!(signed.terms.valid_until, 29_123_456 + 3_600); +} + +#[tokio::test] +async fn negotiate_503_when_anchor_block_unknown() { + // Everything ready except the anchor clock (`current_anchor_block == 0`, + // i.e. the chain-state coordinator has not processed a finalized block + // yet): signing would emit terms whose `valid_until` is meaningless on + // the pallet's clock, so the handler must refuse. + let state = ProviderState::with_seed(Arc::new(Storage::new()), PROVIDER_SEED).unwrap(); + *state.chain_state.constants.write() = Some(PalletConstants { + request_timeout: 200, + }); + let counter = std::sync::Arc::new(NonceCounter::new(1)); + counter.bootstrap_from_hsn(0); + *state.chain_state.nonce_counter.write() = Some(counter); + *state.chain_state.provider_info.write() = Some(provider_info()); + let server = TestServer::serve(Arc::new(state)).await; + + let resp = server.negotiate(&primary_request()).await; + assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE); + let body: Value = resp.json().await.unwrap(); + assert_eq!(body["error"], "chain_state_not_ready"); +} + +#[tokio::test] +async fn negotiate_503_when_request_timeout_unknown() { + // The mirror case: clock known but the RequestTimeout constant not yet + // fetched — an unbounded validity window must not be signed either. + let state = ProviderState::with_seed(Arc::new(Storage::new()), PROVIDER_SEED).unwrap(); + state + .chain_state + .current_anchor_block + .store(100, std::sync::atomic::Ordering::Relaxed); + let counter = std::sync::Arc::new(NonceCounter::new(1)); + counter.bootstrap_from_hsn(0); + *state.chain_state.nonce_counter.write() = Some(counter); + *state.chain_state.provider_info.write() = Some(provider_info()); + let server = TestServer::serve(Arc::new(state)).await; + + let resp = server.negotiate(&primary_request()).await; + assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE); + let body: Value = resp.json().await.unwrap(); + assert_eq!(body["error"], "chain_state_not_ready"); +} + #[tokio::test] async fn negotiate_pins_listed_price_when_client_overpays() { let server = TestServer::ready(provider_info()).await; @@ -250,7 +319,7 @@ async fn negotiate_503_when_provider_info_unavailable() { let state = ProviderState::with_seed(Arc::new(Storage::new()), PROVIDER_SEED).unwrap(); state .chain_state - .current_block + .current_anchor_block .store(100, std::sync::atomic::Ordering::Relaxed); *state.chain_state.constants.write() = Some(PalletConstants { request_timeout: 200, @@ -336,7 +405,7 @@ async fn negotiate_transitions_to_info_unavailable_after_complete_deregister() { let state = ProviderState::with_seed(Arc::new(Storage::new()), PROVIDER_SEED).unwrap(); state .chain_state - .current_block + .current_anchor_block .store(100, std::sync::atomic::Ordering::Relaxed); *state.chain_state.constants.write() = Some(PalletConstants { request_timeout: 200, @@ -386,7 +455,7 @@ async fn negotiate_recovers_after_deregister_cancelled() { let state = ProviderState::with_seed(Arc::new(Storage::new()), PROVIDER_SEED).unwrap(); state .chain_state - .current_block + .current_anchor_block .store(100, std::sync::atomic::Ordering::Relaxed); *state.chain_state.constants.write() = Some(PalletConstants { request_timeout: 200, @@ -429,7 +498,7 @@ async fn negotiate_503_when_nonce_counter_absent() { let state = ProviderState::with_seed(Arc::new(Storage::new()), PROVIDER_SEED).unwrap(); state .chain_state - .current_block + .current_anchor_block .store(100, std::sync::atomic::Ordering::Relaxed); *state.chain_state.constants.write() = Some(PalletConstants { request_timeout: 200, @@ -453,7 +522,7 @@ async fn negotiate_503_when_nonce_counter_present_but_not_bootstrapped() { let state = ProviderState::with_seed(Arc::new(Storage::new()), PROVIDER_SEED).unwrap(); state .chain_state - .current_block + .current_anchor_block .store(100, std::sync::atomic::Ordering::Relaxed); *state.chain_state.constants.write() = Some(PalletConstants { request_timeout: 200, diff --git a/runtimes/web3-storage-local/src/constants.rs b/runtimes/web3-storage-local/src/constants.rs index f1a21c3b..9970cedb 100644 --- a/runtimes/web3-storage-local/src/constants.rs +++ b/runtimes/web3-storage-local/src/constants.rs @@ -49,6 +49,18 @@ pub mod time { pub const HOURS: BlockNumber = MINUTES * 60; } +/// Durations measured in RELAY chain blocks (6s), independent of the +/// parachain block time. All storage-pallet timeouts are denominated in relay +/// blocks (via `pallet_storage_provider::Config::BlockNumberProvider`) so +/// they keep their wall-clock meaning when the parachain block time changes. +pub mod relay_time { + use crate::BlockNumber; + + pub const RC_MINUTES: BlockNumber = + 60_000 / (super::consensus::RELAY_CHAIN_SLOT_DURATION_MILLIS as BlockNumber); + pub const RC_HOURS: BlockNumber = RC_MINUTES * 60; +} + /// Constants relating to the native token. pub mod currency { use crate::Balance; diff --git a/runtimes/web3-storage-local/src/lib.rs b/runtimes/web3-storage-local/src/lib.rs index a8542c3f..b5296f5a 100644 --- a/runtimes/web3-storage-local/src/lib.rs +++ b/runtimes/web3-storage-local/src/lib.rs @@ -833,6 +833,14 @@ pallet_revive::impl_runtime_apis_plus_revive_traits!( ) -> Vec<(AccountId, pallet_storage_provider::runtime_api::ProviderInfoResponse)> { StorageProvider::query_providers_with_capacity(bytes_needed, offset, limit) } + + fn current_anchor_block() -> BlockNumber { + StorageProvider::current_anchor_block() + } + + fn anchor_block_time_millis() -> u64 { + StorageProvider::anchor_block_time_millis() + } } impl xcm_runtime_apis::fees::XcmPaymentApi for Runtime { diff --git a/runtimes/web3-storage-local/src/storage.rs b/runtimes/web3-storage-local/src/storage.rs index 4e8ca49b..d4c3cda4 100644 --- a/runtimes/web3-storage-local/src/storage.rs +++ b/runtimes/web3-storage-local/src/storage.rs @@ -10,28 +10,34 @@ use frame_support::{ use sp_runtime::traits::AccountIdConversion; use crate::{ - constants::{currency::UNIT, time::HOURS}, + constants::{ + consensus::RELAY_CHAIN_SLOT_DURATION_MILLIS, currency::UNIT, relay_time::RC_HOURS, + }, AccountId, Balance, Balances, BlockNumber, Runtime, RuntimeEvent, }; +// Every duration below is measured in RELAY chain blocks (6s), not parachain +// blocks: the storage pallet reads its clock from +// `cumulus_pallet_parachain_system::RelaychainDataProvider`, so these keep +// their wall-clock meaning when the parachain block time changes. parameter_types! { pub const MinProviderStake: Balance = 1_000 * UNIT; // 1000 tokens minimum stake - pub const ChallengeTimeout: BlockNumber = 48 * HOURS; // 48 hours to respond + pub const ChallengeTimeout: BlockNumber = 48 * RC_HOURS; // 48 hours to respond // Replay-protection window for `CommitmentPayload::nonce`. A signature whose // nonce is older than this is rejected. Set wide enough to accommodate // normal off-chain choreography (provider signs, client builds & broadcasts // tx, tx finalises) without forcing re-signing. - pub const MaxNonceAge: BlockNumber = 24 * HOURS; + pub const MaxNonceAge: BlockNumber = 24 * RC_HOURS; // Reserved from the challenger when opening a challenge. 1 token at 12 // decimals = floor on spam economics. Previously hardcoded `100u32` // (1e-10 of a token) which made challenge spam effectively free. pub const ChallengeDeposit: Balance = UNIT; - pub const SettlementTimeout: BlockNumber = 24 * HOURS; - pub const RequestTimeout: BlockNumber = 6 * HOURS; + pub const SettlementTimeout: BlockNumber = 24 * RC_HOURS; + pub const RequestTimeout: BlockNumber = 6 * RC_HOURS; // 1 token (1e12) per 1 GB (1e9 bytes) = 1000 per byte pub const MinStakePerByte: Balance = 1_000; - pub const DefaultCheckpointInterval: BlockNumber = 100; - pub const DefaultCheckpointGrace: BlockNumber = 20; + pub const DefaultCheckpointInterval: BlockNumber = 100; // relay blocks (~10 min) + pub const DefaultCheckpointGrace: BlockNumber = 20; // relay blocks (~2 min) pub const CheckpointReward: Balance = 1_000_000_000_000; // 1 token pub const CheckpointMissPenalty: Balance = 500_000_000_000; // 0.5 token /// Must be `> ChallengeTimeout` so any challenge opened up to the @@ -40,11 +46,15 @@ parameter_types! { /// pre-deregistration agreement quote expires before re-registration (the /// re-register replay defense). Both are checked in `integrity_test`. /// Value: the 48h challenge window plus a 6h grace. - pub const DeregisterAnnouncementPeriod: BlockNumber = 54 * HOURS; - /// Caps the challenges sharing one deadline block so the `on_finalize` - /// slash sweep stays bounded. Generous: only challenges created in the - /// same block share a deadline. + pub const DeregisterAnnouncementPeriod: BlockNumber = 54 * RC_HOURS; + /// Caps the challenges sharing one deadline (relay block) and the + /// `on_initialize` sweep's per-block slash budget. Generous: only + /// challenges created while the chain sits on the same relay parent + /// share a deadline. pub const MaxChallengesPerDeadline: u16 = 1_000; + /// One anchor block = one relay slot: `BlockNumberProvider` below reads + /// the relay chain. + pub const AnchorBlockTimeMillis: u64 = RELAY_CHAIN_SLOT_DURATION_MILLIS as u64; } /// Treasury account that receives slashed funds. @@ -104,5 +114,7 @@ impl pallet_storage_provider::Config for Runtime { type MaxBucketsPerMember = ConstU32<1000>; type DeregisterAnnouncementPeriod = DeregisterAnnouncementPeriod; type MaxChallengesPerDeadline = MaxChallengesPerDeadline; + type BlockNumberProvider = cumulus_pallet_parachain_system::RelaychainDataProvider; + type AnchorBlockTimeMillis = AnchorBlockTimeMillis; type WeightInfo = crate::weights::pallet_storage_provider::WeightInfo; } diff --git a/runtimes/web3-storage-paseo/src/lib.rs b/runtimes/web3-storage-paseo/src/lib.rs index 6529b984..1a8e52d1 100644 --- a/runtimes/web3-storage-paseo/src/lib.rs +++ b/runtimes/web3-storage-paseo/src/lib.rs @@ -842,6 +842,14 @@ pallet_revive::impl_runtime_apis_plus_revive_traits!( ) -> Vec<(AccountId, pallet_storage_provider::runtime_api::ProviderInfoResponse)> { StorageProvider::query_providers_with_capacity(bytes_needed, offset, limit) } + + fn current_anchor_block() -> BlockNumber { + StorageProvider::current_anchor_block() + } + + fn anchor_block_time_millis() -> u64 { + StorageProvider::anchor_block_time_millis() + } } impl xcm_runtime_apis::fees::XcmPaymentApi for Runtime { diff --git a/runtimes/web3-storage-paseo/src/paseo_constants.rs b/runtimes/web3-storage-paseo/src/paseo_constants.rs index 9bdbccda..40adea0d 100644 --- a/runtimes/web3-storage-paseo/src/paseo_constants.rs +++ b/runtimes/web3-storage-paseo/src/paseo_constants.rs @@ -54,6 +54,18 @@ pub mod time { pub const HOURS: BlockNumber = MINUTES * 60; } +/// Durations measured in RELAY chain blocks (6s), independent of the +/// parachain block time. All storage-pallet timeouts are denominated in relay +/// blocks (via `pallet_storage_provider::Config::BlockNumberProvider`) so +/// they keep their wall-clock meaning when the parachain block time changes. +pub mod relay_time { + use crate::BlockNumber; + + pub const RC_MINUTES: BlockNumber = + 60_000 / (super::consensus::RELAY_CHAIN_SLOT_DURATION_MILLIS as BlockNumber); + pub const RC_HOURS: BlockNumber = RC_MINUTES * 60; +} + /// Constants relating to the native token. pub mod currency { use crate::Balance; diff --git a/runtimes/web3-storage-paseo/src/storage.rs b/runtimes/web3-storage-paseo/src/storage.rs index eb12643b..cc96f3e7 100644 --- a/runtimes/web3-storage-paseo/src/storage.rs +++ b/runtimes/web3-storage-paseo/src/storage.rs @@ -11,7 +11,9 @@ use frame_support::{ use sp_runtime::traits::AccountIdConversion; use crate::{ - paseo_constants::{currency::UNIT, time::HOURS}, + paseo_constants::{ + consensus::RELAY_CHAIN_SLOT_DURATION_MILLIS, currency::UNIT, relay_time::RC_HOURS, + }, AccountId, Balance, Balances, BlockNumber, Runtime, RuntimeEvent, }; @@ -20,24 +22,29 @@ use crate::{ // a runtime upgrade. Useful for tuning previewnet timing without redeploying the // wasm. Each `pub storage X` exposes `X::key()` / `X::set()` and reads the // current value from unhashed storage, falling back to the default. +// +// Every duration below is measured in RELAY chain blocks (6s), not parachain +// blocks: the storage pallet reads its clock from +// `cumulus_pallet_parachain_system::RelaychainDataProvider`, so these keep +// their wall-clock meaning when the parachain block time changes. parameter_types! { pub storage MinProviderStake: Balance = 1_000 * UNIT; // 1000 tokens minimum stake - pub storage ChallengeTimeout: BlockNumber = 48 * HOURS; + pub storage ChallengeTimeout: BlockNumber = 48 * RC_HOURS; // Replay-protection window for `CommitmentPayload::nonce`. A signature whose // nonce is older than this is rejected. Set wide enough to accommodate // normal off-chain choreography (provider signs, client builds & broadcasts // tx, tx finalises) without forcing re-signing. - pub storage MaxNonceAge: BlockNumber = 24 * HOURS; + pub storage MaxNonceAge: BlockNumber = 24 * RC_HOURS; // Reserved from the challenger when opening a challenge. 1 token at 12 // decimals = floor on spam economics. Previously hardcoded `100u32` // (1e-10 of a token) which made challenge spam effectively free. pub storage ChallengeDeposit: Balance = UNIT; - pub storage SettlementTimeout: BlockNumber = 24 * HOURS; - pub storage RequestTimeout: BlockNumber = 6 * HOURS; + pub storage SettlementTimeout: BlockNumber = 24 * RC_HOURS; + pub storage RequestTimeout: BlockNumber = 6 * RC_HOURS; // 1 token (1e12) per 1 GB (1e9 bytes) = 1000 per byte pub storage MinStakePerByte: Balance = 1_000; - pub storage DefaultCheckpointInterval: BlockNumber = 100; - pub storage DefaultCheckpointGrace: BlockNumber = 20; + pub storage DefaultCheckpointInterval: BlockNumber = 100; // relay blocks (~10 min) + pub storage DefaultCheckpointGrace: BlockNumber = 20; // relay blocks (~2 min) pub storage CheckpointReward: Balance = 1_000_000_000_000; // 1 token pub storage CheckpointMissPenalty: Balance = 500_000_000_000; // 0.5 token /// Must be `> ChallengeTimeout` so any challenge opened up to the @@ -46,11 +53,16 @@ parameter_types! { /// pre-deregistration agreement quote expires before re-registration (the /// re-register replay defense). Both are checked in `integrity_test`. /// Value: the 48h challenge window plus a 6h grace. - pub storage DeregisterAnnouncementPeriod: BlockNumber = 54 * HOURS; - /// Caps the challenges sharing one deadline block so the `on_finalize` - /// slash sweep stays bounded. Generous: only challenges created in the - /// same block share a deadline. + pub storage DeregisterAnnouncementPeriod: BlockNumber = 54 * RC_HOURS; + /// Caps the challenges sharing one deadline (relay block) and the + /// `on_initialize` sweep's per-block slash budget. Generous: only + /// challenges created while the chain sits on the same relay parent + /// share a deadline. pub storage MaxChallengesPerDeadline: u16 = 1_000; + /// One anchor block = one relay slot: `BlockNumberProvider` below reads + /// the relay chain. `const` (not `storage`): a physical property of the + /// anchor clock, not a tunable. + pub const AnchorBlockTimeMillis: u64 = RELAY_CHAIN_SLOT_DURATION_MILLIS as u64; } /// Treasury account that receives slashed funds. @@ -110,5 +122,7 @@ impl pallet_storage_provider::Config for Runtime { type MaxBucketsPerMember = ConstU32<1000>; type DeregisterAnnouncementPeriod = DeregisterAnnouncementPeriod; type MaxChallengesPerDeadline = MaxChallengesPerDeadline; + type BlockNumberProvider = cumulus_pallet_parachain_system::RelaychainDataProvider; + type AnchorBlockTimeMillis = AnchorBlockTimeMillis; type WeightInfo = crate::weights::pallet_storage_provider::WeightInfo; } diff --git a/runtimes/web3-storage-paseo/tests/tests.rs b/runtimes/web3-storage-paseo/tests/tests.rs index 3af8a828..94bdd8a8 100644 --- a/runtimes/web3-storage-paseo/tests/tests.rs +++ b/runtimes/web3-storage-paseo/tests/tests.rs @@ -15,9 +15,9 @@ use sp_keyring::Sr25519Keyring; use sp_runtime::{transaction_validity, ApplyExtrinsicResult, BuildStorage}; use storage_paseo_runtime::{ paseo_constants::currency::UNIT, xcm_config::LocationToAccountId, AllPalletsWithoutSystem, - Balance, Balances, Block, Runtime, RuntimeCall, RuntimeEvent, RuntimeGenesisConfig, - RuntimeOrigin, S3Registry, SessionKeys, StorageProvider, System, TxExtension, - UncheckedExtrinsic, WeightToFee, + Balance, Balances, Block, BlockNumber, Runtime, RuntimeCall, RuntimeEvent, + RuntimeGenesisConfig, RuntimeOrigin, S3Registry, SessionKeys, StorageProvider, System, + TxExtension, UncheckedExtrinsic, WeightToFee, }; use storage_primitives::AgreementTerms; use xcm::latest::prelude::*; @@ -29,6 +29,15 @@ use xcm_runtime_apis::conversions::LocationToAccountHelper; const ALICE: [u8; 32] = [1u8; 32]; +/// Set both clocks the runtime reads: `System` (parachain height) and the +/// relay-chain number served by `RelaychainDataProvider`, which the storage +/// pallets measure all durations against. Kept in lockstep for simplicity. +fn set_clocks(n: BlockNumber) { + use sp_runtime::traits::BlockNumberProvider; + System::set_block_number(n); + cumulus_pallet_parachain_system::RelaychainDataProvider::::set_block_number(n); +} + /// Advance to the next block for testing transaction storage. fn advance_block() { let current = frame_system::Pallet::::block_number(); @@ -37,12 +46,13 @@ fn advance_block() { >::on_finalize(current); let next = current + 1; - System::set_block_number(next); + set_clocks(next); frame_system::BlockWeight::::kill(); frame_system::BlockSize::::kill(); >::on_initialize(next); + >::on_initialize(next); } fn construct_extrinsic( @@ -155,7 +165,9 @@ fn primary_terms( max_bytes, duration, price_per_byte: 0, - valid_until: frame_system::Pallet::::block_number() + // `valid_until` is checked against the pallet's relay-chain clock, + // not the parachain height. + valid_until: pallet_storage_provider::Pallet::::current_anchor_block() + ::RequestTimeout::get(), nonce, bucket_id: None, @@ -494,11 +506,11 @@ fn should_deregister_provider() { ); // Step 3: fast-forward through the announcement period. Jump close to - // the boundary with `set_block_number` (the period is `48 * HOURS` + // the boundary with `set_clocks` (the period is `48 * RC_HOURS` // ≈ 28,800 blocks, far too many to iterate one-by-one) then cross it // via `advance_block` so the `on_finalize` / `on_initialize` hooks // fire at the maturing block. - System::set_block_number(deregister_at.saturating_sub(1)); + set_clocks(deregister_at.saturating_sub(1)); advance_block(); assert!(frame_system::Pallet::::block_number() >= deregister_at); diff --git a/storage-interfaces/file-system/pallet-registry/src/benchmarking.rs b/storage-interfaces/file-system/pallet-registry/src/benchmarking.rs index 3b1d8107..68e513a1 100644 --- a/storage-interfaces/file-system/pallet-registry/src/benchmarking.rs +++ b/storage-interfaces/file-system/pallet-registry/src/benchmarking.rs @@ -107,7 +107,7 @@ fn make_primary_terms(owner: &T::AccountId, nonce: u64) -> AgreementT max_bytes: 1_000u64, duration: 100u32.into(), price_per_byte: 1u32.into(), - valid_until: frame_system::Pallet::::block_number() + valid_until: pallet_storage_provider::Pallet::::current_anchor_block() .saturating_add(::RequestTimeout::get()), nonce, bucket_id: None, diff --git a/storage-interfaces/file-system/pallet-registry/src/lib.rs b/storage-interfaces/file-system/pallet-registry/src/lib.rs index baec7f01..1ffe428d 100644 --- a/storage-interfaces/file-system/pallet-registry/src/lib.rs +++ b/storage-interfaces/file-system/pallet-registry/src/lib.rs @@ -249,14 +249,14 @@ pub mod pallet { let next_id = drive_id.checked_add(1).ok_or(Error::::DriveIdOverflow)?; // Calculate expiry block - let current_block = >::block_number(); - let expires_at = current_block + storage_period; + let anchor_block = pallet_storage_provider::Pallet::::current_anchor_block(); + let expires_at = anchor_block + storage_period; // Create drive info let drive_info = DriveInfo { owner: who.clone(), bucket_id, - created_at: current_block, + created_at: anchor_block, name: bounded_name, max_capacity, storage_period, diff --git a/storage-interfaces/file-system/pallet-registry/src/mock.rs b/storage-interfaces/file-system/pallet-registry/src/mock.rs index 7552252a..eb3c2f92 100644 --- a/storage-interfaces/file-system/pallet-registry/src/mock.rs +++ b/storage-interfaces/file-system/pallet-registry/src/mock.rs @@ -115,6 +115,8 @@ impl pallet_storage_provider::Config for Test { // pallet's `integrity_test`. 100 + 50 grace. type DeregisterAnnouncementPeriod = ConstU64<150>; type MaxChallengesPerDeadline = ConstU16<1_000>; + type BlockNumberProvider = System; + type AnchorBlockTimeMillis = ConstU64<6000>; type WeightInfo = (); } diff --git a/storage-interfaces/s3/pallet-s3-registry/src/benchmarking.rs b/storage-interfaces/s3/pallet-s3-registry/src/benchmarking.rs index 62767cad..c1d03a89 100644 --- a/storage-interfaces/s3/pallet-s3-registry/src/benchmarking.rs +++ b/storage-interfaces/s3/pallet-s3-registry/src/benchmarking.rs @@ -182,7 +182,7 @@ fn insert_s3_bucket( name: bounded_name.clone(), layer0_bucket_id: 0, owner: owner.clone(), - created_at: frame_system::Pallet::::block_number(), + created_at: pallet_storage_provider::Pallet::::current_anchor_block(), object_count, total_size: 0, }; @@ -230,7 +230,7 @@ mod benchmarks { max_bytes, duration, price_per_byte: 1u32.into(), - valid_until: frame_system::Pallet::::block_number() + valid_until: pallet_storage_provider::Pallet::::current_anchor_block() .saturating_add(::RequestTimeout::get()), nonce: 1, bucket_id: None, diff --git a/storage-interfaces/s3/pallet-s3-registry/src/lib.rs b/storage-interfaces/s3/pallet-s3-registry/src/lib.rs index d28c4fa0..c675dd62 100644 --- a/storage-interfaces/s3/pallet-s3-registry/src/lib.rs +++ b/storage-interfaces/s3/pallet-s3-registry/src/lib.rs @@ -242,7 +242,7 @@ pub mod pallet { name: bounded_name.clone(), layer0_bucket_id, owner: who.clone(), - created_at: frame_system::Pallet::::block_number(), + created_at: pallet_storage_provider::Pallet::::current_anchor_block(), object_count: 0, total_size: 0, }; @@ -347,7 +347,8 @@ pub mod pallet { .unwrap_or_default(); // Get current timestamp - let timestamp = frame_system::Pallet::::block_number().saturated_into::(); + let timestamp = pallet_storage_provider::Pallet::::current_anchor_block() + .saturated_into::(); // Check if this is an update or new object match Objects::::get(s3_bucket_id, &bounded_key) { @@ -466,8 +467,8 @@ pub mod pallet { .ok_or(Error::::ObjectNotFound)?; // Update last modified - metadata.last_modified = - frame_system::Pallet::::block_number().saturated_into::(); + metadata.last_modified = pallet_storage_provider::Pallet::::current_anchor_block() + .saturated_into::(); // Update destination bucket stats (re-read if same bucket since src was read separately) let mut dst_bucket = diff --git a/storage-interfaces/s3/pallet-s3-registry/src/mock.rs b/storage-interfaces/s3/pallet-s3-registry/src/mock.rs index 9befcdec..4b700a59 100644 --- a/storage-interfaces/s3/pallet-s3-registry/src/mock.rs +++ b/storage-interfaces/s3/pallet-s3-registry/src/mock.rs @@ -115,6 +115,8 @@ impl pallet_storage_provider::Config for Test { // pallet's `integrity_test`. 100 + 50 grace. type DeregisterAnnouncementPeriod = ConstU64<150>; type MaxChallengesPerDeadline = ConstU16<1_000>; + type BlockNumberProvider = System; + type AnchorBlockTimeMillis = ConstU64<6000>; type WeightInfo = (); } diff --git a/user-interfaces/provider/src/lib/chain-client.ts b/user-interfaces/provider/src/lib/chain-client.ts index fb3c785d..fa0476ed 100644 --- a/user-interfaces/provider/src/lib/chain-client.ts +++ b/user-interfaces/provider/src/lib/chain-client.ts @@ -33,11 +33,40 @@ export { clientReady$, connectToChain, getClient, subscribeToBlocks } // The generic connection lifecycle lives in the shared @web3-storage/chain-client // package (imported/re-exported above); only provider-specific state stays here. -export const blockNumber$ = new BehaviorSubject(undefined) +/** + * The pallet's anchor block — the clock every on-chain duration (agreement + * expiry, challenge deadlines, checkpoint windows, deregister cooldown) is + * measured against. NOT the parachain height: on live networks the two clocks + * differ by millions of blocks, so pallet-clock comparisons must read this. + */ +export const anchorBlock$ = new BehaviorSubject(undefined) + +/** + * Refresh [`anchorBlock$`] from the `current_anchor_block` runtime API. The + * unsafe API resolves against live metadata, so no descriptor regeneration is + * needed; on runtimes without the API the parachain height is the pallet + * clock, so fall back to it. + */ +export async function refreshAnchorBlock(parachainBlock: number): Promise { + let anchor = parachainBlock + const client = getClient() + if (client) { + try { + anchor = Number( + await client.getUnsafeApi().apis.StorageProviderApi.current_anchor_block() + ) + } catch { /* pre-anchor runtime */ } + } + // Publish only if we are still on the connection this answer came from — + // a call resolving after a disconnect/reconnect would resurrect a stale + // anchor from the previous chain. + if (getClient() !== client) return + anchorBlock$.next(anchor) +} export function disconnectFromChain(): void { disconnectChain() - blockNumber$.next(undefined) + anchorBlock$.next(undefined) } // ───────────────────────────────────────────────────────────────────────────── @@ -47,7 +76,7 @@ export function disconnectFromChain(): void { export async function getChainProperties(): Promise<{ tokenDecimals: number tokenSymbol: string - blockTimeMs: number + anchorBlockTimeMs: number minProviderStake: bigint ss58Prefix: number specName: string @@ -58,7 +87,7 @@ export async function getChainProperties(): Promise<{ // them via constants / spec data. let tokenDecimals = 12 let tokenSymbol = 'UNIT' - let blockTimeMs = 6000 + let anchorBlockTimeMs = 6000 let minProviderStake = 1_000_000_000_000_000n let ss58Prefix = getSs58Prefix() let specName = '' @@ -98,13 +127,21 @@ export async function getChainProperties(): Promise<{ minProviderStake = await api.constants.StorageProvider.MinProviderStake() } catch { /* use default */ } + // Anchor-clock tick — deliberately NOT Aura.SlotDuration: every duration + // this UI formats (agreement duration, checkpoint interval/grace, provider + // min/max duration) is anchor-denominated (relay blocks), which the + // parachain block time will stop matching when it changes. The unsafe API + // resolves against live metadata, so no descriptor regeneration is needed; + // runtimes without the API keep the 6s default. try { - const period = await api.constants.Aura.SlotDuration() - blockTimeMs = Number(period) + const millis = await client + .getUnsafeApi() + .apis.StorageProviderApi.anchor_block_time_millis() + anchorBlockTimeMs = Number(millis) } catch { /* use default */ } } - return { tokenDecimals, tokenSymbol, blockTimeMs, minProviderStake, ss58Prefix, specName, specVersion, genesisHash } + return { tokenDecimals, tokenSymbol, anchorBlockTimeMs, minProviderStake, ss58Prefix, specName, specVersion, genesisHash } } export async function getGenesisHash(): Promise { @@ -331,7 +368,8 @@ export async function getAccountBalance(address: string): Promise<{ export async function getProviderAgreements(address: string): Promise { const entries = await requireApi().query.StorageProvider.StorageAgreements.getEntries() - const currentBlock = blockNumber$.getValue() || 0 + // expires_at is on the pallet's anchor clock, so the comparison must be too. + const anchorBlock = anchorBlock$.getValue() || 0 const out: OnChainAgreement[] = [] for (const { keyArgs, value } of entries) { const [bucketIdRaw, providerAddr] = keyArgs @@ -340,7 +378,7 @@ export async function getProviderAgreements(address: string): Promise expiresAt && expiresAt > 0) status = 'expired' + else if (anchorBlock > expiresAt && expiresAt > 0) status = 'expired' out.push({ id: bucketId, bucketId, @@ -482,7 +520,8 @@ export async function getBucketDetails( export async function getProviderChallenges(address: string): Promise { const entries = await requireApi().query.StorageProvider.Challenges.getEntries() - const currentBlock = blockNumber$.getValue() || 0 + // Challenge deadlines are on the pallet's anchor clock. + const anchorBlock = anchorBlock$.getValue() || 0 const challenges: OnChainChallenge[] = [] // Challenges is a StorageDoubleMap keyed by (deadline, index): each entry is // a single challenge with keyArgs = [deadline, index] and value = Challenge. @@ -499,7 +538,7 @@ export async function getProviderChallenges(address: string): Promise deadline ? 'expired' : 'pending', + status: anchorBlock > deadline ? 'expired' : 'pending', createdAt: 0, deadline, }) diff --git a/user-interfaces/provider/src/pages/Earnings.tsx b/user-interfaces/provider/src/pages/Earnings.tsx index 7d6e2ffe..98f397a4 100644 --- a/user-interfaces/provider/src/pages/Earnings.tsx +++ b/user-interfaces/provider/src/pages/Earnings.tsx @@ -10,7 +10,7 @@ import { useProviderInfo, } from '@/state/provider.state' import { RequireProvider } from '@/components/RequireProvider' -import { useBlockNumber } from '@/state/chain.state' +import { useAnchorBlock } from '@/state/chain.state' import { formatTokens, formatBytes, formatBlockNumber } from '@/utils/format' export function Earnings() { @@ -25,12 +25,13 @@ function EarningsContent() { const earnings = useEarnings() const activeAgreements = useActiveAgreements() const providerInfo = useProviderInfo() - const currentBlock = useBlockNumber() + // Agreement start/end blocks are on the pallet's anchor clock. + const anchorBlock = useAnchorBlock() // Calculate agreement progress for each active agreement const agreementProgress = activeAgreements.map((agreement) => { const duration = agreement.endBlock - agreement.startBlock - const elapsed = Math.max(0, Math.min(currentBlock - agreement.startBlock, duration)) + const elapsed = Math.max(0, Math.min(anchorBlock - agreement.startBlock, duration)) const progress = (elapsed / duration) * 100 const earnedSoFar = (agreement.pricePerByte * agreement.maxBytes * BigInt(elapsed)) / BigInt(duration) diff --git a/user-interfaces/provider/src/pages/Overview.tsx b/user-interfaces/provider/src/pages/Overview.tsx index 32886dca..96310624 100644 --- a/user-interfaces/provider/src/pages/Overview.tsx +++ b/user-interfaces/provider/src/pages/Overview.tsx @@ -23,7 +23,7 @@ import { import type { TxStatus } from '@/state/provider.state' import { useSelectedAccount } from '@/state/wallet.state' import { useSelectedNetwork, useSelectedNetworkId } from '@/state/network.state' -import { useChainInfo, useConnectionStatus, useBlockNumber } from '@/state/chain.state' +import { useChainInfo, useConnectionStatus, useAnchorBlock } from '@/state/chain.state' import { RequireProvider } from '@/components/RequireProvider' import { formatBytes, formatTokens, formatDuration, formatHash } from '@/utils/format' @@ -83,7 +83,8 @@ function DangerZone() { const providerInfo = useProviderInfo() const activeAgreements = useActiveAgreements() const selectedAccount = useSelectedAccount() - const currentBlock = useBlockNumber() + // deregister_at is on the pallet's anchor clock. + const anchorBlock = useAnchorBlock() const networkId = useSelectedNetworkId() const [confirming, setConfirming] = useState<'announce' | 'complete' | null>(null) @@ -109,7 +110,7 @@ function DangerZone() { const hasAgreements = activeAgreements.length > 0 const deregisterAt = providerInfo?.deregisterAt const isAnnounced = deregisterAt != null && deregisterAt > 0 - const cooldownElapsed = isAnnounced && currentBlock >= deregisterAt + const cooldownElapsed = isAnnounced && anchorBlock >= deregisterAt const handleProgress = useCallback((status: TxStatus) => { setTxStatus(status) @@ -246,7 +247,7 @@ function DangerZone() { De-registration announced. Completion available after block #{deregisterAt}.

- Current block: #{currentBlock} — {deregisterAt - currentBlock} block{deregisterAt - currentBlock !== 1 ? 's' : ''} remaining + Anchor block: #{anchorBlock} — {deregisterAt - anchorBlock} block{deregisterAt - anchorBlock !== 1 ? 's' : ''} remaining