Skip to content

Commit 2802963

Browse files
AIQnetLabclaude
andcommitted
registry_root: scalable LtHash + onboarding consensus fixes
registry_root rewritten as an incremental LtHash multiset hash (new registry_lthash.rs): O(1) maintain + O(1) per-checkpoint-head seal read, scaling to millions of light nodes. From-scratch fallback (compute_lt_state); one rebuild scan at boot/snapshot/reorg recomputes lt_state, prunes orphan roster keys, and refreshes seals. Binds super+light; snapshot recompute-reject closes the forgeable-snapshot Sybil/fork vector. registry_root carried in the QC macroblock Checkpoint, enforced in content_ok (gate=0). Onboarding correctness (producer-inline vs apply parity under gate=0): - producer mirrors apply first-match-wins: skip Phase2 NodeActivation registry rows (closes a registry_root divergence -> finality halt) - light eligibility bitmap indexed via one shared writer on both apply paths (no reward_root divergence at the emission boundary) - reorg orphan prune: drop reg_height>target roster keys so the unbounded reward rosters match a from-genesis node - NodeActivation proof-of-burn gate at verify + producer pre-filter: an activation must be backed by a burn-attested registration (no free identity) Also lands prior uncommitted work: cbw fork-safety (derived reg_height-bounded index), 2f+1 committee burn-attestation, genesis vrf_pk seeding, light on-chain burn onboarding, finality/repair freeze fixes, reward-roster apply-time indices. 184/184 lib tests green; cargo check clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent e2d0168 commit 2802963

17 files changed

Lines changed: 2732 additions & 323 deletions

File tree

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -673,3 +673,6 @@ transactions_export.csv
673673
infrastructure/nginx/ssl/*.pem
674674
infrastructure/nginx/ssl/*.key
675675
infrastructure/nginx/ssl/*.crt
676+
677+
# overnight audit scaffolding + findings (local, do not track)
678+
audit-overnight/

applications/qnet-mobile/src/components/WalletManager.js

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5509,7 +5509,7 @@ export class WalletManager {
55095509
// v6.0: Create a NodeRegistration TX client-side and submit it to the current producer.
55105510
// Called after /api/v1/light-node/register returns registration_proof.
55115511
// Signing message: "client_node_reg:{node_id}:{wallet_address}:{registration_proof}:{timestamp}"
5512-
async createAndSubmitNodeRegistrationTx(nodeId, walletAddress, registrationProof, password, dilithiumKeys) {
5512+
async createAndSubmitNodeRegistrationTx(nodeId, walletAddress, registrationProof, password, dilithiumKeys, burnTxHash, burnAmount, burnWallet) {
55135513
const walletData = await this.loadWallet(password);
55145514
if (!walletData || !walletData.secretKey) {
55155515
throw new Error('Cannot sign NodeRegistration TX: wallet not loaded');
@@ -5542,6 +5542,14 @@ export class WalletManager {
55425542
public_key: publicKeyHex,
55435543
};
55445544

5545+
// Option A: carry the Solana 1DEV burn so the server can build a burn-attested ON-CHAIN Light
5546+
// registration (without it the TX has an empty burn and is hard-rejected at the gate). The burn is
5547+
// already cryptographically committed by registration_proof = blake3(burn:node_id:wallet)[..32],
5548+
// so the server binds it by recomputing the proof — no extra signed field is needed.
5549+
if (burnTxHash) payload.burn_tx_hash = burnTxHash;
5550+
if (burnAmount) payload.burn_amount = burnAmount;
5551+
if (burnWallet) payload.burn_wallet = burnWallet;
5552+
55455553
// Optionally add Dilithium3 signature for post-quantum security
55465554
if (dilithiumKeys) {
55475555
try {
@@ -5826,7 +5834,10 @@ export class WalletManager {
58265834
walletAddress,
58275835
registrationResult.registration_proof,
58285836
password,
5829-
dilithiumKeys
5837+
dilithiumKeys,
5838+
burnTxHash, // Option A: server embeds the burn + committee attestation into the on-chain TX
5839+
burnAmount,
5840+
burnWallet
58305841
);
58315842
console.log('[Registration] NodeRegistration TX submitted:', txResult.tx_hash);
58325843
if (txResult.tx_hash) {

core/qnet-consensus/src/checkpoint_bft.rs

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -227,6 +227,12 @@ pub struct Checkpoint {
227227
/// QC-signed ⇒ 2f+1 certify it ⇒ nodes adopt this root for claims, never a single
228228
/// producer's unverified value (no Byzantine/lag reward divergence).
229229
pub reward_root: Hash,
230+
/// Deterministic digest of the chain-confirmed Super/genesis registry identity
231+
/// (node_id, wallet, reg_height, burn, sha3(vrf_pk)) as of the window head. QC-signed ⇒ 2f+1
232+
/// certify the registry, so a node joining via an UNTRUSTED snapshot verifies the restored
233+
/// node_registry — the source of cbw and attestor VRF keys — against this committed root,
234+
/// closing the forgeable-snapshot Sybil/fork vector.
235+
pub registry_root: Hash,
230236
/// Proposer's wall-clock for this window (the head microblock's timestamp).
231237
/// In the QC-signed hash ⇒ agreed by the committee ⇒ every node seals an
232238
/// identical MacroBlock from the checkpoint (no producer dependency, no fork).
@@ -251,6 +257,7 @@ impl Checkpoint {
251257
h.update(self.beacon);
252258
h.update(self.epoch_commitment);
253259
h.update(self.reward_root);
260+
h.update(self.registry_root);
254261
h.update(self.timestamp.to_le_bytes());
255262
h.update(self.proposer.as_bytes());
256263
h.finalize().into()
@@ -420,7 +427,7 @@ mod tests {
420427
let mut c = Checkpoint {
421428
index: 1, parent_qc: None, window_head_height: 90,
422429
window_mb_hashes: vec![h(1), h(2)], state_root: h(3),
423-
beacon: h(4), epoch_commitment: h(0), reward_root: h(0), timestamp: 0, proposer: "n1".into(), proposer_sig: vec![1,2,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],
424431
};
425432
let x = c.hash();
426433
c.proposer_sig = vec![9, 9, 9]; // sig change must NOT change hash
@@ -602,7 +609,7 @@ mod tests {
602609
let child = Checkpoint {
603610
index: 5, parent_qc: Some(parent_qc), window_head_height: 450,
604611
window_mb_hashes: vec![h(1)], state_root: h(2), beacon: h(3),
605-
epoch_commitment: h(0), reward_root: h(0), timestamp: 0, proposer: "n0".into(), proposer_sig: vec![],
612+
epoch_commitment: h(0), reward_root: h(0), registry_root: h(0), timestamp: 0, proposer: "n0".into(), proposer_sig: vec![],
606613
};
607614
let child_qc = mk_qc(&committee, child.hash(), 5, 3);
608615
assert_eq!(commits_parent(&child, &child_qc), Some(4)); // C4 final
@@ -618,7 +625,7 @@ mod tests {
618625
let c = Checkpoint {
619626
index: 7, parent_qc: Some(qc.clone()), window_head_height: 630,
620627
window_mb_hashes: vec![h(1), h(2)], state_root: h(3), beacon: h(4),
621-
epoch_commitment: h(0), reward_root: h(0), timestamp: 0, proposer: "n1".into(), proposer_sig: vec![1,2,3],
628+
epoch_commitment: h(0), reward_root: h(0), registry_root: h(0), timestamp: 0, proposer: "n1".into(), proposer_sig: vec![1,2,3],
622629
};
623630
let bytes = bincode::serialize(&c).unwrap();
624631
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), 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), timestamp: 0, proposer: c[li].clone(), proposer_sig: Vec::new(),
233233
}
234234
}
235235

core/qnet-consensus/src/consensus_crypto.rs

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2094,6 +2094,46 @@ async fn verify_with_real_dilithium(
20942094
}
20952095
}
20962096

2097+
/// Verify a consensus Dilithium3 signature against an EXPLICIT expected public key, bypassing the
2098+
/// in-RAM CONSENSUS_PK_REGISTRY identity binding. Burn-attestation validation MUST be a pure
2099+
/// function of on-chain state: the registry is RAM-resident + idle-evicted (non-deterministic across
2100+
/// nodes → fork) and its Tier-3 path TOFV-accepts a first-seen PK (forge surface for non-genesis).
2101+
/// Here the PK embedded in the signature MUST equal `expected_pk` (the caller's on-chain key:
2102+
/// load_vrf_public_key, or a pinned genesis anchor); only then is the ML-DSA-65 math run. Same
2103+
/// signature wire format as verify_with_real_dilithium; identity binding is the explicit PK match.
2104+
pub async fn verify_consensus_signature_bound(
2105+
node_id: &str, message: &str, signature: &str, expected_pk: &[u8],
2106+
) -> bool {
2107+
use pqcrypto_mldsa::mldsa65 as dilithium3;
2108+
if !signature.starts_with("dilithium_sig_") { return false; }
2109+
let part = &signature["dilithium_sig_".len()..];
2110+
let sep = match part.rfind('_') { Some(p) => p, None => return false };
2111+
if &part[..sep] != node_id { return false; } // sig carries its claimed id; must match
2112+
let bytes = match general_purpose::STANDARD.decode(&part[sep + 1..]) { Ok(b) => b, Err(_) => return false };
2113+
if bytes.len() < 8 { return false; }
2114+
// Combined format: [sig_len(4)] + [SignedMessage] + [pk_len(4)] + [pk(1952)] (same as verify path).
2115+
let signed_len = u32::from_le_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]) as usize;
2116+
if signed_len <= 3309 || 4 + signed_len >= bytes.len() { return false; }
2117+
let pk_len_start = 4 + signed_len;
2118+
if pk_len_start + 4 > bytes.len() { return false; }
2119+
let pk_len = u32::from_le_bytes([
2120+
bytes[pk_len_start], bytes[pk_len_start + 1], bytes[pk_len_start + 2], bytes[pk_len_start + 3],
2121+
]) as usize;
2122+
let pk_start = pk_len_start + 4;
2123+
if pk_len != dilithium3::public_key_bytes() || pk_start + pk_len != bytes.len() { return false; }
2124+
let signed_message_bytes = &bytes[4..4 + signed_len];
2125+
let public_key_bytes = &bytes[pk_start..pk_start + pk_len];
2126+
// On-chain identity binding: the embedded PK MUST be the node's registered key. Deterministic
2127+
// (expected_pk comes from finalized state) and forge-proof (wrong PK rejected before open).
2128+
if public_key_bytes != expected_pk { return false; }
2129+
let public_key = match dilithium3::PublicKey::from_bytes(public_key_bytes) { Ok(p) => p, Err(_) => return false };
2130+
let signed_message = match dilithium3::SignedMessage::from_bytes(signed_message_bytes) { Ok(s) => s, Err(_) => return false };
2131+
match dilithium3::open(&signed_message, &public_key) {
2132+
Ok(recovered) => ct_eq(recovered.as_slice(), message.as_bytes()),
2133+
Err(_) => false,
2134+
}
2135+
}
2136+
20972137
/// Constant-time byte slice comparison -- prevents timing side-channel attacks.
20982138
/// Returns true only if slices are equal in length and content.
20992139
/// FIX L-C2ct: Also constant-time for length to prevent length-based timing leaks.

core/qnet-state/src/feature_gates.rs

Lines changed: 45 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,36 @@
1111
//! 3. deploy the binary to all nodes BEFORE `activation_height`.
1212
//! Genesis-active rules need no entry — the default is active.
1313
14-
/// (feature id, activation height). Empty on this chain — all current rules are genesis-active.
15-
/// Heights are hardcoded in the binary, so every node agrees without on-chain governance.
16-
const ACTIVATIONS: &[(&str, u64)] = &[];
14+
/// Coordinated activation height for the burn-attestation rule (`burn_attestation_required`):
15+
/// at/after this height a NON-genesis NodeRegistration must carry a 2f+1 genesis burn-attestation
16+
/// quorum (verify_burn_attestation_quorum); genesis identities are always exempt (is_legacy_genesis_node).
17+
/// 0 = active from genesis — correct for a fresh genesis: no Sybil-free window, only the 5 genesis
18+
/// bypass. Raise to a future height ONLY for a rolling upgrade of a live network, or to defer
19+
/// activation until the burn flow (live Solana burn + reachable genesis RPC) is ready for non-genesis
20+
/// onboarding; genesis bootstrap never needs it.
21+
pub const BURN_ATTESTATION_GATE_HEIGHT: u64 = 0;
22+
23+
/// Coordinated activation height for the registry-root rule (`registry_root_required`): at/after this
24+
/// height a checkpoint's `registry_root` (deterministic Super/genesis burn-registry digest over
25+
/// {node_id,wallet,reg_height,burn}) MUST match the validator's independent recompute (consensus
26+
/// content_ok) AND a snapshot's restored node_registry MUST match the anchor macroblock's committed
27+
/// registry_root (snapshot binding). This closes the forgeable-snapshot vector for the burn→wallet
28+
/// binding (an untrusted snapshot server rebinding a burn to its wallet in a transported node_registry).
29+
/// 0 = ACTIVE FROM GENESIS, symmetric with BURN_ATTESTATION_GATE_HEIGHT — full production from the first
30+
/// block, no staging window. Safe because registry_root is a pure function of the single chain-apply
31+
/// writer (save_node_registration_inner: node_id/wallet/reg_height/burn are byte-identical on every
32+
/// node); it deliberately does NOT hash vrf_pk (that key is not co-resident with the srtr_ row, so
33+
/// hashing it would split the digest per node). Binding VRF-key integrity into the digest is a separate
34+
/// follow-up (make vrf_pk_ co-resident with srtr_ first), not a reason to delay the burn-binding defence.
35+
pub const REGISTRY_ROOT_GATE_HEIGHT: u64 = 0;
36+
37+
/// (feature id, activation height). Heights are hardcoded in the binary, so every node agrees
38+
/// without on-chain governance. Genesis-active rules need no entry (the default is active);
39+
/// only rules that must stay dormant until a coordinated height are listed.
40+
const ACTIVATIONS: &[(&str, u64)] = &[
41+
("burn_attestation_required", BURN_ATTESTATION_GATE_HEIGHT),
42+
("registry_root_required", REGISTRY_ROOT_GATE_HEIGHT),
43+
];
1744

1845
/// Core gate: active iff `feature` is unlisted (genesis-active default) or `height` has reached
1946
/// its scheduled activation. Pure ⇒ identical on every node at the same height.
@@ -49,7 +76,21 @@ mod tests {
4976

5077
#[test]
5178
fn production_registry_all_active() {
52-
// No scheduled features on this chain ⇒ every gate active from height 0.
79+
// Unlisted rules are genesis-active (default) from height 0.
5380
assert!(super::is_active("any_current_rule", 0));
5481
}
82+
83+
#[test]
84+
fn burn_attestation_active_from_genesis() {
85+
// gate=0 ⇒ active from block 0: a non-genesis NodeRegistration needs a 2f+1 genesis
86+
// burn-attestation immediately. Genesis identities bypass at the call site
87+
// (is_legacy_genesis_node), not here. (If re-gated to a future height for a rolling
88+
// upgrade, is_active would be false below it — covered by gate_switches_at_activation_height.)
89+
assert_eq!(super::BURN_ATTESTATION_GATE_HEIGHT, 0, "active-from-genesis on fresh genesis");
90+
assert!(super::is_active("burn_attestation_required", 0), "active from genesis");
91+
assert!(super::is_active("burn_attestation_required", 1), "active just after genesis");
92+
// No upper bound — the rule stays active at every block height (this is a HEIGHT, not any
93+
// registration cap; the network has no limit on the number of registrations).
94+
assert!(super::is_active("burn_attestation_required", u64::MAX), "active at the highest possible height");
95+
}
5596
}

core/qnet-state/src/transaction.rs

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -352,6 +352,19 @@ pub enum TransactionType {
352352
/// Light nodes: always empty (privacy protection - mobile apps!)
353353
#[serde(default)]
354354
api_endpoint: String,
355+
/// 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.
361+
#[serde(default)]
362+
burn_tx: String,
363+
#[serde(default)]
364+
burn_amount: u64,
365+
/// (genesis_id, dilithium_sig) attestation quorum. Verified, never trusted blindly.
366+
#[serde(default)]
367+
burn_attestors: Vec<(String, String)>,
355368
},
356369

357370
/// Create new account
@@ -863,6 +876,19 @@ impl Transaction {
863876
/// Consistent with `compute_gas_used() == 0` for every variant listed
864877
/// below (except NodeActivation which uses `gas_limits::NODE_ACTIVATION`
865878
/// 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
881+
/// 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 {
885+
let nt: u8 = match node_type {
886+
NodeType::Super => 0,
887+
NodeType::Light => 1,
888+
};
889+
format!("burn_attest:{}:{}:{}:{}", burn_tx, wallet, amount, nt)
890+
}
891+
866892
pub fn is_system_tx(&self) -> bool {
867893
matches!(
868894
&self.tx_type,
@@ -3418,6 +3444,22 @@ mod tests_v34_heartbeat {
34183444
assert_eq!(accts.get("super_y").unwrap().heartbeat_slots.count_ones(), 9);
34193445
}
34203446

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.
3450+
#[test]
3451+
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");
3461+
}
3462+
34213463
// Structural validation (the pure part; anchor/sig are checked at block validation).
34223464
#[test]
34233465
fn validate_structural_accepts_valid_rejects_malformed() {

0 commit comments

Comments
 (0)