Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 9 additions & 6 deletions client/consensus/qpow/src/chain_management.rs
Original file line number Diff line number Diff line change
Expand Up @@ -163,8 +163,10 @@ pub fn is_heavier<N: PartialOrd>(
/// This should be called synchronously after each block import to ensure finalization
/// happens before the next block is imported.
///
/// Also cleans up achieved work entries for blocks that are now deep enough in the
/// finalized chain that they can never be involved in fork choice again.
/// Cleans up achieved work entries for canonical blocks that are now finalized.
/// Note: Non-canonical fork blocks are not cleaned up since we cannot enumerate them
/// by height. This is acceptable because fork blocks require valid PoW to create,
/// making accumulation attacks expensive, and the entries are small (~96 bytes each).
pub fn finalize_canonical_at_depth<B, C, BE>(client: &C) -> Result<(), ConsensusError>
where
B: BlockT<Hash = H256>,
Expand Down Expand Up @@ -267,10 +269,11 @@ where

log::debug!("✓ Finalized block #{:?} ({:?})", finalize_number, finalize_hash);

// Clean up achieved work for the previously finalized block.
// Once a block is finalized and we've moved past it, its achieved work
// is no longer needed for fork choice decisions.
if last_finalized_before > Zero::zero() {
// Clean up achieved work entries for blocks that are now below the finalized tip.
// Only clean up if finalization actually advanced (last_finalized_after >
// last_finalized_before), and only delete entries strictly below the new finalized height.
// The finalized tip's entry must be preserved as it's the parent work source for children.
if last_finalized_after > last_finalized_before && last_finalized_before > Zero::zero() {
if let Ok(Some(old_finalized_hash)) = client.hash(last_finalized_before) {
if let Err(e) = delete_cumulative_achieved_work::<B, C>(client, old_finalized_hash) {
// Non-fatal: log warning but don't fail the finalization
Expand Down
106 changes: 39 additions & 67 deletions client/consensus/qpow/src/worker.rs
Original file line number Diff line number Diff line change
Expand Up @@ -134,77 +134,49 @@ where
self.build.lock().as_ref().map(|b| b.metadata.clone())
}

/// Verify a seal without consuming the build.
///
/// Returns `true` if the seal is valid for the current block, `false` otherwise.
/// Returns `false` if there's no current build.
/// Logs detailed information on failure for debugging.
pub fn verify_seal(&self, seal: &Seal) -> bool {
let build = self.build.lock();
let build = match build.as_ref() {
Some(b) => b,
None => {
warn!(target: LOG_TARGET, "verify_seal: No current build available");
return false;
},
};
/// Submit a mined seal. The seal will be validated before consuming the build.
/// Returns true if the submission is successful.
pub async fn submit(&self, seal: Seal) -> bool {
// Atomically verify and take the build in a single lock acquisition.
// This prevents TOCTOU issues where a rebuild could land between verify and consume.
let build = {
let mut build_guard = self.build.lock();
let build_ref = match build_guard.as_ref() {
Some(b) => b,
None => {
warn!(target: LOG_TARGET, "Unable to import mined block: build does not exist");
return false;
},
};

// Convert seal to nonce [u8; 64]
let nonce: [u8; 64] = match seal.as_slice().try_into() {
Ok(arr) => arr,
Err(_) => {
warn!(target: LOG_TARGET, "Seal does not have exactly 64 bytes, got {}", seal.len());
return false;
},
};
// Verify seal before consuming the build
let nonce: [u8; 64] = match seal.as_slice().try_into() {
Ok(arr) => arr,
Err(_) => {
warn!(target: LOG_TARGET, "Seal does not have exactly 64 bytes, got {}", seal.len());
return false;
},
};

let pre_hash = build.metadata.pre_hash.0;
let best_hash = build.metadata.best_hash;
let difficulty = build.metadata.difficulty;
let extrinsic_count = build.proposal.block.extrinsics().len();

// Verify using runtime API
match self.client.runtime_api().verify_nonce_local_mining(best_hash, pre_hash, nonce) {
Ok(true) => true,
Ok(false) => {
log::error!(
target: LOG_TARGET,
"verify_seal FAILED:\n\
pre_hash (block template): {}\n\
best_hash (parent block): {}\n\
difficulty: {}\n\
nonce (seal): {}\n\
extrinsics in block: {}",
hex::encode(pre_hash),
best_hash,
difficulty,
hex::encode(nonce),
extrinsic_count,
);
false
},
Err(e) => {
warn!(target: LOG_TARGET, "Runtime API error verifying seal: {:?}", e);
false
},
}
}
let pre_hash = build_ref.metadata.pre_hash.0;
let best_hash = build_ref.metadata.best_hash;

/// Submit a mined seal. The seal will be validated again. Returns true if the submission is
/// successful.
pub async fn submit(&self, seal: Seal) -> bool {
let build = if let Some(build) = {
let mut build = self.build.lock();
let value = build.take();
if value.is_some() {
self.increment_version();
match self.client.runtime_api().verify_nonce_local_mining(best_hash, pre_hash, nonce) {
Ok(true) => {
// Seal is valid, now take the build
let value = build_guard.take();
self.increment_version();
value.unwrap() // Safe: we checked it exists above
},
Ok(false) => {
warn!(target: LOG_TARGET, "Seal verification failed, not consuming build");
return false;
},
Err(e) => {
warn!(target: LOG_TARGET, "Runtime API error verifying seal: {:?}", e);
return false;
},
}
value
} {
build
} else {
warn!(target: LOG_TARGET, "Unable to import mined block: build does not exist",);
return false;
};

let seal = DigestItem::Seal(POW_ENGINE_ID, seal);
Expand Down
7 changes: 5 additions & 2 deletions miner-api/src/lib.rs
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
use serde::{Deserialize, Serialize};
use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt};

/// Maximum message size (16 MB) to prevent memory exhaustion attacks.
pub const MAX_MESSAGE_SIZE: u32 = 16 * 1024 * 1024;
/// Maximum message size (1 KB) to prevent memory exhaustion attacks.
///
/// Real MinerMessage payloads are only a few hundred bytes (Ready, NewJob, JobResult).
/// 1 KB provides sufficient headroom while minimizing the amplification attack surface.
pub const MAX_MESSAGE_SIZE: u32 = 1024;

/// Status codes returned in API responses.
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
Expand Down
11 changes: 3 additions & 8 deletions node/src/service.rs
Original file line number Diff line number Diff line change
Expand Up @@ -212,12 +212,7 @@ async fn handle_external_mining(
},
};

// Verify the seal before attempting to submit (submit consumes the build)
if !worker_handle.verify_seal(&seal) {
continue;
}

// Seal is valid, submit it
// Submit the seal (submit verifies atomically before consuming the build)
if worker_handle.submit(seal.clone()).await {
let mining_time = mining_start_time.elapsed().as_secs();
log::info!(
Expand All @@ -229,9 +224,9 @@ async fn handle_external_mining(
return ExternalMiningOutcome::Success;
}

// Submit failed for some other reason (should be rare after verify_seal passed)
// Submit failed (seal invalid or import error)
log::warn!(
"⛏️ Failed to submit verified seal from miner {}, continuing to wait (job {})",
"⛏️ Failed to submit seal from miner {}, continuing to wait (job {})",
miner_id,
job_id
);
Expand Down
15 changes: 9 additions & 6 deletions pallets/qpow/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -77,12 +77,12 @@ pub mod pallet {
#[pallet::genesis_build]
impl<T: Config> BuildGenesisConfig for GenesisConfig<T> {
fn build(&self) {
let initial_difficulty = T::InitialDifficulty::get();

<CurrentDifficulty<T>>::put(initial_difficulty);
// Use the genesis config value, not the runtime constant.
// This allows chain-spec overrides of initial difficulty.
<CurrentDifficulty<T>>::put(self.initial_difficulty);

log::info!(target: "qpow", "Genesis: Set initial difficulty to {:x}",
initial_difficulty.low_u64());
self.initial_difficulty.low_u64());
}
}

Expand All @@ -109,7 +109,7 @@ pub mod pallet {

/// Called at the end of each block to adjust mining difficulty.
fn on_finalize(block_number: BlockNumberFor<T>) {
let current_difficulty = <CurrentDifficulty<T>>::get();
let current_difficulty = Self::get_difficulty();
log::debug!(target: "qpow",
"📢 QPoW: before submit at block {:?}, current_difficulty={:?}",
block_number,
Expand Down Expand Up @@ -137,7 +137,10 @@ pub mod pallet {
fn adjust_difficulty() {
let now = pallet_timestamp::Pallet::<T>::now().saturated_into::<u64>();
let last_time = <LastBlockTime<T>>::get();
let current_difficulty = <CurrentDifficulty<T>>::get();
// Use get_difficulty() to handle zero/missing storage consistently with verification.
// This ensures we use InitialDifficulty as the base when storage is unset,
// rather than computing from zero which would clamp to min_difficulty.
let current_difficulty = Self::get_difficulty();
let current_block_number = <frame_system::Pallet<T>>::block_number();

// Calculate block time (use target for genesis block)
Expand Down
2 changes: 1 addition & 1 deletion qpow-math/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,7 @@ pub fn mine_range(
return Some((nonce_bytes, hash));
}

nonce = nonce.saturating_add(U512::from(1u64));
nonce = nonce.overflowing_add(U512::from(1u64)).0;
}

None
Expand Down
Loading