commit ee737d81f17431058921ef6f526c3e895bc8f168
parent dde7679e31e33e5c15f3708d561af99ca51f7d19
Author: Joris Hartog <jorishartog@hotmail.com>
Date: Wed, 22 Jul 2026 12:02:52 +0200
Require burn transaction before VDF mining
Diffstat:
3 files changed, 48 insertions(+), 15 deletions(-)
diff --git a/README.md b/README.md
@@ -15,7 +15,7 @@ The validated chain is persisted to `.mivora/chain.sqlite3` and resumes automati
Mining is automatic. There is no "mine block" button and no exact sleep. Each node can burn its configured amount once per chain height. Those burns become one-shot leader tickets for a future height after the launch profile's maturity delay. Only the selected ticket owner builds the next block, signs a leader proof, performs the VDF work, and gossips the finished block. The VDF is the clock.
-The plain command above creates the default zero-balance starter chain: genesis mints 1 coin and immediately burns it into the first leader ticket. That is enough to mine block 1 and earn the first reward. For a local demo with room to configure automatic burns, leave one extra coin after genesis and set the burn rate in the Configuration tab before block 1 is mined:
+The plain command above creates the default zero-balance starter chain: genesis mints 1 coin and immediately burns it into the first leader ticket. Because non-genesis blocks must include at least one burn, the default 0-coin burn rate will wait instead of starting VDF work. For a local demo with room to configure automatic burns, leave one extra coin after genesis and set the burn rate in the Configuration tab:
```sh
cargo run -- --start --genesis-amount 2 --http 127.0.0.1:8443 --p2p 127.0.0.1:9444
@@ -52,7 +52,7 @@ To start a small friendly network:
cargo run -- --start --genesis-amount 2 --p2p 0.0.0.0:9444 --http 127.0.0.1:8443
```
-2. Open the Configuration tab and set the starter burn rate before block 1 is mined.
+2. Open the Configuration tab and set the starter burn rate so block 1 has a burn.
3. Give friends your public `host:9444`.
4. Friends join your chain:
@@ -64,7 +64,7 @@ Friends who join after you start will adopt your genesis and current chain. With
The genesis block bootstraps the chain with a 1-coin burn from the starter wallet. Burns included in a block create one-shot tickets for a future height through a deterministic ticket lottery. The selected leader creates the next block content, signs a proof for the selected ticket, and runs a hash-chain VDF before gossiping the block.
-Every non-genesis block must consume the selected mature ticket. A block may contain zero burns, but then it does not create future tickets. The VDF seed is bound to the parent hash and child height; the block hash separately commits to the miner, timestamp, reward, rounds, previous hash, leader proof, VDF output, and transactions.
+Every non-genesis block must consume the selected mature ticket and include at least one burn transaction. The VDF seed is bound to the parent hash and child height; the block hash separately commits to the miner, timestamp, reward, rounds, previous hash, leader proof, VDF output, and transactions.
The protocol targets 60-second blocks by retargeting the expected VDF rounds after each block. It uses a rolling average of recent block intervals and only moves the next round count by about 10% per block, so short bursts do not make the delay swing wildly. Every node derives the same next-round count from the validated chain.
diff --git a/src/domain.rs b/src/domain.rs
@@ -1005,6 +1005,7 @@ impl Ledger {
.into_iter()
.take(self.launch_profile.max_block_transactions)
.collect::<Vec<_>>();
+ ensure_block_has_burn(&transactions)?;
let tip = self.tip();
let prev_hash = tip.hash.clone();
@@ -1120,6 +1121,7 @@ impl Ledger {
if block.transactions.len() > self.launch_profile.max_block_transactions {
bail!("block has too many transactions");
}
+ ensure_block_has_burn(&block.transactions)?;
let Some(leader) = self.expected_leader_for_next_block() else {
bail!("no selected leader for block {}", block.height);
};
@@ -1296,6 +1298,13 @@ fn consume_leader_ticket(block: &Block, tickets: &mut Vec<BurnTicket>) -> Result
Ok(())
}
+fn ensure_block_has_burn(transactions: &[Transaction]) -> Result<()> {
+ if !transactions.iter().any(Transaction::is_burn) {
+ bail!("block must include at least one burn transaction");
+ }
+ Ok(())
+}
+
fn verify_leader_proof(block: &Block, tickets: &[BurnTicket]) -> Result<()> {
let Some(proof) = &block.leader_proof else {
bail!("block is missing leader proof");
diff --git a/tests/coin.rs b/tests/coin.rs
@@ -98,16 +98,21 @@ fn genesis_burn_starts_chain_with_zero_balance_and_first_leader() {
}
#[test]
-fn starter_node_mines_first_reward_from_genesis_ticket() {
+fn starter_node_waits_for_a_burn_before_vdf_work() {
let alice = Wallet::from_seed("alice");
let mut node = starter_node(alice.clone());
let outcome = node.automatic_mine_once(1);
assert!(outcome.burned.is_none());
- assert_eq!(outcome.block.as_ref().map(|block| block.height), Some(1));
- assert!(outcome.skipped_reason.is_none());
- assert_eq!(node.ledger().status().height, 1);
- assert_eq!(node.ledger().balance_of(alice.address()), BLOCK_REWARD);
+ assert!(outcome.block.is_none());
+ assert!(
+ outcome
+ .skipped_reason
+ .as_deref()
+ .is_some_and(|reason| reason.contains("at least one burn"))
+ );
+ assert_eq!(node.ledger().status().height, 0);
+ assert_eq!(node.ledger().balance_of(alice.address()), 0);
}
#[test]
@@ -272,19 +277,33 @@ fn block_without_mature_ticket_cannot_be_mined() {
}
#[test]
-fn leader_block_can_create_no_future_tickets() {
+fn vdf_work_requires_at_least_one_pending_burn() {
+ let alice = Wallet::from_seed("alice");
+ let mut allocations = BTreeMap::new();
+ allocations.insert(alice.address().to_string(), 1_000);
+
+ let ledger = Ledger::new(allocations, 10);
+ let error = ledger.prepare_next_block(alice.address(), 1).unwrap_err();
+
+ assert!(format!("{error:#}").contains("at least one burn"));
+}
+
+#[test]
+fn leader_block_without_burn_is_rejected() {
let alice = Wallet::from_seed("alice");
let mut allocations = BTreeMap::new();
allocations.insert(alice.address().to_string(), 1_000);
let mut ledger = Ledger::new(allocations, 10);
+ ledger
+ .submit_transaction(alice.burn(1, ledger.next_nonce(alice.address())))
+ .unwrap();
let mut block = ledger.mine_next_block(&alice, 1).unwrap();
block.transactions.clear();
- block.vdf_output = run_vdf(&block.vdf_seed(), block.vdf_rounds);
block.hash = block.compute_hash();
- ledger.apply_block(block).unwrap();
- assert_eq!(ledger.status().height, 1);
+ let error = ledger.apply_block(block).unwrap_err();
+ assert!(format!("{error:#}").contains("at least one burn"));
}
#[test]
@@ -339,9 +358,14 @@ fn default_automatic_mining_does_not_burn() {
let outcome = node.automatic_mine_once(1);
assert!(outcome.burned.is_none());
- assert_eq!(outcome.block.as_ref().map(|block| block.height), Some(1));
- assert!(outcome.skipped_reason.is_none());
- assert_eq!(node.ledger().balance_of(alice.address()), 1_100);
+ assert!(outcome.block.is_none());
+ assert!(
+ outcome
+ .skipped_reason
+ .as_deref()
+ .is_some_and(|reason| reason.contains("at least one burn"))
+ );
+ assert_eq!(node.ledger().balance_of(alice.address()), 1_000);
}
#[test]