Skip to content

Commit 860b809

Browse files
AIQnetLabclaude
andcommitted
cold-join: rehydrate in-mem state, bind total_supply + burn amount in consensus
- A2: rehydrate the in-mem StateManager from the promoted CF after snapshot promote, fail-closed against the QC-bound macroblock state_root (fixes the first-tail-block empty-root wedge that stalled super-node onboarding). - A1: settle the GALC pin before reading the snapshot ceiling so a cold joiner adopts the recent anchor, not the h=90 genesis anchor; skip the pin-wait below the first-capsule height. - total_supply: bind into the QC Checkpoint (mirror registry_root); producer seals get_total_supply(), cold-join reads the QC-bound value instead of summing balances (correct at all epochs, fixes the emission-cap divergence). - registered_nodes: rebuild from the snapshot-bound node_registry on cold-join so a joiner's dedup map matches a from-genesis node. - burn attestation: sign the actual on-Solana burned amount and embed the 2f+1-agreed cost + amount in the registration (consensus-bound Sybil floor). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 1c7e35e commit 860b809

10 files changed

Lines changed: 589 additions & 130 deletions

File tree

core/qnet-consensus/src/checkpoint_bft.rs

Lines changed: 28 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -233,6 +233,13 @@ pub struct Checkpoint {
233233
/// node_registry — the source of cbw and attestor VRF keys — against this committed root,
234234
/// closing the forgeable-snapshot Sybil/fork vector.
235235
pub registry_root: Hash,
236+
/// QC-signed total minted supply as of window_head_height. The apply-accumulated
237+
/// emission total (genesis=0, monotonic +emit_rewards). QC-signed ⇒ 2f+1 certify it ⇒
238+
/// a cold-joiner reads this QC-bound value instead of summing restored balances (which
239+
/// diverges at epoch≥2 once rewards are minted-then-claimed-later). total_supply is
240+
/// consensus-critical (emission cap) but not in state_root (account-only), so it is
241+
/// bound separately here.
242+
pub total_supply: u64,
236243
/// Proposer's wall-clock for this window (the head microblock's timestamp).
237244
/// In the QC-signed hash ⇒ agreed by the committee ⇒ every node seals an
238245
/// identical MacroBlock from the checkpoint (no producer dependency, no fork).
@@ -258,6 +265,7 @@ impl Checkpoint {
258265
h.update(self.epoch_commitment);
259266
h.update(self.reward_root);
260267
h.update(self.registry_root);
268+
h.update(self.total_supply.to_le_bytes());
261269
h.update(self.timestamp.to_le_bytes());
262270
h.update(self.proposer.as_bytes());
263271
h.finalize().into()
@@ -427,7 +435,7 @@ mod tests {
427435
let mut c = Checkpoint {
428436
index: 1, parent_qc: None, window_head_height: 90,
429437
window_mb_hashes: vec![h(1), h(2)], state_root: h(3),
430-
beacon: h(4), epoch_commitment: h(0), reward_root: h(0), registry_root: h(0), timestamp: 0, proposer: "n1".into(), proposer_sig: vec![1,2,3],
438+
beacon: h(4), epoch_commitment: h(0), reward_root: h(0), registry_root: h(0), total_supply: 0, timestamp: 0, proposer: "n1".into(), proposer_sig: vec![1,2,3],
431439
};
432440
let x = c.hash();
433441
c.proposer_sig = vec![9, 9, 9]; // sig change must NOT change hash
@@ -444,6 +452,23 @@ mod tests {
444452
assert_ne!(a.hash(), b.hash());
445453
}
446454

455+
#[test]
456+
fn checkpoint_hash_binds_total_supply() {
457+
// total_supply MUST be bound into the QC-signed hash: a cold-joiner trusts this value
458+
// (2f+1 certify it) instead of summing balances, so a checkpoint differing only in
459+
// total_supply must produce a different hash. Otherwise stable + deterministic.
460+
let c = Checkpoint {
461+
index: 1, parent_qc: None, window_head_height: 90,
462+
window_mb_hashes: vec![h(1), h(2)], state_root: h(3),
463+
beacon: h(4), epoch_commitment: h(0), reward_root: h(0), registry_root: h(0), total_supply: 1_000_000, timestamp: 0, proposer: "n1".into(), proposer_sig: vec![1,2,3],
464+
};
465+
let base = c.hash();
466+
assert_eq!(c.hash(), base, "hash must be deterministic for fixed fields");
467+
let mut d = c.clone();
468+
d.total_supply = 2_000_000; // value change MUST change hash
469+
assert_ne!(d.hash(), base, "total_supply must be in the QC-signed hash");
470+
}
471+
447472
#[test]
448473
fn merkle_root_deterministic_and_sensitive() {
449474
let a = vec![vec![1u8,2], vec![3,4], vec![5,6]];
@@ -609,7 +634,7 @@ mod tests {
609634
let child = Checkpoint {
610635
index: 5, parent_qc: Some(parent_qc), window_head_height: 450,
611636
window_mb_hashes: vec![h(1)], state_root: h(2), beacon: h(3),
612-
epoch_commitment: h(0), reward_root: h(0), registry_root: h(0), timestamp: 0, proposer: "n0".into(), proposer_sig: vec![],
637+
epoch_commitment: h(0), reward_root: h(0), registry_root: h(0), total_supply: 0, timestamp: 0, proposer: "n0".into(), proposer_sig: vec![],
613638
};
614639
let child_qc = mk_qc(&committee, child.hash(), 5, 3);
615640
assert_eq!(commits_parent(&child, &child_qc), Some(4)); // C4 final
@@ -625,7 +650,7 @@ mod tests {
625650
let c = Checkpoint {
626651
index: 7, parent_qc: Some(qc.clone()), window_head_height: 630,
627652
window_mb_hashes: vec![h(1), h(2)], state_root: h(3), beacon: h(4),
628-
epoch_commitment: h(0), reward_root: h(0), registry_root: h(0), timestamp: 0, proposer: "n1".into(), proposer_sig: vec![1,2,3],
653+
epoch_commitment: h(0), reward_root: h(0), registry_root: h(0), total_supply: 0, timestamp: 0, proposer: "n1".into(), proposer_sig: vec![1,2,3],
629654
};
630655
let bytes = bincode::serialize(&c).unwrap();
631656
let back: Checkpoint = bincode::deserialize(&bytes).unwrap();

core/qnet-consensus/src/checkpoint_consensus.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -229,7 +229,7 @@ mod tests {
229229
Checkpoint {
230230
index, parent_qc, window_head_height: index * 10,
231231
window_mb_hashes: vec![hh(index as u8)], state_root: hh(index as u8),
232-
beacon: hh(0), epoch_commitment: hh(0), reward_root: hh(0), registry_root: hh(0), timestamp: 0, proposer: c[li].clone(), proposer_sig: Vec::new(),
232+
beacon: hh(0), epoch_commitment: hh(0), reward_root: hh(0), registry_root: hh(0), total_supply: 0, timestamp: 0, proposer: c[li].clone(), proposer_sig: Vec::new(),
233233
}
234234
}
235235

core/qnet-state/src/state.rs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1186,6 +1186,15 @@ impl StateManager {
11861186
self.registered_nodes.insert(node_id.to_string(), wallet_address.to_string());
11871187
}
11881188

1189+
/// Cold-join: seed one chain-confirmed node_id->wallet binding into `registered_nodes` from the
1190+
/// snapshot-bound node_registry CF (the integration layer reads the CF and calls this per entry).
1191+
/// Same insert as mark_node_registered; named distinctly so the cold-join rehydrate intent is
1192+
/// explicit. The crate boundary forbids qnet-state reading the integration Storage type, so the
1193+
/// read lives integration-side and only the inserter is here.
1194+
pub fn seed_registered_node(&self, node_id: &str, wallet_address: &str) {
1195+
self.registered_nodes.insert(node_id.to_string(), wallet_address.to_string());
1196+
}
1197+
11891198
/// Get the wallet address for a registered node (None if not registered)
11901199
pub fn get_node_wallet(&self, node_id: &str) -> Option<String> {
11911200
self.registered_nodes.get(node_id).map(|v| v.clone())

core/qnet-state/src/transaction.rs

Lines changed: 63 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -353,16 +353,20 @@ pub enum TransactionType {
353353
#[serde(default)]
354354
api_endpoint: String,
355355
/// Phase-1 proof-of-burn carrier. The external Solana 1DEV burn is non-deterministic (live
356-
/// RPC) and cannot be re-checked in apply, so the burn fact is brought ON-CHAIN as a genesis
357-
/// quorum: `burn_attestors` carries ≥2f+1 distinct genesis Dilithium signatures over
358-
/// `burn_attestation_message(burn_tx, wallet_address, burn_amount, node_type)`, re-verified
359-
/// from these bytes at block validation (deterministic, snapshot-independent). Empty for
360-
/// genesis identities (registration_proof=="genesis") and when the gate is inactive.
356+
/// RPC) and cannot be re-checked in apply, so the burn fact is brought ON-CHAIN as a committee
357+
/// quorum: `burn_attestors` carries ≥2f+1 distinct committee Dilithium signatures over
358+
/// `burn_attestation_message(burn_tx, wallet_address, burn_amount, node_type, burn_cost)`,
359+
/// re-verified from these bytes at block validation (deterministic, snapshot-independent).
360+
/// Empty for genesis identities (registration_proof=="genesis") and when the gate is inactive.
361361
#[serde(default)]
362362
burn_tx: String,
363363
#[serde(default)]
364364
burn_amount: u64,
365-
/// (genesis_id, dilithium_sig) attestation quorum. Verified, never trusted blindly.
365+
/// Required Phase-1 cost the committee attested (whole 1DEV). Carried so the verifier rebuilds
366+
/// the exact signed message + asserts burn_amount >= burn_cost with NO Solana re-read.
367+
#[serde(default)]
368+
burn_cost: u64,
369+
/// (committee_id, dilithium_sig) attestation quorum. Verified, never trusted blindly.
366370
#[serde(default)]
367371
burn_attestors: Vec<(String, String)>,
368372
},
@@ -876,17 +880,37 @@ impl Transaction {
876880
/// Consistent with `compute_gas_used() == 0` for every variant listed
877881
/// below (except NodeActivation which uses `gas_limits::NODE_ACTIVATION`
878882
/// for metering purposes but is still a system TX).
879-
/// Canonical message a genesis node signs to attest a Phase-1 Solana 1DEV burn backing a node
880-
/// registration. FIXED format (pipe-joined, node_type as a stable integer) so all genesis sign
883+
/// Canonical message a committee member signs to attest a Phase-1 Solana 1DEV burn backing a node
884+
/// registration. FIXED format (pipe-joined, node_type as a stable integer) so all signers produce
881885
/// identical bytes and every validator recomputes the identical message at block validation —
882-
/// no float / locale / map-iteration input. Binds the burn tx, beneficiary wallet, amount and
883-
/// node type, so a quorum signature is valid for exactly one (burn, wallet, amount, type) tuple.
884-
pub fn burn_attestation_message(burn_tx: &str, wallet: &str, amount: u64, node_type: &NodeType) -> String {
886+
/// no float / locale / map-iteration input. Binds the burn tx, beneficiary wallet, amount, node
887+
/// type AND the required Phase-1 cost, so a quorum signature is valid for exactly one
888+
/// (burn, wallet, amount, type, cost) tuple. The `cost` lives INSIDE the 2f+1-signed message so
889+
/// every validator agrees on it by signature-verification, never by re-reading Solana.
890+
pub fn burn_attestation_message(burn_tx: &str, wallet: &str, amount: u64, node_type: &NodeType, cost: u64) -> String {
885891
let nt: u8 = match node_type {
886892
NodeType::Super => 0,
887893
NodeType::Light => 1,
888894
};
889-
format!("burn_attest:{}:{}:{}:{}", burn_tx, wallet, amount, nt)
895+
format!("burn_attest:{}:{}:{}:{}:{}", burn_tx, wallet, amount, nt, cost)
896+
}
897+
898+
/// Deterministic Phase-1 super/light activation cost in whole 1DEV, computed with INTEGER math so
899+
/// every node agrees (the f64 burn_percentage diverges across nodes). Mirrors the off-chain
900+
/// formula `max(1500 - 150*floor(burn_pct/10), 300)`: base 1500, −150 per complete 10% of the
901+
/// 1DEV supply burned, floored at 300. Universal Light=Super in Phase 1.
902+
/// `burn_pct_tenths` = burn% to one decimal; `tiers` = each complete 10% (capped at 8 → floor 300).
903+
/// Bucketing to 10% means members reading Solana at slightly different times still agree on the
904+
/// cost except exactly at a 10% boundary (there the registration just retries — a liveness hiccup,
905+
/// not a fork).
906+
pub fn phase1_activation_cost(total_burned: u64, current_supply: u64) -> u64 {
907+
// burn% = burned / ORIGINAL supply; original = burned + current(remaining). Denominator is their
908+
// SUM, not the remaining alone (else 50% burned would read as 100%). current_supply is the live
909+
// Solana getTokenSupply (remaining); the sum reconstructs the original cap.
910+
let original = total_burned.saturating_add(current_supply);
911+
let burn_pct_tenths = if original == 0 { 0 } else { total_burned * 1000 / original };
912+
let tiers = burn_pct_tenths / 100; // each complete 10%
913+
1500u64.saturating_sub(150 * tiers.min(8)).max(300)
890914
}
891915

892916
pub fn is_system_tx(&self) -> bool {
@@ -3444,20 +3468,35 @@ mod tests_v34_heartbeat {
34443468
assert_eq!(accts.get("super_y").unwrap().heartbeat_slots.count_ones(), 9);
34453469
}
34463470

3447-
// Burn-attestation canonical message: FIXED, deterministic, type-distinct — every genesis signs
3448-
// identical bytes and every validator recomputes the identical message, so the quorum verdict is
3449-
// byte-identical network-wide. Drift here would split the genesis signatures ⇒ no quorum forms.
3471+
// Burn-attestation canonical message: FIXED, deterministic, type-distinct — every committee member
3472+
// signs identical bytes and every validator recomputes the identical message, so the quorum verdict
3473+
// is byte-identical network-wide. Drift here would split the signatures ⇒ no quorum forms.
34503474
#[test]
34513475
fn burn_attestation_message_is_canonical_and_type_distinct() {
3452-
let m = Transaction::burn_attestation_message("solSig", "walletA", 1500, &NodeType::Super);
3453-
assert_eq!(m, Transaction::burn_attestation_message("solSig", "walletA", 1500, &NodeType::Super), "deterministic");
3454-
assert_eq!(m, "burn_attest:solSig:walletA:1500:0", "fixed format, Super=0");
3455-
// Every bound field (incl. node_type) changes the signed message.
3456-
assert_ne!(m, Transaction::burn_attestation_message("solSig", "walletA", 1500, &NodeType::Light));
3457-
assert_ne!(m, Transaction::burn_attestation_message("solSig", "walletA", 1501, &NodeType::Super));
3458-
assert_ne!(m, Transaction::burn_attestation_message("solSig", "walletB", 1500, &NodeType::Super));
3459-
assert_ne!(m, Transaction::burn_attestation_message("solSig2", "walletA", 1500, &NodeType::Super));
3460-
assert_eq!(Transaction::burn_attestation_message("x", "y", 0, &NodeType::Light), "burn_attest:x:y:0:1", "Light=1");
3476+
let m = Transaction::burn_attestation_message("solSig", "walletA", 1500, &NodeType::Super, 1500);
3477+
assert_eq!(m, Transaction::burn_attestation_message("solSig", "walletA", 1500, &NodeType::Super, 1500), "deterministic");
3478+
assert_eq!(m, "burn_attest:solSig:walletA:1500:0:1500", "fixed format, Super=0, cost suffix");
3479+
// Every bound field (incl. node_type and cost) changes the signed message.
3480+
assert_ne!(m, Transaction::burn_attestation_message("solSig", "walletA", 1500, &NodeType::Light, 1500));
3481+
assert_ne!(m, Transaction::burn_attestation_message("solSig", "walletA", 1501, &NodeType::Super, 1500));
3482+
assert_ne!(m, Transaction::burn_attestation_message("solSig", "walletB", 1500, &NodeType::Super, 1500));
3483+
assert_ne!(m, Transaction::burn_attestation_message("solSig2", "walletA", 1500, &NodeType::Super, 1500));
3484+
assert_ne!(m, Transaction::burn_attestation_message("solSig", "walletA", 1500, &NodeType::Super, 1350), "cost is bound");
3485+
assert_eq!(Transaction::burn_attestation_message("x", "y", 0, &NodeType::Light, 300), "burn_attest:x:y:0:1:300", "Light=1");
3486+
}
3487+
3488+
// Phase-1 cost formula: integer-deterministic, matching max(1500 - 150*floor(burn_pct/10), 300).
3489+
#[test]
3490+
fn phase1_activation_cost_tiers() {
3491+
// Args are (total_burned, current_remaining) as Solana reports them; the fn reconstructs the
3492+
// original cap = burned + remaining. Original 1DEV cap = 1B.
3493+
let orig = 1_000_000_000u64;
3494+
assert_eq!(Transaction::phase1_activation_cost(0, orig), 1500, "0% burned ⇒ base 1500");
3495+
assert_eq!(Transaction::phase1_activation_cost(orig / 10, orig - orig / 10), 1350, "10% ⇒ −150");
3496+
assert_eq!(Transaction::phase1_activation_cost(orig / 2, orig - orig / 2), 750, "50% ⇒ −750");
3497+
assert_eq!(Transaction::phase1_activation_cost(orig * 8 / 10, orig - orig * 8 / 10), 300, "80% ⇒ floor 300");
3498+
assert_eq!(Transaction::phase1_activation_cost(orig * 95 / 100, orig - orig * 95 / 100), 300, "95% ⇒ floored 300");
3499+
assert_eq!(Transaction::phase1_activation_cost(0, 0), 1500, "zero original ⇒ base (no div-by-zero)");
34613500
}
34623501

34633502
// Structural validation (the pure part; anchor/sig are checked at block validation).

development/qnet-integration/src/consensus_v2_driver.rs

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -122,7 +122,7 @@ impl ConsensusDriver {
122122
&mut self, window: u64, mb_hashes: Vec<Hash>,
123123
state_root: Hash, beacon: Hash, head_ts: u64,
124124
committee: Vec<NodeId>, eligible_producers: Vec<u8>, banned: Vec<NodeId>, reward_root: Hash,
125-
registry_root: Hash,
125+
registry_root: Hash, total_supply: u64,
126126
) -> Vec<Effect> {
127127
self.set_committee(committee.clone());
128128
let round = self.eng.current_index;
@@ -140,7 +140,7 @@ impl ConsensusDriver {
140140
let head_height = window.saturating_mul(self.cp_interval);
141141
let cp = Checkpoint {
142142
index: round, parent_qc: self.eng.high_qc.clone(), window_head_height: head_height,
143-
window_mb_hashes: mb_hashes, state_root, beacon, epoch_commitment: epoch_c, reward_root, registry_root, timestamp: head_ts,
143+
window_mb_hashes: mb_hashes, state_root, beacon, epoch_commitment: epoch_c, reward_root, registry_root, total_supply, timestamp: head_ts,
144144
proposer: self.node_id.clone(), proposer_sig: Vec::new(),
145145
};
146146
self.last_proposed_round = round;
@@ -313,7 +313,7 @@ mod tests {
313313
for index in 1..=8u64 {
314314
let mut seed = Vec::new();
315315
for k in 0..nodes.len() {
316-
let effs = nodes[k].d.build_proposal(index, vec![[index as u8; 32]], [index as u8; 32], [0u8; 32], index * 1000, c.clone(), Vec::new(), Vec::new(), [0u8; 32], [0u8; 32]);
316+
let effs = nodes[k].d.build_proposal(index, vec![[index as u8; 32]], [index as u8; 32], [0u8; 32], index * 1000, c.clone(), Vec::new(), Vec::new(), [0u8; 32], [0u8; 32], 0);
317317
for e in effs { seed.extend(exec(&mut nodes[k], e)); }
318318
}
319319
deliver(&mut nodes, &c, seed);
@@ -341,7 +341,7 @@ mod tests {
341341
for index in 1..=6u64 {
342342
let mut seed = Vec::new();
343343
for k in 0..nodes.len() {
344-
let effs = nodes[k].d.build_proposal(index, vec![[index as u8; 32]], [index as u8; 32], [0u8; 32], index * 1000, c.clone(), Vec::new(), Vec::new(), [0u8; 32], [0u8; 32]);
344+
let effs = nodes[k].d.build_proposal(index, vec![[index as u8; 32]], [index as u8; 32], [0u8; 32], index * 1000, c.clone(), Vec::new(), Vec::new(), [0u8; 32], [0u8; 32], 0);
345345
for e in effs { seed.extend(exec(&mut nodes[k], e)); }
346346
}
347347
deliver(&mut nodes, &c, seed);
@@ -360,7 +360,7 @@ mod tests {
360360
let c: Vec<NodeId> = (0..4).map(|i| format!("n{}", i)).collect();
361361
let cp = Checkpoint {
362362
index: 1, parent_qc: None, window_head_height: 10, window_mb_hashes: vec![[1u8; 32]],
363-
state_root: [1u8; 32], beacon: [0u8; 32], epoch_commitment: [0u8; 32], reward_root: [0u8; 32], registry_root: [0u8; 32], timestamp: 0, proposer: "n1".into(), proposer_sig: vec![9, 9],
363+
state_root: [1u8; 32], beacon: [0u8; 32], epoch_commitment: [0u8; 32], reward_root: [0u8; 32], registry_root: [0u8; 32], total_supply: 0, timestamp: 0, proposer: "n1".into(), proposer_sig: vec![9, 9],
364364
};
365365
// forged proposer_sig fails node verify ⇒ never reaches the driver
366366
assert!(!verify_msg(&c, &ConsensusMsg::Proposal(cp)));
@@ -383,7 +383,7 @@ mod tests {
383383
fn step(nodes: &mut Vec<Node>, c: &[NodeId], w: u64) {
384384
let mut seed = Vec::new();
385385
for k in 0..nodes.len() {
386-
let effs = nodes[k].d.build_proposal(w, vec![[w as u8; 32]], [w as u8; 32], [0u8; 32], w * 1000, c.to_vec(), Vec::new(), Vec::new(), [0u8; 32], [0u8; 32]);
386+
let effs = nodes[k].d.build_proposal(w, vec![[w as u8; 32]], [w as u8; 32], [0u8; 32], w * 1000, c.to_vec(), Vec::new(), Vec::new(), [0u8; 32], [0u8; 32], 0);
387387
for e in effs { seed.extend(exec(&mut nodes[k], e)); }
388388
}
389389
deliver(nodes, c, seed);
@@ -427,7 +427,7 @@ mod tests {
427427
let cp = Checkpoint {
428428
index: i, parent_qc: prev_qc.clone(), window_head_height: i * 90,
429429
window_mb_hashes: vec![[i as u8; 32]], state_root: [i as u8; 32],
430-
beacon: [0u8; 32], epoch_commitment: [0u8; 32], reward_root: [0u8; 32], registry_root: [0u8; 32], timestamp: 0,
430+
beacon: [0u8; 32], epoch_commitment: [0u8; 32], reward_root: [0u8; 32], registry_root: [0u8; 32], total_supply: 0, timestamp: 0,
431431
proposer: c[leader_index(i, &parent_hash, c.len())].clone(), proposer_sig: Vec::new(),
432432
};
433433
let signers: Vec<NodeId> = c.iter().take(3).cloned().collect();

0 commit comments

Comments
 (0)