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
14 changes: 14 additions & 0 deletions crates/quip-coordinator/src/chain/fake.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ pub struct FakeChain {
pub submitted: Mutex<Vec<Proof>>,
/// Optional scripted submit receipt (default `Success`, no chain detail).
submit_result: Mutex<Result<SubmitReceipt, ChainError>>,
/// Mempool orders the fake chain was asked to claim the reward on.
pub claimed_rewards: Mutex<Vec<u64>>,
/// Scripted `latest_qblock_id` (default `None`).
qblock_id: Mutex<Option<u64>>,
/// Scripted `fetch_decay_params` (default `None`).
Expand Down Expand Up @@ -94,6 +96,7 @@ impl FakeChain {
balance_accounts: Mutex::new(Vec::new()),
miner_info: Mutex::new(None),
solutions: Mutex::new(Vec::new()),
claimed_rewards: Mutex::new(Vec::new()),
solver_registered: Mutex::new(false),
solver_registration_result: Mutex::new(Ok(RegistrationOutcome::Registered)),
solver_registration_submits: Mutex::new(0),
Expand Down Expand Up @@ -606,6 +609,17 @@ impl ChainClient for FakeChain {
}
}

async fn claim_reward(&self, order_id: u64) -> Result<SubmitReceipt, ChainError> {
#[expect(
clippy::unwrap_used,
reason = "test double; Mutex poison is a test failure"
)]
{
self.claimed_rewards.lock().unwrap().push(order_id);
Ok(SubmitReceipt::action_only(SubmitAction::Success))
}
}

async fn submit_solution(&self, proof: &Proof) -> Result<SubmitReceipt, ChainError> {
#[expect(
clippy::unwrap_used,
Expand Down
10 changes: 10 additions & 0 deletions crates/quip-coordinator/src/chain/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,16 @@ pub trait ChainClient: Send + Sync {
/// ([`MAX_ORDER_SOLUTIONS`] rows).
async fn submit_solution(&self, proof: &Proof) -> Result<SubmitReceipt, ChainError>;

/// Claim the reward on a mempool order this account won.
///
/// The pallet pays nothing on its own: the winner has to claim. A
/// solver that submits a solution and never claims is paid nothing,
/// and the reward goes back to the proposer when the order is
/// reclaimed. The claim is only submitted once the order's wait has
/// elapsed; before that the receipt is retryable and no fee is
/// spent.
async fn claim_reward(&self, order_id: u64) -> Result<SubmitReceipt, ChainError>;

/// Hybrid-sign and submit `MinerRegistry.set_descriptor`.
async fn file_descriptor(
&self,
Expand Down
96 changes: 95 additions & 1 deletion crates/quip-coordinator/src/chain/real.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,8 @@ use super::extrinsic::{
use super::pool::{decode_pending_proof, PendingProof};
use super::proof_encode::{build_quantum_proof, ProofBuildContext};
use super::scale_types::{
encode_deregister_solver_call, encode_participate_call, encode_register_miner_call,
encode_claim_reward_call, encode_deregister_solver_call, encode_participate_call,
encode_register_miner_call,
encode_register_solver_call, encode_set_descriptor_call, encode_submit_proof_call,
encode_submit_solution_call, require_set_values, CurveCScale, DifficultyConfig, JobOrderScale,
JobSolutionScale, MinerInfoScale, MinerKind, NodeDescriptorV2Input, OrderStatus, OrderTiming,
Expand Down Expand Up @@ -703,6 +704,12 @@ impl RealChainClient {
.await?;
Ok(stored.is_some_and(|s| u64::from(s.submitted_at) == number))
}
Confirmation::RewardClaimed { order_id } => {
let stored = self
.read_storage::<JobOrderScale>(&job_orders_storage_key(order_id), block_hex)
.await?;
Ok(stored.is_some_and(|o| o.status == OrderStatus::Closed))
}
}
}

Expand Down Expand Up @@ -852,6 +859,10 @@ impl RealChainClient {
"dispatch failed and OrderSolutions for order {order_id} holds no submission from \
this block; the order may have closed, or the solutions missed its gates"
),
Confirmation::RewardClaimed { order_id } => format!(
"dispatch failed and order {order_id} did not close; the wait may not have elapsed, \
this account may not be a winner, or the order may already be claimed"
),
};
(text, false)
}
Expand Down Expand Up @@ -914,6 +925,10 @@ pub(crate) enum Confirmation {
/// this block. Presence alone is not enough: an earlier submission for the
/// same order would already be there.
SolutionAccepted { order_id: u64, account: [u8; 32] },
/// `QuantumComputeMempool.JobOrders[order_id]` is closed. A claim
/// pays the winner and closes the order, so the order's own status
/// is what the inclusion has to show.
RewardClaimed { order_id: u64 },
}

/// On-chain result of a hybrid-signed extrinsic, before pallet-specific classify.
Expand Down Expand Up @@ -1555,6 +1570,85 @@ impl ChainClient for RealChainClient {
})
}

async fn claim_reward(&self, order_id: u64) -> Result<SubmitReceipt, ChainError> {
// The order's own timing decides when the reward can be
// collected, so the order is read before anything is signed.
// A claim that is not ready yet is not submitted at all.
let head = self
.rpc_call("chain_getBlockHash", Value::Array(vec![]))
.await?;
let head_hex = head
.as_str()
.ok_or_else(|| ChainError::Decode("chain_getBlockHash not a string".into()))?
.to_string();
let header = self
.rpc_call(
"chain_getHeader",
Value::Array(vec![Value::String(head_hex.clone())]),
)
.await?;
let head_number = parse_block_number(&header)?;

let Some(order) = self
.read_storage::<JobOrderScale>(&job_orders_storage_key(order_id), &head_hex)
.await?
else {
tracing::warn!(order_id, "mempool reward not claimed: the order is not on chain");
return Ok(SubmitReceipt::action_only(SubmitAction::StopFatal));
};

// The pallet expires an order at the earlier of the hard
// deadline and the block wait after the first solution. The
// reward can only be collected once that point is reached.
let hard = order.created_at.saturating_add(order.timing.deadline_blocks);
let expiry = order.first_solution_at.map_or(hard, |first| {
hard.min(first.saturating_add(order.timing.block_wait))
});
if head_number < u64::from(expiry) {
tracing::debug!(
order_id,
head_number,
expiry,
"mempool reward is not claimable yet"
);
return Ok(SubmitReceipt::action_only(SubmitAction::Retry));
}

let call = encode_claim_reward_call(order_id);
let (outcome, extrinsic_hash) = self
.submit_signed_call(&call, Confirmation::RewardClaimed { order_id })
.await?;
let action = match &outcome {
SignedCallOutcome::Success { block, number } => {
tracing::info!(order_id, block = %block, number, "mempool reward claimed");
let block_hash = hex_decode(block)
.ok()
.and_then(|b| <[u8; 32]>::try_from(b.as_slice()).ok());
return Ok(SubmitReceipt {
action: SubmitAction::Success,
extrinsic_hash: Some(extrinsic_hash),
block_hash,
block_number: Some(*number),
});
}
SignedCallOutcome::DispatchFailed { error: reason, .. }
| SignedCallOutcome::Invalid { message: reason } => {
tracing::warn!(order_id, reason = %reason, "mempool reward not claimed");
SubmitAction::StopFatal
}
SignedCallOutcome::Dropped { message } => {
tracing::warn!(order_id, reason = %message, "mempool reward claim dropped before inclusion");
SubmitAction::Retry
}
};
Ok(SubmitReceipt {
action,
extrinsic_hash: Some(extrinsic_hash),
block_hash: None,
block_number: None,
})
}

async fn file_descriptor(
&self,
descriptor: &NodeDescriptorV2Input,
Expand Down
26 changes: 26 additions & 0 deletions crates/quip-coordinator/src/chain/scale_types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -572,6 +572,24 @@ pub fn encode_deregister_solver_call() -> Vec<u8> {
]
}

/// `QuantumComputeMempool::claim_reward(order_id)` call index.
pub const CLAIM_REWARD_CALL_INDEX: u8 = 5;

/// SCALE-encode `QuantumComputeMempool.claim_reward(order_id)`.
///
/// The reward on a won order is not paid automatically. The pallet
/// leaves the reward reserved with the proposer and the winner has to
/// call this to collect it, so a solver that submits a solution and
/// stops there is paid nothing, and the proposer reclaims the order
/// when it expires.
pub fn encode_claim_reward_call(order_id: u64) -> Vec<u8> {
let mut out = Vec::with_capacity(2 + 8);
out.push(QUANTUM_COMPUTE_MEMPOOL_PALLET_INDEX);
out.push(CLAIM_REWARD_CALL_INDEX);
out.extend_from_slice(&order_id.to_le_bytes());
out
}

/// SCALE-encode `QuantumComputeMempool.submit_solution(order_id, solutions)`.
///
/// Each row is one `±1` spin per node, in node order. A `BoundedVec` encodes
Expand Down Expand Up @@ -680,6 +698,14 @@ mod tests {
}

#[test]
#[test]
fn claim_reward_call_encodes_pallet_then_call_index_then_order_id() {
let call = encode_claim_reward_call(0x0102);
assert_eq!(call[0], QUANTUM_COMPUTE_MEMPOOL_PALLET_INDEX);
assert_eq!(call[1], CLAIM_REWARD_CALL_INDEX);
assert_eq!(&call[2..], &0x0102u64.to_le_bytes());
}

fn submit_solution_call_encodes_order_id_then_nested_spin_rows() {
let call = encode_submit_solution_call(0x0102, &[vec![1, -1], vec![-1, 1]]);
let mut expected = vec![
Expand Down
41 changes: 40 additions & 1 deletion crates/quip-coordinator/src/runtime.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
//! this alone serves sessions and supervises miners but stages no work yet.

use crate::chain::sync::{wait_until_synced, SyncOutcome, SyncSource};
use crate::chain::{ChainClient, JobOrder, MiningSnapshot};
use crate::chain::{ChainClient, JobOrder, MiningSnapshot, SubmitAction};
use crate::config::{DescriptorParams, LaunchEntry};
use crate::decay::{DecayAlgorithm, DecayModel, EnergyCurve};
use crate::funding::{ensure_funded, BalanceSource, Faucet};
Expand Down Expand Up @@ -586,6 +586,42 @@ async fn stage_mempool_orders<C: ChainClient>(
true
}

/// Claim the reward on every mempool order this account won.
///
/// The pallet pays a won order only when the winner claims it, so an
/// order whose solution was accepted has to be claimed or it is paid
/// nothing. The chain client reads the order and only submits the
/// claim once the order's wait has elapsed, so a claim made too early
/// costs no fee.
async fn claim_mempool_rewards<C: ChainClient>(
chain: &C,
state: &Arc<Mutex<CoordinatorState>>,
) {
let due: Vec<u64> = {
let mut st = state.lock().await;
std::mem::take(&mut st.pending_claims)
};
for order_id in due {
match chain.claim_reward(order_id).await {
Ok(receipt) => match receipt.action {
SubmitAction::Success => {}
// The order's wait has not elapsed yet. Put it back and
// try on a later poll.
SubmitAction::Retry => {
state.lock().await.pending_claims.push(order_id);
}
_ => {
tracing::warn!(order_id, "mempool reward not claimed");
}
},
Err(e) => {
tracing::warn!(order_id, error = %e, "mempool reward claim failed");
state.lock().await.pending_claims.push(order_id);
}
}
}
}

/// The replenished `PoW` feeder: follow the chain head, and keep each registered
/// miner's staged queue topped up to `buffer_depth` with fresh-salt jobs.
///
Expand Down Expand Up @@ -1157,6 +1193,9 @@ pub async fn feeder_loop<C>(
{
orders_read_at = snap.block_number;
}
if params.solver_registered.load(Ordering::Relaxed) {
claim_mempool_rewards(chain.as_ref(), &state).await;
}

// Steady-state narration. Every poll at debug for diagnosis; once a
// minute at info so an operator watching the log sees the
Expand Down
33 changes: 26 additions & 7 deletions crates/quip-coordinator/src/session.rs
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,13 @@ pub struct CoordinatorState {
/// Populated in [`CoordinatorState::dispatch_inflight`] for lease jobs
/// only; cleared in [`CoordinatorState::complete_inflight`].
pub lease_issued_at: HashMap<Vec<u8>, std::time::Instant>,
/// Mempool orders this account submitted a solution for and has not
/// claimed the reward on yet.
///
/// The pallet pays a won order only when the winner claims it, so
/// the order has to be remembered until the claim is made. The
/// feeder drains this list.
pub pending_claims: Vec<u64>,
/// Live per-miner outbound channels, so the supervisor can push an in-band
/// `Shutdown`/cancel to a running session. Registered on handshake success,
/// removed when the session ends.
Expand Down Expand Up @@ -245,6 +252,7 @@ impl CoordinatorState {
inflight: HashMap::new(),
inflight_owner: HashMap::new(),
lease_issued_at: HashMap::new(),
pending_claims: Vec::new(),
outbound: HashMap::new(),
wakeups: HashMap::new(),
salts: HashMap::new(),
Expand Down Expand Up @@ -1201,13 +1209,24 @@ async fn submit_mempool_result<C: ChainClient>(
salt: Vec::new(),
device_access_time_us,
};
match chain.submit_solution(&proof).await.map(|r| r.action) {
Ok(SubmitAction::Success) => tracing::info!(
order = %order,
best_energy_milli = proof.best_energy_milli,
rows = proof.solutions.len(),
"mempool solution submitted"
),
let receipt = chain.submit_solution(&proof).await;
match receipt.as_ref().map(|r| r.action) {
Ok(SubmitAction::Success) => {
tracing::info!(
order = %order,
best_energy_milli = proof.best_energy_milli,
rows = proof.solutions.len(),
"mempool solution submitted"
);
// The reward is not paid on submission. The order has to be
// remembered so the feeder can claim it once the order's
// wait has elapsed.
if let Ok(order_id) = <[u8; 8]>::try_from(proof.order_id.as_slice())
.map(u64::from_le_bytes)
{
state.lock().await.pending_claims.push(order_id);
}
}
Ok(SubmitAction::StopRoundStale | SubmitAction::StopFatal) => {
tracing::warn!(order = %order, "mempool solution refused; not retrying this order");
}
Expand Down