commit 36cfbfc9a31ae66a025b32a6d6a83de09ec491c5
parent 9e17dfe4cff42c7b105e6b8cac8e60bcbd6ddd57
Author: Joris Hartog <jorishartog@hotmail.com>
Date: Wed, 19 Aug 2026 14:09:37 +0200
Harden burn bundle relay checks
Diffstat:
5 files changed, 395 insertions(+), 46 deletions(-)
diff --git a/ROADMAP.md b/ROADMAP.md
@@ -30,6 +30,29 @@ The current goal is to keep a small real testnet stable while increasing confide
- [ ] Upgrade and rollback instructions exist.
- [ ] Basic operational monitoring is available for height, tip hash, peers, last block age, finalizer mode, VDF rounds, mempool, and rejected blocks.
+## Pre-Reset Mainnet-Candidate Test Backlog
+
+These items are not protocol rules. They are the attack and reliability checks to finish or consciously defer before the planned devnet reset that should become the mainnet-candidate network.
+
+### Must Before Reset
+
+- [x] Burn bundle relay cannot import embedded burns before bundle metadata, membership, signature, fee ordering, and size are prechecked.
+- [x] Block validation with burn attestations remains independent of local mempool contents, including empty and conflicting mempools.
+- [x] Post-genesis transactions cannot spend with `genesis` input signatures.
+- [x] P2P envelope item limits reject batches only above their configured boundaries.
+- [ ] Stratum endpoint has explicit DoS limits: maximum line size, maximum jobs per session, idle timeout, and connection/session caps.
+- [ ] Fork and snapshot adversarial tests cover same-height leader-quality choice, taller valid forks inside finality, invalid late snapshot blocks, and pending transaction carry-forward after reorg.
+- [ ] Compact snapshot decoder has malformed-input tests for huge lengths, oversized varints, trailing bytes, truncated payloads, invalid tags, and random byte inputs without panics or excessive allocation.
+- [ ] Supply invariant tests cover mixed burns, fees, PoW mine actions, reorgs, no replay, and no double spend.
+
+### Should Before Mainnet
+
+- [ ] HTTP/auth abuse tests cover CSRF same-origin behavior, lockout/backoff behavior, forwarded-header spoofing from untrusted peers, and session expiry.
+- [ ] Multi-node in-memory simulation covers delayed gossip, withheld burn bundles, bundle equivocation, partitions, restarts, persistence reload, and convergence.
+- [ ] Long-running release-mode soak test runs with automatic burn/finalization, P2P sync, Stratum-disabled and Stratum-enabled nodes, and periodic node restarts.
+- [ ] Operator failure playbooks exist for stalled height, divergent tips, old snapshots, no burn committee signatures, recovery blocks, and corrupted local persistence.
+- [ ] Mainnet-candidate release rehearsal includes fresh genesis, published bootnodes, checksums, backup/restore instructions, and a no-reset stability window.
+
## Milestones
### M1: Testnet Hardening
diff --git a/src/adapters/p2p/line_codec.rs b/src/adapters/p2p/line_codec.rs
@@ -208,11 +208,18 @@ fn ensure_len(label: &str, len: usize, max: usize) -> Result<()> {
mod tests {
use crate::{
adapters::p2p::metrics::P2pMetricsCounters,
- app::GossipEnvelope,
- domain::{BurnBundle, OutPoint, Transaction, TxInput, TxOutput},
+ app::{BlockInventory, GossipEnvelope, TRANSACTION_BATCH_LIMIT},
+ domain::{
+ Block, BurnBundle, BurnBundleSection, ChainSnapshot, FinalizerMode, LaunchProfile,
+ OutPoint, Transaction, TxInput, TxOutput,
+ },
};
- use super::{parse_envelope, record_received_envelope_kind};
+ use super::{
+ MAX_BLOCK_BATCH, MAX_INVENTORY_ITEMS, MAX_OBJECT_REQUESTS, MAX_PEER_LIST,
+ MAX_SNAPSHOT_BLOCKS, parse_envelope, record_received_envelope_kind,
+ validate_envelope_limits,
+ };
fn burn(signature: &str) -> Transaction {
Transaction::Burn {
@@ -245,6 +252,35 @@ mod tests {
}
}
+ fn dummy_block(height: u64) -> Block {
+ Block {
+ height,
+ prev_hash: "0".repeat(64),
+ timestamp_ms: height,
+ miner: "0".repeat(64),
+ finalizer_mode: FinalizerMode::Ticket,
+ finalizer_rank: 0,
+ reward: 0,
+ vdf_rounds: 0,
+ vdf_output: "0:0".to_string(),
+ leader_proof: None,
+ burn_bundle_section: BurnBundleSection::default(),
+ transactions: Vec::new(),
+ hash: format!("{height:064x}"),
+ }
+ }
+
+ fn dummy_snapshot(blocks: usize) -> ChainSnapshot {
+ ChainSnapshot {
+ genesis_allocations: Default::default(),
+ vdf_rounds: 1,
+ launch_profile: LaunchProfile::default(),
+ blocks: (0..blocks)
+ .map(|height| dummy_block(height as u64))
+ .collect(),
+ }
+ }
+
#[test]
fn metrics_count_transaction_and_burn_bundle_batches() {
let metrics = P2pMetricsCounters::default();
@@ -281,4 +317,118 @@ mod tests {
assert_eq!(parse_envelope(&line).unwrap(), envelope);
}
+
+ #[test]
+ fn envelope_item_limits_reject_only_above_the_boundary() {
+ assert!(
+ validate_envelope_limits(&GossipEnvelope::BlockRangeRequest {
+ from_height: 1,
+ limit: MAX_BLOCK_BATCH
+ })
+ .is_ok()
+ );
+ assert!(
+ validate_envelope_limits(&GossipEnvelope::BlockRangeRequest {
+ from_height: 1,
+ limit: MAX_BLOCK_BATCH + 1
+ })
+ .is_err()
+ );
+ assert!(
+ validate_envelope_limits(&GossipEnvelope::BlockRequest {
+ hashes: vec!["0".repeat(64); MAX_OBJECT_REQUESTS]
+ })
+ .is_ok()
+ );
+ assert!(
+ validate_envelope_limits(&GossipEnvelope::BlockRequest {
+ hashes: vec!["0".repeat(64); MAX_OBJECT_REQUESTS + 1]
+ })
+ .is_err()
+ );
+ assert!(
+ validate_envelope_limits(&GossipEnvelope::Inventory {
+ blocks: vec![
+ BlockInventory {
+ height: 1,
+ hash: "0".repeat(64)
+ };
+ MAX_INVENTORY_ITEMS
+ ]
+ })
+ .is_ok()
+ );
+ assert!(
+ validate_envelope_limits(&GossipEnvelope::Inventory {
+ blocks: vec![
+ BlockInventory {
+ height: 1,
+ hash: "0".repeat(64)
+ };
+ MAX_INVENTORY_ITEMS + 1
+ ]
+ })
+ .is_err()
+ );
+ assert!(
+ validate_envelope_limits(&GossipEnvelope::Transactions {
+ transactions: vec![burn("a"); TRANSACTION_BATCH_LIMIT]
+ })
+ .is_ok()
+ );
+ assert!(
+ validate_envelope_limits(&GossipEnvelope::Transactions {
+ transactions: vec![burn("a"); TRANSACTION_BATCH_LIMIT + 1]
+ })
+ .is_err()
+ );
+ assert!(
+ validate_envelope_limits(&GossipEnvelope::BurnBundles {
+ bundles: vec![burn_bundle(1, "a"); TRANSACTION_BATCH_LIMIT]
+ })
+ .is_ok()
+ );
+ assert!(
+ validate_envelope_limits(&GossipEnvelope::BurnBundles {
+ bundles: vec![burn_bundle(1, "a"); TRANSACTION_BATCH_LIMIT + 1]
+ })
+ .is_err()
+ );
+ assert!(
+ validate_envelope_limits(&GossipEnvelope::Blocks {
+ blocks: vec![dummy_block(1); MAX_BLOCK_BATCH]
+ })
+ .is_ok()
+ );
+ assert!(
+ validate_envelope_limits(&GossipEnvelope::Blocks {
+ blocks: vec![dummy_block(1); MAX_BLOCK_BATCH + 1]
+ })
+ .is_err()
+ );
+ assert!(
+ validate_envelope_limits(&GossipEnvelope::ChainSnapshot(dummy_snapshot(
+ MAX_SNAPSHOT_BLOCKS
+ )))
+ .is_ok()
+ );
+ assert!(
+ validate_envelope_limits(&GossipEnvelope::ChainSnapshot(dummy_snapshot(
+ MAX_SNAPSHOT_BLOCKS + 1
+ )))
+ .is_err()
+ );
+ assert!(
+ validate_envelope_limits(&GossipEnvelope::PeerList {
+ peers: vec!["127.0.0.1:9444".to_string(); MAX_PEER_LIST]
+ })
+ .is_ok()
+ );
+ assert!(
+ validate_envelope_limits(&GossipEnvelope::PeerList {
+ peers: vec!["127.0.0.1:9444".to_string(); MAX_PEER_LIST + 1]
+ })
+ .is_err()
+ );
+ }
}
diff --git a/src/app/receive.rs b/src/app/receive.rs
@@ -32,6 +32,7 @@ impl NodeCore {
return Ok(());
}
let key = (bundle.height, bundle.slot);
+ self.ledger.precheck_next_block_burn_bundle(&bundle)?;
for burn in &bundle.burns {
self.receive_gossiped_transaction(burn.clone())?;
}
@@ -241,4 +242,48 @@ mod tests {
.any(|transaction| transaction.signature() == burn.signature())
);
}
+
+ #[test]
+ fn oversized_burn_bundle_does_not_import_embedded_burns() {
+ let alice = Wallet::from_seed("oversized-bundle-alice");
+ let bob = Wallet::from_seed("oversized-bundle-bob");
+ let wallets = [alice.clone(), bob.clone()];
+ let ledger = funded_ledger(&wallets);
+ let finalizer = ledger.expected_leader_for_next_block().unwrap();
+ let signer = wallets
+ .iter()
+ .find(|wallet| wallet.address() == finalizer)
+ .expect("test ledger should include selected finalizer")
+ .clone();
+ let burner = wallets
+ .iter()
+ .find(|wallet| wallet.address() != signer.address())
+ .expect("test ledger should include a non-finalizer")
+ .clone();
+ let mut signer_ledger = ledger.clone();
+ let mut burns = Vec::new();
+ let oversized_bundle = loop {
+ let burn = signer_ledger.build_burn(&burner, 1, 1).unwrap();
+ signer_ledger.submit_transaction(burn.clone()).unwrap();
+ burns.push(burn);
+ let bundle = signer_ledger.test_burn_bundle(&signer, burns.clone());
+ if bundle.serialized_size_bytes().unwrap() > 10_000 {
+ break bundle;
+ }
+ };
+ let first_burn_signature = oversized_bundle.burns[0].signature().to_string();
+ let mut receiver = NodeCore::from_ledger(signer, ledger, 0);
+
+ let error = receiver.receive_burn_bundle(oversized_bundle).unwrap_err();
+
+ assert!(error.to_string().contains("burn bundle exceeds max size"));
+ assert!(
+ receiver
+ .ledger()
+ .pending()
+ .iter()
+ .all(|transaction| transaction.signature() != first_burn_signature)
+ );
+ assert!(receiver.drain_outbox().is_empty());
+ }
}
diff --git a/src/domain/adversarial_tests.rs b/src/domain/adversarial_tests.rs
@@ -648,6 +648,52 @@ fn attested_burn_is_not_selected_again_as_normal_transaction() {
}
#[test]
+fn attested_burn_block_validates_independent_of_local_mempool() {
+ let mut harness = harness_for_percent(22, 25);
+ let leader = harness.next_rank(0);
+ let finalizer = harness.wallet(&leader.owner).clone();
+ harness.submit_anchor_burn(&finalizer);
+ let victim = harness
+ .honest
+ .iter()
+ .find(|wallet| wallet.address() != finalizer.address())
+ .unwrap()
+ .clone();
+ let attested_burn = harness.submit_fee_burn(&victim, 1, 1);
+ let bundle = finalizer.burn_bundle(BurnBundlePayload {
+ height: harness.ledger.height() + 1,
+ prev_hash: harness.ledger.tip_hash().to_string(),
+ slot: 0,
+ member: finalizer.address().to_string(),
+ burns: vec![attested_burn.clone()],
+ });
+ let block = harness.finish_ticket_block_from_pending(0, vec![bundle]);
+ let parent_snapshot = harness.ledger.snapshot();
+
+ let mut empty_mempool = Ledger::from_snapshot_at(parent_snapshot.clone(), NOW_MS).unwrap();
+ empty_mempool
+ .apply_block_at(block.clone(), NOW_MS.saturating_add(block.timestamp_ms))
+ .unwrap();
+
+ let mut conflicting_mempool = Ledger::from_snapshot_at(parent_snapshot, NOW_MS).unwrap();
+ let conflict = conflicting_mempool
+ .build_transfer(&victim, finalizer.address(), 1, 1)
+ .unwrap();
+ conflicting_mempool
+ .submit_transaction(conflict.clone())
+ .unwrap();
+ conflicting_mempool
+ .apply_block_at(block, NOW_MS.saturating_add(1))
+ .unwrap();
+ assert!(
+ conflicting_mempool
+ .pending()
+ .iter()
+ .all(|tx| tx.signature() != conflict.signature())
+ );
+}
+
+#[test]
fn required_burn_cannot_be_executed_twice_in_one_block() {
let mut harness = harness_for_percent(11, 25);
let leader = harness.next_rank(0);
@@ -774,6 +820,41 @@ fn zero_fee_public_burn_is_rejected() {
}
#[test]
+fn post_genesis_transactions_cannot_spend_with_genesis_input_signatures() {
+ let alice = Wallet::from_seed("post-genesis-signature-alice");
+ let bob = Wallet::from_seed("post-genesis-signature-bob");
+ let mut allocations = BTreeMap::new();
+ allocations.insert(alice.address().to_string(), 10 * MICRO_IUNA);
+ allocations.insert(bob.address().to_string(), 10 * MICRO_IUNA);
+ let mut ledger = Ledger::new_with_genesis_burns(
+ allocations,
+ vec![GenesisBurn::new(alice.address(), MICRO_IUNA)],
+ 1,
+ )
+ .unwrap();
+ let mut transaction = ledger.build_transfer(&alice, bob.address(), 1, 1).unwrap();
+ let Transaction::Transfer {
+ inputs, signature, ..
+ } = &mut transaction
+ else {
+ panic!("test builds a transfer");
+ };
+ for input in inputs {
+ input.signature = "genesis".to_string();
+ }
+ *signature = "0".repeat(128);
+
+ let error = ledger.submit_transaction(transaction).unwrap_err();
+
+ assert!(
+ error.to_string().contains("invalid input signature")
+ || error
+ .to_string()
+ .contains("transaction signature is invalid")
+ );
+}
+
+#[test]
fn invalid_committee_signature_is_rejected() {
let mut harness = harness_for_percent(13, 25);
harness.mature_lineages(1, 4);
diff --git a/src/domain/ledger_reveal.rs b/src/domain/ledger_reveal.rs
@@ -6,8 +6,8 @@ use super::ledger_ops::verify_address_signature;
use super::reveal::{burn_bundle_slot_mask, burn_committee_mask};
use super::{
Amount, BURN_COMMITTEE_SIZE, Block, BurnBundle, BurnBundlePayload, BurnBundleSection,
- BurnBundleSignature, FinalizerMode, Ledger, MAX_BURN_BUNDLE_BYTES, MaskedBurn, Transaction,
- Wallet,
+ BurnBundleSignature, BurnCommitteeMember, FinalizerMode, Ledger, MAX_BURN_BUNDLE_BYTES,
+ MaskedBurn, Transaction, Wallet,
};
impl Ledger {
@@ -87,6 +87,40 @@ impl Ledger {
self.validate_burn_bundles_for_block(expected_height, &expected_prev_hash, bundles)
}
+ pub(crate) fn precheck_next_block_burn_bundle(&self, bundle: &BurnBundle) -> Result<()> {
+ let expected_height = self.tip().height + 1;
+ let expected_prev_hash = self.tip().hash.clone();
+ let committee = self
+ .burn_committee_for_height(expected_height)
+ .into_iter()
+ .map(|member| (member.slot, member))
+ .collect::<BTreeMap<_, _>>();
+ self.precheck_burn_bundle_for_block(
+ expected_height,
+ &expected_prev_hash,
+ &committee,
+ bundle,
+ )
+ }
+
+ #[cfg(test)]
+ pub fn test_burn_bundle(&self, wallet: &Wallet, burns: Vec<Transaction>) -> BurnBundle {
+ let height = self.tip().height + 1;
+ let prev_hash = self.tip().hash.clone();
+ let member = self
+ .burn_committee_for_next_block()
+ .into_iter()
+ .find(|member| member.owner == wallet.address())
+ .expect("test wallet must be a burn committee member");
+ wallet.burn_bundle(BurnBundlePayload {
+ height,
+ prev_hash,
+ slot: member.slot,
+ member: wallet.address().to_string(),
+ burns,
+ })
+ }
+
pub(super) fn burn_bundle_section_from_bundles(
&self,
bundles: Vec<BurnBundle>,
@@ -262,59 +296,75 @@ impl Ledger {
.collect::<BTreeMap<_, _>>();
let mut seen_members = BTreeSet::new();
for bundle in &bundles {
- if bundle.height != expected_height {
- bail!("burn bundle height is invalid");
- }
- if bundle.prev_hash != expected_prev_hash {
- bail!("burn bundle parent hash is invalid");
- }
- if usize::from(bundle.slot) >= BURN_COMMITTEE_SIZE {
- bail!("burn bundle slot is invalid");
- }
if !seen_members.insert(bundle.member.clone()) {
bail!("duplicate burn bundle member");
}
- let member = committee
- .get(&bundle.slot)
- .context("burn bundle slot is not assigned")?;
- if bundle.member != member.owner {
- bail!("burn bundle member is not assigned to slot");
- }
- if bundle.serialized_size_bytes()? > MAX_BURN_BUNDLE_BYTES {
- bail!("burn bundle exceeds max size");
- }
- verify_address_signature(
- &bundle.member,
- &bundle.canonical_payload(),
- &bundle.signature,
- "burn bundle",
+ self.precheck_burn_bundle_for_block(
+ expected_height,
+ expected_prev_hash,
+ &committee,
+ bundle,
)?;
- let mut seen_bundle_burns = BTreeSet::new();
- let mut previous_key: Option<(Amount, String)> = None;
for burn in &bundle.burns {
- if !seen_bundle_burns.insert(burn.signature().to_string()) {
- bail!("duplicate burn in burn bundle");
- }
- if !burn.is_burn() {
- bail!("burn bundle contains a non-burn transaction");
- }
if matching_burn_by_signature(burn, &self.pending).is_none() {
bail!("burn bundle references a burn that is not in the mempool");
}
- self.validate_transaction_terms(burn)?;
- let key = (burn.fee(), burn.signature().to_string());
- if let Some((previous_fee, previous_signature)) = &previous_key {
- if key.0 > *previous_fee
- || key.0 == *previous_fee && key.1 < *previous_signature
- {
- bail!("burn bundle is not fee ordered");
- }
- }
- previous_key = Some(key);
}
}
Ok(bundles)
}
+
+ fn precheck_burn_bundle_for_block(
+ &self,
+ expected_height: u64,
+ expected_prev_hash: &str,
+ committee: &BTreeMap<u8, BurnCommitteeMember>,
+ bundle: &BurnBundle,
+ ) -> Result<()> {
+ if bundle.height != expected_height {
+ bail!("burn bundle height is invalid");
+ }
+ if bundle.prev_hash != expected_prev_hash {
+ bail!("burn bundle parent hash is invalid");
+ }
+ if usize::from(bundle.slot) >= BURN_COMMITTEE_SIZE {
+ bail!("burn bundle slot is invalid");
+ }
+ let member = committee
+ .get(&bundle.slot)
+ .context("burn bundle slot is not assigned")?;
+ if bundle.member != member.owner {
+ bail!("burn bundle member is not assigned to slot");
+ }
+ if bundle.serialized_size_bytes()? > MAX_BURN_BUNDLE_BYTES {
+ bail!("burn bundle exceeds max size");
+ }
+ verify_address_signature(
+ &bundle.member,
+ &bundle.canonical_payload(),
+ &bundle.signature,
+ "burn bundle",
+ )?;
+ let mut seen_bundle_burns = BTreeSet::new();
+ let mut previous_key: Option<(Amount, String)> = None;
+ for burn in &bundle.burns {
+ if !seen_bundle_burns.insert(burn.signature().to_string()) {
+ bail!("duplicate burn in burn bundle");
+ }
+ if !burn.is_burn() {
+ bail!("burn bundle contains a non-burn transaction");
+ }
+ self.validate_transaction_terms(burn)?;
+ let key = (burn.fee(), burn.signature().to_string());
+ if let Some((previous_fee, previous_signature)) = &previous_key {
+ if key.0 > *previous_fee || key.0 == *previous_fee && key.1 < *previous_signature {
+ bail!("burn bundle is not fee ordered");
+ }
+ }
+ previous_key = Some(key);
+ }
+ Ok(())
+ }
}
fn matching_burn_by_signature<'a>(