Skip to content
Merged
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

7 changes: 4 additions & 3 deletions client/consensus/qpow/src/chain_management.rs
Original file line number Diff line number Diff line change
Expand Up @@ -159,7 +159,8 @@ pub fn is_heavier<N: PartialOrd>(
(candidate_work == current_work && candidate_number > current_number)
}

/// Finalizes blocks that are `max_reorg_depth - 1` blocks behind the current best block.
/// Finalizes blocks that are `max_reorg_depth` blocks behind the current best block,
/// keeping exactly `max_reorg_depth` blocks reorganizable to match the configured window.
/// This should be called synchronously after each block import to ensure finalization
/// happens before the next block is imported.
///
Expand Down Expand Up @@ -204,8 +205,8 @@ where
ChainManagementError::RuntimeApiError(format!("Failed to get max reorg depth: {:?}", e))
})?;

// Calculate how far back to finalize
let finalize_depth = max_reorg_depth.saturating_sub(1);
// Keep the full maximum reorganization window unfinalized.
let finalize_depth = max_reorg_depth;

// Only finalize if we have enough blocks
if best_number <= finalize_depth.into() {
Expand Down
48 changes: 32 additions & 16 deletions client/consensus/qpow/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,9 @@ pub struct PowBlockImport<B: BlockT<Hash = H256>, I, C, CIDP, BE, const LOGGING_
client: Arc<C>,
create_inherent_data_providers: Arc<CIDP>,
check_inherents_after: <<B as BlockT>::Header as HeaderT>::Number,
// Serializes the best-work read, fork-choice decision and inner import so
// concurrent imports cannot race on a stale best. Shared across clones.
import_lock: Arc<futures::lock::Mutex<()>>,
_backend: PhantomData<BE>,
}

Expand All @@ -120,6 +123,7 @@ impl<
client: self.client.clone(),
create_inherent_data_providers: self.create_inherent_data_providers.clone(),
check_inherents_after: self.check_inherents_after,
import_lock: self.import_lock.clone(),
_backend: PhantomData,
}
}
Expand Down Expand Up @@ -156,6 +160,7 @@ where
client,
check_inherents_after,
create_inherent_data_providers: Arc::new(create_inherent_data_providers),
import_lock: Arc::new(futures::lock::Mutex::new(())),
_backend: PhantomData,
}
}
Expand Down Expand Up @@ -290,21 +295,20 @@ where
return Err(Error::<B>::InvalidSeal.into());
}

// Get parent's cumulative achieved work from aux storage
let parent_work = get_chain_work::<B, C>(&*self.client, parent_hash).unwrap_or_else(|e| {
log::warn!(target: LOG_TARGET, "Failed to get parent achieved work for {parent_hash:?}: {e:?}");
U512::zero()
});
// Get parent's cumulative achieved work from aux storage. A backend/decode
// failure must fail the import, not silently seed fork choice with zero.
let parent_work = get_chain_work::<B, C>(&*self.client, parent_hash)?;

// Calculate new cumulative achieved work
let new_work = parent_work.saturating_add(achieved_difficulty);

// Serialize the best-work read, fork-choice decision and inner import so a
// concurrent import cannot commit a new best between our read and our commit
// and let a weaker block win fork choice. Held until the end of the import.
let _import_guard = self.import_lock.lock().await;

let info = self.client.info();
let current_best_work = get_chain_work::<B, C>(&*self.client, info.best_hash)
.unwrap_or_else(|e| {
log::warn!(target: LOG_TARGET, "Failed to get best chain achieved work for {:?}: {e:?}", info.best_hash);
U512::zero()
});
let current_best_work = get_chain_work::<B, C>(&*self.client, info.best_hash)?;

let is_best = is_heavier(
new_work,
Expand Down Expand Up @@ -374,11 +378,16 @@ where
},
};

// Finalization prunes competing forks that are beyond max_reorg_depth.
// Finalization prunes competing forks that are beyond max_reorg_depth. A
// failure must be surfaced (error log with block context) but must NOT gate
// block import: finalization is retried on every subsequent import, and
// halting on a transient error would harm liveness.
if let Err(e) = finalize_canonical_at_depth::<B, C, BE>(&*self.client) {
log::warn!(
log::error!(
target: LOG_TARGET,
"Failed to finalize after block import: {:?}",
"Failed to finalize after importing block #{} ({:?}): {:?} (import not gated; will retry on next import)",
block_number_u64,
block_hash,
e
);
}
Expand Down Expand Up @@ -516,15 +525,22 @@ where
tx_notifications,
MIN_INTERVAL_BETWEEN_TX_REBUILDS,
);
let worker = MiningHandle::new(client.clone(), block_import, justification_sync_link);
let worker_ret = worker.clone();

// Latest build request - overwrites previous if builder is slow.
// Uses a Mutex<Option> for the value + a channel for wake notification.
let pending_build: Arc<parking_lot::Mutex<Option<Block::Hash>>> =
Arc::new(parking_lot::Mutex::new(None));
let (notify_tx, mut notify_rx) = futures::channel::mpsc::channel::<()>(1);

let worker = MiningHandle::new(
client.clone(),
block_import,
justification_sync_link,
sync_oracle.clone(),
pending_build.clone(),
notify_tx.clone(),
);
let worker_ret = worker.clone();

// Task 1: Convert triggers into build requests
let trigger_task = {
let client = client.clone();
Expand Down
47 changes: 43 additions & 4 deletions client/consensus/qpow/src/worker.rs
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,8 @@ use primitive_types::{H256, U512};
use sc_client_api::ImportNotifications;
use sc_consensus::{BlockImportParams, BoxBlockImport, StateAction, StorageChanges};
use sp_api::ProvideRuntimeApi;
use sp_consensus::{BlockOrigin, Proposal};
use sp_blockchain::HeaderBackend;
use sp_consensus::{BlockOrigin, Proposal, SyncOracle};
use sp_consensus_qpow::{QPoWApi, Seal, POW_ENGINE_ID};
use sp_runtime::{
traits::{Block as BlockT, Header as HeaderT},
Expand Down Expand Up @@ -76,33 +77,56 @@ pub struct MiningHandle<Block: BlockT, AC, L: sc_consensus::JustificationSyncLin
justification_sync_link: Arc<L>,
build: Arc<Mutex<Option<MiningBuild<Block, Proof>>>>,
block_import: Arc<BoxBlockImport<Block>>,
sync_oracle: Arc<dyn SyncOracle + Send + Sync>,
// Rebuild-request channel shared with the block-building task, so mining can be
// resumed (post-sync, or after a failed import) without an external trigger.
pending_build: Arc<Mutex<Option<Block::Hash>>>,
rebuild_notify: futures::channel::mpsc::Sender<()>,
}

impl<Block, AC, L, Proof> MiningHandle<Block, AC, L, Proof>
where
Block: BlockT<Hash = H256>,
AC: ProvideRuntimeApi<Block>,
AC: ProvideRuntimeApi<Block> + HeaderBackend<Block>,
AC::Api: QPoWApi<Block>,
L: sc_consensus::JustificationSyncLink<Block>,
{
fn increment_version(&self) {
self.version.fetch_add(1, Ordering::SeqCst);
}

pub(crate) fn new(
pub(crate) fn new<SO>(
client: Arc<AC>,
block_import: BoxBlockImport<Block>,
justification_sync_link: L,
) -> Self {
sync_oracle: SO,
pending_build: Arc<Mutex<Option<Block::Hash>>>,
rebuild_notify: futures::channel::mpsc::Sender<()>,
) -> Self
where
SO: SyncOracle + Send + Sync + 'static,
{
Self {
version: Arc::new(AtomicUsize::new(0)),
client,
justification_sync_link: Arc::new(justification_sync_link),
build: Arc::new(Mutex::new(None)),
block_import: Arc::new(block_import),
sync_oracle: Arc::new(sync_oracle),
pending_build,
rebuild_notify,
}
}

/// Request a rebuild of the mining candidate on top of the current best block.
/// Used to resume mining after the build was cleared (post-sync) or a submitted
/// block failed to import, leaving no candidate.
pub fn request_rebuild(&self) {
let best_hash = self.client.info().best_hash;
*self.pending_build.lock() = Some(best_hash);
let _ = self.rebuild_notify.clone().try_send(());
}

pub(crate) fn on_major_syncing(&self) {
let mut build = self.build.lock();
*build = None;
Expand Down Expand Up @@ -142,6 +166,15 @@ where
let build = {
let mut build_guard = self.build.lock();

// Defense-in-depth: never import a locally mined block while the node is
// still doing major sync. Drop the stale candidate.
if self.sync_oracle.is_major_syncing() {
debug!(target: LOG_TARGET, "Rejecting mined block submission due to sync.");
*build_guard = None;
self.increment_version();
return false;
}

// Extract metadata for verification while keeping the build in place
let (pre_hash, best_hash) = match build_guard.as_ref() {
Some(b) => (b.metadata.pre_hash.0, b.metadata.best_hash),
Expand Down Expand Up @@ -215,6 +248,9 @@ where
},
Err(err) => {
warn!(target: LOG_TARGET, "Unable to import mined block: {}", err,);
// The build was consumed above; request a fresh candidate so mining
// resumes without waiting for an external trigger.
self.request_rebuild();
false
},
}
Expand All @@ -234,6 +270,9 @@ where
justification_sync_link: self.justification_sync_link.clone(),
build: self.build.clone(),
block_import: self.block_import.clone(),
sync_oracle: self.sync_oracle.clone(),
pending_build: self.pending_build.clone(),
rebuild_notify: self.rebuild_notify.clone(),
}
}
}
Expand Down
12 changes: 9 additions & 3 deletions node/src/service.rs
Original file line number Diff line number Diff line change
Expand Up @@ -244,8 +244,10 @@ async fn handle_local_mining(
(),
>,
) -> Option<Vec<u8>> {
let metadata = worker_handle.metadata()?;
// Read the version BEFORE snapshotting metadata so any concurrent rebuild
// between the two reads is caught by the post-search version check below.
let version = worker_handle.version();
let metadata = worker_handle.metadata()?;
let block_hash = metadata.pre_hash.0;
let difficulty = client.runtime_api().get_difficulty(metadata.best_hash).unwrap_or_else(|e| {
log::warn!("API error getting difficulty: {:?}", e);
Expand Down Expand Up @@ -378,9 +380,13 @@ async fn mining_loop(
offline_since = None;
}

// Wait for mining metadata to be available
// Wait for mining metadata to be available. We are past the sync check,
// so if there is no candidate (e.g. it was cleared during a completed
// sync, or a submitted block failed to import) request a rebuild so
// mining resumes without waiting for an external block/tx trigger.
if worker_handle.metadata().is_none() {
log::debug!(target: "pow", "No mining metadata available");
log::debug!(target: "pow", "No mining metadata available, requesting rebuild");
worker_handle.request_rebuild();
tokio::select! {
_ = tokio::time::sleep(Duration::from_millis(250)) => {}
_ = cancellation_token.cancelled() => continue
Expand Down
9 changes: 9 additions & 0 deletions pallets/multisig/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ codec = { features = ["derive", "max-encoded-len"], workspace = true }
frame-benchmarking = { optional = true, workspace = true }
frame-support.workspace = true
frame-system.workspace = true
log.workspace = true
pallet-balances.workspace = true
pallet-reversible-transfers = { path = "../reversible-transfers", default-features = false, optional = true }
qp-high-security = { path = "../../primitives/high-security", default-features = false }
Expand Down Expand Up @@ -59,6 +60,7 @@ std = [
"frame-benchmarking?/std",
"frame-support/std",
"frame-system/std",
"log/std",
"pallet-balances/std",
"pallet-reversible-transfers?/std",
"pallet-timestamp/std",
Expand All @@ -70,3 +72,10 @@ std = [
"sp-io/std",
"sp-runtime/std",
]
try-runtime = [
"frame-support/try-runtime",
"frame-system/try-runtime",
"pallet-balances/try-runtime",
"pallet-reversible-transfers?/try-runtime",
"sp-runtime/try-runtime",
]
9 changes: 4 additions & 5 deletions pallets/multisig/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -316,7 +316,6 @@ Stores proposal data indexed by (multisig_address, proposal_id):
ProposalData {
proposer: AccountId, // Who proposed (receives deposit back)
call: BoundedVec<u8>, // Encoded RuntimeCall to execute
call_weight: Weight, // Declared inner-call weight captured at propose time
expiry: BlockNumber, // Deadline for approvals
approvals: BoundedVec<AccountId>, // List of signers who approved
deposit: Balance, // Reserved deposit (refundable)
Expand Down Expand Up @@ -486,8 +485,8 @@ This event structure is optimized for indexing by SubSquid and similar indexers:
- **No global limits:** Only per-multisig limits (decentralized resistance)

### Call Execution
- Calls are decoded and validated at `propose()` time, then stored as bounded call bytes with the declared `call_weight`
- Calls are decoded again at `execute()` time before dispatch
- Calls are decoded and validated at `propose()` time (including an inner-call weight check against `MaxInnerCallWeight`), then stored as bounded call bytes
- Calls are decoded again at `execute()` time before dispatch, and the inner-call weight is recomputed then (it is not stored)
- High-security whitelist enforcement runs at proposal creation for currently high-security multisigs and again at execution time
- Allowed calls execute with multisig_address as origin
- Standard multisigs can call any pallet (including recursive multisig calls) as long as the call fits size and weight limits
Expand Down Expand Up @@ -646,9 +645,9 @@ Normal multisigs automatically get refunded for unused high-security overhead.

**Weight calculation:**
- `propose()` charges upfront for the current worst-case proposal path used by the implementation: `propose_high_security(call.len())`. Actual weight is refunded based on path: `propose(call_size)` for normal multisigs, `propose_high_security(call_size)` for high-security multisigs. No cleanup runs in propose.
- `propose()` rejects calls whose declared `call_weight` exceeds `MaxInnerCallWeight`.
- `propose()` rejects calls whose inner-call weight (from `get_dispatch_info()`) exceeds `MaxInnerCallWeight`.
- `execute()` charges upfront for bookkeeping worst-case plus the maximum allowed inner-call weight: `WeightInfo::execute(T::MaxCallSize::get()) + T::MaxInnerCallWeight::get()`.
- `execute()` returns actual weight as bookkeeping for the stored call size plus the inner call's post-dispatch weight, using the stored `call_weight` as fallback when the inner call does not report actual weight.
- `execute()` returns actual weight as bookkeeping for the stored call size plus the inner call's post-dispatch weight, using the inner-call weight recomputed at execute time as fallback when the inner call does not report actual weight.
- `claim_deposits()` charges upfront for worst-case iteration and cleanup; actual weight based on proposals iterated and cleaned (dynamic refund).

**Security notes:**
Expand Down
2 changes: 2 additions & 0 deletions pallets/multisig/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,8 @@ pub use weights::*;
#[cfg(feature = "runtime-benchmarks")]
mod benchmarking;

pub mod migrations;

#[cfg(test)]
mod mock;

Expand Down
Loading
Loading