iuna

iuna - experimental devnet protocol
git clone https://iuna.jhx.app/git/iuna.git
Log | Files | Refs | README | LICENSE

commit 730d61cc5967fd05fa104a9c6f49a1f84f21418a
parent 940cf741724b016a96744e01ce0cbeda89371a7a
Author: Joris Hartog <jorishartog@hotmail.com>
Date:   Wed, 22 Jul 2026 15:16:51 +0200

Add fees and block size limits

Diffstat:
MREADME.md | 6+++---
Massets/mivora-ui.js | 12++++++++++--
Msrc/adapters/http.rs | 15++++++++++++---
Msrc/app.rs | 47++++++++++++++++++++++++++++++++++++++---------
Msrc/domain.rs | 233+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------
Mtests/coin.rs | 159++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
6 files changed, 439 insertions(+), 33 deletions(-)

diff --git a/README.md b/README.md @@ -74,11 +74,11 @@ Friends who join after you start will adopt your genesis and current chain. The 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 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. +Every non-genesis block must consume the selected mature ticket, include at least one burn transaction, and fit under the 100kB serialized block limit. The VDF seed is bound to the parent hash and child height; the block hash separately commits to the miner, timestamp, miner payout, 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. -The block reward is fixed at 100 coins. The default burn is 0 coins per block, so new nodes can join before they own coins. Genesis starters begin at 100 coins per block; after another wallet has coins, raise its burn from the Configuration screen. +The base block reward is fixed at 100 coins, and miners collect transaction fees on top. The miner includes the best valid burn for liveness, then fills the remaining block space by fee-rate while respecting nonce and balance validity. The default burn is 0 coins per block, so new nodes can join before they own coins. Genesis starters begin at 100 coins per block; after another wallet has coins, raise its burn from the Configuration screen. The measured VDF round count is only the initial delay. After the first blocks, the protocol steers rounds toward the 60-second target. @@ -98,7 +98,7 @@ Mivora stores the latest validated `ChainSnapshot` in SQLite at `<data-dir>/chai ## Architecture -- `src/domain.rs`: wallet, transactions, balances, genesis burn bootstrap, fixed 100-coin rewards, blocks, mature leader tickets, leader proofs, fork choice, launch profile, and VDF checks. +- `src/domain.rs`: wallet, fee-paying transactions, balances, genesis burn bootstrap, 100-coin base rewards, 100kB blocks, mature leader tickets, leader proofs, fork choice, launch profile, and VDF checks. - `src/app.rs`: node use cases, automatic VDF-paced mining, peer bookkeeping, and an in-memory network harness. - `src/adapters/http.rs`: HTTP management UI and status endpoint. - `src/adapters/p2p.rs`: line-delimited JSON gossip, block-range catch-up, and chain snapshots over one TCP port. diff --git a/assets/mivora-ui.js b/assets/mivora-ui.js @@ -23,6 +23,7 @@ window.mivoraApp = function mivoraApp() { burnAmountDirty: false, transferTo: "", transferAmount: 25, + transferFee: 1, peerAddress: "", showBurnTransactions: false, flash: null, @@ -417,14 +418,21 @@ window.mivoraApp = function mivoraApp() { } }, + automaticBurnFeeDraft() { + const amount = Math.max(0, Math.trunc(Number(this.burnAmountDraft) || 0)); + const savedFee = this.status.mining?.automatic_burn_fee ?? 1; + return Math.min(savedFee || 1, Math.max(amount - 1, 0)); + }, + async sendTransfer() { try { const amount = this.transferAmount || 0; + const fee = this.transferFee || 0; const recipient = this.short(this.transferTo); await this.postForm( "/api/transfer", - { to: this.transferTo, amount }, - `Queued transfer of ${amount} coin(s) to ${recipient}` + { to: this.transferTo, amount, fee }, + `Queued transfer of ${amount} coin(s) to ${recipient} with ${fee} fee` ); this.transferTo = ""; } catch (error) { diff --git a/src/adapters/http.rs b/src/adapters/http.rs @@ -18,7 +18,7 @@ use tokio::{net::TcpListener, sync::Mutex}; use crate::{ adapters::{config_store, config_store::UiConfig, p2p::GossipNetwork, wallet_store}, app::{NodeStatus, PeerInfo, SharedNode, SharedPeerBook}, - domain::{Amount, Block, Transaction}, + domain::{Amount, Block, DEFAULT_TRANSACTION_FEE, Transaction}, }; const EXPLORER_LIMIT: usize = 50; @@ -43,6 +43,7 @@ struct AmountForm { struct TransferForm { to: String, amount: Amount, + fee: Option<Amount>, } #[derive(Debug, Deserialize)] @@ -377,7 +378,11 @@ fn dev_seed_verify_bypass_allowed(env_present: bool) -> bool { async fn transfer(state: &HttpState, form: TransferForm) -> Result<()> { let result = { let mut node = state.node.lock().await; - let result = node.transfer(form.to, form.amount); + let result = node.transfer_with_fee( + form.to, + form.amount, + form.fee.unwrap_or(DEFAULT_TRANSACTION_FEE), + ); let outbox = node.drain_outbox(); (result, outbox) }; @@ -554,7 +559,7 @@ const INDEX_HTML: &str = r#"<!doctype html> .block-card { flex-basis: 108px; } } </style> - <script defer src="/assets/mivora-ui.js?v=24"></script> + <script defer src="/assets/mivora-ui.js?v=26"></script> <script defer src="/assets/alpine.min.js"></script> </head> <body x-data="mivoraApp()" x-init="init()" x-cloak> @@ -606,6 +611,7 @@ const INDEX_HTML: &str = r#"<!doctype html> <form @submit.prevent="sendTransfer"> <label>Recipient<input x-model="transferTo" autocomplete="off"></label> <label>Amount<input x-model.number="transferAmount" type="number" min="1"></label> + <label>Fee<input x-model.number="transferFee" type="number" min="0"></label> <button class="primary" type="submit">Send</button> </form> </div> @@ -631,6 +637,7 @@ const INDEX_HTML: &str = r#"<!doctype html> <span class="pill" :class="tx.kind" x-text="tx.direction"></span> <div class="wallet-tx-main"> <div><span class="wallet-tx-amount" x-text="tx.amount"></span> coin(s)</div> + <div class="muted">fee <span x-text="tx.fee ?? 0"></span></div> <div class="muted" x-text="txTitle(tx)"></div> <div><span class="muted">from </span><code x-text="short(tx.from)"></code></div> <div x-show="tx.to"><span class="muted">to </span><code x-text="short(tx.to)"></code></div> @@ -691,6 +698,7 @@ const INDEX_HTML: &str = r#"<!doctype html> <h3>Mining</h3> <form @submit.prevent="saveBurn"> <label>Coins per block<input x-model.number="burnAmountDraft" @input="burnAmountDirty = true" type="number" min="0"></label> + <label>Fee<input :value="automaticBurnFeeDraft()" type="number" readonly></label> <button class="primary" type="submit">Save</button> </form> </div> @@ -764,6 +772,7 @@ const INDEX_HTML: &str = r#"<!doctype html> <template x-for="tx in mempool" :key="tx.signature"> <div class="mempool-item"> <div class="tx-head"><span class="pill" :class="tx.kind" x-text="tx.kind"></span><strong x-text="tx.amount"></strong></div> + <div class="muted">fee <span x-text="tx.fee ?? 0"></span></div> <div><span class="muted">from </span><code x-text="short(tx.from)"></code></div> <div x-show="tx.to"><span class="muted">to </span><code x-text="short(tx.to)"></code></div> <div class="muted">nonce <span x-text="tx.nonce"></span></div> diff --git a/src/app.rs b/src/app.rs @@ -9,8 +9,8 @@ use serde::{Deserialize, Serialize}; use tokio::sync::Mutex; use crate::domain::{ - Amount, Block, ChainSnapshot, ChainStatus, Ledger, PreparedBlock, Transaction, - VDF_TARGET_BLOCK_MS, Wallet, run_vdf, + Amount, Block, ChainSnapshot, ChainStatus, DEFAULT_TRANSACTION_FEE, Ledger, PreparedBlock, + Transaction, VDF_TARGET_BLOCK_MS, Wallet, run_vdf, }; pub type SharedNode = Arc<Mutex<NodeCore>>; @@ -106,6 +106,7 @@ pub struct LaunchProfileStatus { pub struct MiningStatus { pub automatic: bool, pub burn_per_block: Amount, + pub automatic_burn_fee: Amount, pub vdf_rounds: u32, pub vdf_target_block_ms: u64, pub current_leader: Option<String>, @@ -310,6 +311,7 @@ impl NodeCore { mining: MiningStatus { automatic: true, burn_per_block: self.burn_per_block, + automatic_burn_fee: automatic_burn_fee(self.burn_per_block), vdf_rounds: self.ledger.vdf_rounds(), vdf_target_block_ms: VDF_TARGET_BLOCK_MS, current_leader, @@ -330,9 +332,13 @@ impl NodeCore { } pub fn burn(&mut self, amount: Amount) -> Result<Transaction> { - let tx = self - .wallet - .burn(amount, self.ledger.next_nonce(self.wallet.address())); + self.burn_with_fee(amount, DEFAULT_TRANSACTION_FEE) + } + + pub fn burn_with_fee(&mut self, amount: Amount, fee: Amount) -> Result<Transaction> { + let tx = + self.wallet + .burn_with_fee(amount, fee, self.ledger.next_nonce(self.wallet.address())); if self.ledger.submit_transaction(tx.clone())? { self.outbox.push(GossipEnvelope::Transaction(tx.clone())); } @@ -340,9 +346,21 @@ impl NodeCore { } pub fn transfer(&mut self, to: impl Into<String>, amount: Amount) -> Result<Transaction> { - let tx = self - .wallet - .transfer(to, amount, self.ledger.next_nonce(self.wallet.address())); + self.transfer_with_fee(to, amount, DEFAULT_TRANSACTION_FEE) + } + + pub fn transfer_with_fee( + &mut self, + to: impl Into<String>, + amount: Amount, + fee: Amount, + ) -> Result<Transaction> { + let tx = self.wallet.transfer_with_fee( + to, + amount, + fee, + self.ledger.next_nonce(self.wallet.address()), + ); if self.ledger.submit_transaction(tx.clone())? { self.outbox.push(GossipEnvelope::Transaction(tx.clone())); } @@ -429,7 +447,14 @@ impl NodeCore { return Ok(None); } - let tx = self.burn(self.burn_per_block)?; + let fee = automatic_burn_fee(self.burn_per_block); + let balance = self.ledger.balance_of(self.wallet.address()); + let amount = if balance >= self.burn_per_block.saturating_add(fee) { + self.burn_per_block + } else { + self.burn_per_block.saturating_sub(fee) + }; + let tx = self.burn_with_fee(amount, fee)?; self.last_auto_burn_height = Some(current_height); Ok(Some(tx)) } @@ -557,6 +582,10 @@ impl NodeCore { } } +fn automatic_burn_fee(burn_per_block: Amount) -> Amount { + DEFAULT_TRANSACTION_FEE.min(burn_per_block.saturating_sub(1)) +} + #[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)] pub struct PeerBook { peers: BTreeMap<String, PeerInfo>, diff --git a/src/domain.rs b/src/domain.rs @@ -7,6 +7,8 @@ use sha2::{Digest, Sha256}; pub type Amount = u64; pub const BLOCK_REWARD: Amount = 100; +pub const DEFAULT_TRANSACTION_FEE: Amount = 1; +pub const MAX_BLOCK_BYTES: usize = 100_000; pub const VDF_TARGET_BLOCK_MS: u64 = 60_000; const MAX_PENDING_TRANSACTIONS: usize = 10_000; const MAX_BLOCK_TRANSACTIONS: usize = 1_000; @@ -40,19 +42,35 @@ impl Wallet { } pub fn burn(&self, amount: Amount, nonce: u64) -> Transaction { + self.burn_with_fee(amount, 0, nonce) + } + + pub fn burn_with_fee(&self, amount: Amount, fee: Amount, nonce: u64) -> Transaction { let unsigned = UnsignedTransaction::Burn { from: self.address.clone(), amount, + fee, nonce, }; unsigned.sign(self) } pub fn transfer(&self, to: impl Into<String>, amount: Amount, nonce: u64) -> Transaction { + self.transfer_with_fee(to, amount, 0, nonce) + } + + pub fn transfer_with_fee( + &self, + to: impl Into<String>, + amount: Amount, + fee: Amount, + nonce: u64, + ) -> Transaction { let unsigned = UnsignedTransaction::Transfer { from: self.address.clone(), to: to.into(), amount, + fee, nonce, }; unsigned.sign(self) @@ -81,11 +99,13 @@ pub enum UnsignedTransaction { from: String, to: String, amount: Amount, + fee: Amount, nonce: u64, }, Burn { from: String, amount: Amount, + fee: Amount, nonce: u64, }, } @@ -98,21 +118,25 @@ impl UnsignedTransaction { from, to, amount, + fee, nonce, } => Transaction::Transfer { from, to, amount, + fee, nonce, signature, }, Self::Burn { from, amount, + fee, nonce, } => Transaction::Burn { from, amount, + fee, nonce, signature, }, @@ -125,13 +149,28 @@ impl UnsignedTransaction { from, to, amount, + fee, nonce, - } => format!("transfer:{from}:{to}:{amount}:{nonce}"), + } if *fee == 0 => format!("transfer:{from}:{to}:{amount}:{nonce}"), + Self::Transfer { + from, + to, + amount, + fee, + nonce, + } => format!("transfer:{from}:{to}:{amount}:{fee}:{nonce}"), + Self::Burn { + from, + amount, + fee, + nonce, + } if *fee == 0 => format!("burn:{from}:{amount}:{nonce}"), Self::Burn { from, amount, + fee, nonce, - } => format!("burn:{from}:{amount}:{nonce}"), + } => format!("burn:{from}:{amount}:{fee}:{nonce}"), } } } @@ -143,12 +182,16 @@ pub enum Transaction { from: String, to: String, amount: Amount, + #[serde(default)] + fee: Amount, nonce: u64, signature: String, }, Burn { from: String, amount: Amount, + #[serde(default)] + fee: Amount, nonce: u64, signature: String, }, @@ -161,6 +204,7 @@ impl Transaction { Self::Burn { from, amount, + fee: 0, nonce: 0, signature, } @@ -184,6 +228,18 @@ impl Transaction { } } + pub fn fee(&self) -> Amount { + match self { + Self::Transfer { fee, .. } | Self::Burn { fee, .. } => *fee, + } + } + + pub fn total_debit(&self) -> Result<Amount> { + self.amount() + .checked_add(self.fee()) + .context("transaction amount plus fee overflows") + } + pub fn signature(&self) -> &str { match self { Self::Transfer { signature, .. } | Self::Burn { signature, .. } => signature, @@ -204,15 +260,32 @@ impl Transaction { from, to, amount, + fee, nonce, .. - } => format!("transfer:{from}:{to}:{amount}:{nonce}"), + } if *fee == 0 => format!("transfer:{from}:{to}:{amount}:{nonce}"), + Self::Transfer { + from, + to, + amount, + fee, + nonce, + .. + } => format!("transfer:{from}:{to}:{amount}:{fee}:{nonce}"), + Self::Burn { + from, + amount, + fee, + nonce, + .. + } if *fee == 0 => format!("burn:{from}:{amount}:{nonce}"), Self::Burn { from, amount, + fee, nonce, .. - } => format!("burn:{from}:{amount}:{nonce}"), + } => format!("burn:{from}:{amount}:{fee}:{nonce}"), } } @@ -313,6 +386,12 @@ impl Block { .unwrap_or_else(|| self.hash.clone()), ) } + + pub fn serialized_size_bytes(&self) -> Result<usize> { + serde_json::to_vec(self) + .map(|bytes| bytes.len()) + .context("failed to serialize block for size check") + } } #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] @@ -451,27 +530,35 @@ pub struct LaunchProfile { pub ticket_maturity_delay_heights: u64, pub max_pending_transactions: usize, pub max_block_transactions: usize, + #[serde(default = "default_max_block_bytes")] + pub max_block_bytes: usize, } impl Default for LaunchProfile { fn default() -> Self { Self { - profile_id: "mivora-devnet-v1".to_string(), + profile_id: "mivora-devnet-v2".to_string(), ticket_maturity_delay_heights: DEFAULT_TICKET_MATURITY_DELAY, max_pending_transactions: MAX_PENDING_TRANSACTIONS, max_block_transactions: MAX_BLOCK_TRANSACTIONS, + max_block_bytes: MAX_BLOCK_BYTES, } } } +fn default_max_block_bytes() -> usize { + MAX_BLOCK_BYTES +} + impl LaunchProfile { pub fn hash(&self) -> String { hex_hash(format!( - "mivora-launch-profile:{}:{}:{}:{}", + "mivora-launch-profile:{}:{}:{}:{}:{}", self.profile_id, self.ticket_maturity_delay_heights, self.max_pending_transactions, - self.max_block_transactions + self.max_block_transactions, + self.max_block_bytes )) } } @@ -540,6 +627,11 @@ enum ForkChoice { SwitchToCandidate, } +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum TransactionKind { + Burn, +} + #[derive(Clone, Debug)] pub struct Ledger { chain: Vec<Block>, @@ -1000,11 +1092,7 @@ impl Ledger { bail!("no selected leader for block {height}"); } - let transactions = self - .valid_pending_transactions() - .into_iter() - .take(self.launch_profile.max_block_transactions) - .collect::<Vec<_>>(); + let transactions = self.select_block_transactions()?; ensure_block_has_burn(&transactions)?; let tip = self.tip(); @@ -1016,7 +1104,7 @@ impl Ledger { prev_hash, timestamp_ms, miner: miner.to_string(), - reward: self.block_reward, + reward: reward_with_fees(self.block_reward, &transactions)?, vdf_rounds: self.vdf_rounds, vdf_seed, leader_ticket, @@ -1059,6 +1147,9 @@ impl Ledger { } apply_transaction(tx, &mut balances, &mut nonces)?; } + if block.reward != reward_with_fees(self.block_reward, &block.transactions)? { + bail!("block reward is invalid"); + } let mut tickets = self.tickets.clone(); consume_leader_ticket(&block, &mut tickets)?; credit_balance(&mut balances, &block.miner, block.reward)?; @@ -1109,7 +1200,7 @@ impl Ledger { if block.compute_hash() != block.hash { bail!("block hash is invalid"); } - if block.reward != self.block_reward { + if block.reward != reward_with_fees(self.block_reward, &block.transactions)? { bail!("block reward is invalid"); } if block.vdf_rounds != self.vdf_rounds { @@ -1121,6 +1212,9 @@ impl Ledger { if block.transactions.len() > self.launch_profile.max_block_transactions { bail!("block has too many transactions"); } + if block.serialized_size_bytes()? > self.launch_profile.max_block_bytes { + bail!("block exceeds max block size"); + } ensure_block_has_burn(&block.transactions)?; let Some(leader) = self.expected_leader_for_next_block() else { bail!("no selected leader for block {}", block.height); @@ -1202,6 +1296,44 @@ impl Ledger { valid } + fn select_block_transactions(&self) -> Result<Vec<Transaction>> { + let mut balances = self.balances.clone(); + let mut nonces = self.nonces.clone(); + let mut remaining = self.valid_pending_transactions(); + let mut selected = Vec::new(); + + if let Some(index) = best_selectable_transaction_index( + &remaining, + &balances, + &nonces, + Some(TransactionKind::Burn), + ) { + let tx = remaining.remove(index); + let mut candidate = selected.clone(); + candidate.push(tx.clone()); + if estimated_block_size_bytes(&candidate)? <= self.launch_profile.max_block_bytes { + apply_transaction(&tx, &mut balances, &mut nonces)?; + selected.push(tx); + } + } + + while selected.len() < self.launch_profile.max_block_transactions { + let Some(index) = + best_selectable_transaction_index(&remaining, &balances, &nonces, None) + else { + break; + }; + let tx = remaining.remove(index); + let mut candidate = selected.clone(); + candidate.push(tx.clone()); + if estimated_block_size_bytes(&candidate)? <= self.launch_profile.max_block_bytes { + apply_transaction(&tx, &mut balances, &mut nonces)?; + selected.push(tx); + } + } + Ok(selected) + } + fn selected_ticket_for_height(&self, height: u64) -> Option<BurnTicket> { self.tickets .iter() @@ -1305,6 +1437,69 @@ fn ensure_block_has_burn(transactions: &[Transaction]) -> Result<()> { Ok(()) } +fn fee_rate_key(transaction: &Transaction) -> u128 { + let size = serialized_transaction_size_bytes(transaction).unwrap_or(usize::MAX); + if size == 0 || size == usize::MAX { + return 0; + } + u128::from(transaction.fee()) * 1_000_000 / size as u128 +} + +fn best_selectable_transaction_index( + transactions: &[Transaction], + balances: &BTreeMap<String, Amount>, + nonces: &BTreeMap<String, u64>, + required_kind: Option<TransactionKind>, +) -> Option<usize> { + transactions + .iter() + .enumerate() + .filter(|(_, tx)| match required_kind { + Some(TransactionKind::Burn) => tx.is_burn(), + None => true, + }) + .filter(|(_, tx)| { + let mut balances = balances.clone(); + let mut nonces = nonces.clone(); + apply_transaction(tx, &mut balances, &mut nonces).is_ok() + }) + .max_by(|(_, left), (_, right)| { + fee_rate_key(left) + .cmp(&fee_rate_key(right)) + .then_with(|| left.fee().cmp(&right.fee())) + .then_with(|| left.is_burn().cmp(&right.is_burn())) + .then_with(|| right.nonce().cmp(&left.nonce())) + .then_with(|| right.signature().cmp(left.signature())) + }) + .map(|(index, _)| index) +} + +fn serialized_transaction_size_bytes(transaction: &Transaction) -> Result<usize> { + serde_json::to_vec(transaction) + .map(|bytes| bytes.len()) + .context("failed to serialize transaction for size check") +} + +fn estimated_block_size_bytes(transactions: &[Transaction]) -> Result<usize> { + let block = Block { + height: u64::MAX, + prev_hash: "f".repeat(64), + timestamp_ms: u64::MAX, + miner: "f".repeat(64), + reward: u64::MAX, + vdf_rounds: u32::MAX, + vdf_output: "f".repeat(64), + leader_proof: Some(LeaderProof { + ticket_id: "f".repeat(64), + public_key: "f".repeat(64), + signature: "f".repeat(128), + }), + transactions: transactions.to_vec(), + hash: "f".repeat(64), + }; + block.serialized_size_bytes() +} + fn verify_leader_proof(block: &Block, tickets: &[BurnTicket]) -> Result<()> { let Some(proof) = &block.leader_proof else { bail!("block is missing leader proof"); @@ -1367,7 +1562,7 @@ fn apply_transaction( transaction.nonce() ); } - debit_balance(balances, from, transaction.amount())?; + debit_balance(balances, from, transaction.total_debit()?)?; match transaction { Transaction::Transfer { to, amount, .. } => { credit_balance(balances, to, *amount)?; @@ -1378,6 +1573,14 @@ fn apply_transaction( Ok(()) } +fn reward_with_fees(base_reward: Amount, transactions: &[Transaction]) -> Result<Amount> { + transactions.iter().try_fold(base_reward, |total, tx| { + total + .checked_add(tx.fee()) + .context("block reward plus fees overflows") + }) +} + fn next_expected_nonce(nonces: &BTreeMap<String, u64>, address: &str) -> Result<u64> { nonces .get(address) diff --git a/tests/coin.rs b/tests/coin.rs @@ -4,7 +4,8 @@ use mivora::{ adapters::chain_store::SqliteChainStore, app::{DEFAULT_BURN_PER_BLOCK, InMemoryNetwork, NodeConfig, NodeCore, PeerBook, PeerDirection}, domain::{ - Amount, BLOCK_REWARD, GenesisBurn, Ledger, VDF_TARGET_BLOCK_MS, Wallet, run_vdf, verify_vdf, + Amount, BLOCK_REWARD, GenesisBurn, Ledger, MAX_BLOCK_BYTES, VDF_TARGET_BLOCK_MS, Wallet, + run_vdf, verify_vdf, }, }; use tempfile::tempdir; @@ -236,6 +237,144 @@ fn block_reward_is_fixed_at_one_hundred_coins() { } #[test] +fn transaction_fees_are_paid_to_the_block_miner() { + let alice = Wallet::from_seed("fee-miner-alice"); + let bob = Wallet::from_seed("fee-payer-bob"); + let mut allocations = BTreeMap::new(); + allocations.insert(alice.address().to_string(), 1); + allocations.insert(bob.address().to_string(), 200); + + let mut ledger = + Ledger::new_with_genesis_burns(allocations, vec![GenesisBurn::new(alice.address(), 1)], 10) + .unwrap(); + ledger + .submit_transaction(alice.burn(1, ledger.next_nonce(alice.address()))) + .unwrap(); + ledger + .submit_transaction(bob.transfer_with_fee( + alice.address(), + 10, + 7, + ledger.next_nonce(bob.address()), + )) + .unwrap(); + + let block = ledger.mine_next_block(&alice, 1).unwrap(); + assert_eq!(block.reward, BLOCK_REWARD + 7); + ledger.apply_block(block).unwrap(); + + assert_eq!(ledger.balance_of(alice.address()), 216); + assert_eq!(ledger.balance_of(bob.address()), 183); +} + +#[test] +fn miner_orders_block_transactions_by_fee_rate_after_required_burn() { + let wallets = wallets(&["fee-order-alice", "fee-order-bob", "fee-order-carol"]); + let alice = &wallets[0]; + let bob = &wallets[1]; + let carol = &wallets[2]; + let mut allocations = allocations(&wallets, 1_000); + allocations.insert(alice.address().to_string(), 1); + let mut ledger = + Ledger::new_with_genesis_burns(allocations, vec![GenesisBurn::new(alice.address(), 1)], 10) + .unwrap(); + let required_burn = alice.burn(1, ledger.next_nonce(alice.address())); + let low_fee = bob.transfer_with_fee(alice.address(), 1, 1, ledger.next_nonce(bob.address())); + let high_fee = + carol.transfer_with_fee(alice.address(), 1, 20, ledger.next_nonce(carol.address())); + ledger.submit_transaction(low_fee.clone()).unwrap(); + ledger.submit_transaction(high_fee.clone()).unwrap(); + ledger.submit_transaction(required_burn.clone()).unwrap(); + + let block = ledger.mine_next_block(alice, 1).unwrap(); + let signatures = block + .transactions + .iter() + .map(|tx| tx.signature().to_string()) + .collect::<Vec<_>>(); + + assert_eq!(signatures[0], required_burn.signature()); + assert!( + signatures + .iter() + .position(|signature| signature == high_fee.signature()) + < signatures + .iter() + .position(|signature| signature == low_fee.signature()) + ); +} + +#[test] +fn oversized_blocks_are_rejected() { + let alice = Wallet::from_seed("oversized-block-alice"); + let bob = Wallet::from_seed("oversized-block-bob"); + let mut allocations = BTreeMap::new(); + allocations.insert(alice.address().to_string(), 1); + allocations.insert(bob.address().to_string(), 1_000); + let mut ledger = + Ledger::new_with_genesis_burns(allocations, vec![GenesisBurn::new(alice.address(), 1)], 10) + .unwrap(); + ledger + .submit_transaction(alice.burn(1, ledger.next_nonce(alice.address()))) + .unwrap(); + let mut block = ledger.mine_next_block(&alice, 1).unwrap(); + let oversized_transfer = bob.transfer_with_fee( + "x".repeat(MAX_BLOCK_BYTES), + 1, + 3, + ledger.next_nonce(bob.address()), + ); + block.transactions.push(oversized_transfer); + block.reward = BLOCK_REWARD + 3; + block.hash = block.compute_hash(); + + let error = ledger.apply_block(block).unwrap_err(); + + assert!(format!("{error:#}").contains("max block size")); +} + +#[test] +fn miner_skips_oversized_pending_transaction_and_keeps_fitting_fee_transaction() { + let wallets = wallets(&[ + "oversized-select-alice", + "oversized-select-bob", + "oversized-select-carol", + ]); + let alice = &wallets[0]; + let bob = &wallets[1]; + let carol = &wallets[2]; + let mut allocations = allocations(&wallets, 200_000); + allocations.insert(alice.address().to_string(), 1); + let mut ledger = + Ledger::new_with_genesis_burns(allocations, vec![GenesisBurn::new(alice.address(), 1)], 10) + .unwrap(); + ledger + .submit_transaction(alice.burn(1, ledger.next_nonce(alice.address()))) + .unwrap(); + let oversized = bob.transfer_with_fee( + "x".repeat(MAX_BLOCK_BYTES), + 1, + 100_000, + ledger.next_nonce(bob.address()), + ); + let fitting = + carol.transfer_with_fee(alice.address(), 1, 5, ledger.next_nonce(carol.address())); + ledger.submit_transaction(oversized.clone()).unwrap(); + ledger.submit_transaction(fitting.clone()).unwrap(); + + let block = ledger.mine_next_block(alice, 1).unwrap(); + let signatures = block + .transactions + .iter() + .map(|tx| tx.signature().to_string()) + .collect::<Vec<_>>(); + + assert!(!signatures.contains(&oversized.signature().to_string())); + assert!(signatures.contains(&fitting.signature().to_string())); + assert!(block.serialized_size_bytes().unwrap() <= MAX_BLOCK_BYTES); +} + +#[test] fn transfer_that_would_overflow_recipient_balance_is_rejected() { let alice = Wallet::from_seed("alice"); let bob = Wallet::from_seed("bob"); @@ -386,6 +525,24 @@ fn burn_per_block_can_be_set_to_zero() { } #[test] +fn automatic_burn_status_shows_configured_fee() { + let alice = Wallet::from_seed("auto-fee-status-alice"); + let mut allocations = BTreeMap::new(); + allocations.insert(alice.address().to_string(), 1_000); + let mut node = node("alice", alice, allocations); + + assert_eq!(node.status().mining.automatic_burn_fee, 0); + + node.set_burn_per_block(1).unwrap(); + assert_eq!(node.status().mining.burn_per_block, 1); + assert_eq!(node.status().mining.automatic_burn_fee, 0); + + node.set_burn_per_block(50).unwrap(); + assert_eq!(node.status().mining.burn_per_block, 50); + assert_eq!(node.status().mining.automatic_burn_fee, 1); +} + +#[test] fn setting_burn_rate_after_running_at_zero_adds_mempool_burn() { let alice = Wallet::from_seed("alice"); let bob = Wallet::from_seed("bob");