iuna

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

commit c1cdd4d481b432cae99412f048c339872ecd5d15
parent 253ba778edb3c7f6eca980dfbcc8343e8dca939d
Author: Joris Hartog <jorishartog@hotmail.com>
Date:   Tue, 28 Jul 2026 19:53:22 +0200

Fix PoW mine rewards and bounded search

Diffstat:
MREADME.md | 2+-
Msrc/adapters/config_store.rs | 16++++++++--------
Msrc/adapters/http.rs | 72++++++++++++++++++++++++++++--------------------------------------------
Msrc/app.rs | 278++++++++++++++++++++++++++++++++++++++++---------------------------------------
Msrc/domain.rs | 170++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----------------------
Mtests/iuna.rs | 36++++++++++++++++++------------------
Mtests/properties.rs | 2+-
Mwww/assets/iuna-ui.js | 17+++++------------
Mwww/index.html | 2+-
9 files changed, 322 insertions(+), 273 deletions(-)

diff --git a/README.md b/README.md @@ -71,7 +71,7 @@ iuna combines three mechanisms: - **Proof of Burn:** nodes burn IUNA to enter the block-finalization lottery. - **VDF clock:** the selected finalizer must run sequential delay work before publishing a block. -- **PoW issuance:** miners create new IUNA through mine actions and choose the fee paid to the finalizer that includes them. +- **PoW issuance:** mine actions issue 2 IUNA: 1 IUNA to the PoW miner and 1 IUNA as a fixed fee to the finalizer that includes them. The current devnet targets 10-minute blocks, uses local wallet encryption, stores chain state in SQLite, and includes an in-memory network test harness for protocol testing. diff --git a/src/adapters/config_store.rs b/src/adapters/config_store.rs @@ -9,7 +9,7 @@ use std::{ use anyhow::{Context, Result, bail}; use serde::{Deserialize, Serialize}; -use crate::domain::{Amount, DEFAULT_FEE_PER_BYTE, MICRO_IUNA}; +use crate::domain::{Amount, DEFAULT_FEE_PER_BYTE, MICRO_IUNA, MINE_FINALIZER_FEE}; const CONFIG_FILE_VERSION: u32 = 1; const AMOUNT_UNIT_MICROIUNA: &str = "microiuna"; @@ -38,7 +38,7 @@ impl Default for UiConfig { pow_mining_enabled: false, burn_per_block: 0, burn_fee: DEFAULT_BURN_FEE, - pow_mine_fee: DEFAULT_FEE_PER_BYTE, + pow_mine_fee: MINE_FINALIZER_FEE, peers: Vec::new(), } } @@ -139,7 +139,7 @@ fn load(path: &Path) -> Result<UiConfig> { pow_mine_fee: stored .pow_mine_fee .map(|fee| fee.saturating_mul(scale)) - .unwrap_or(DEFAULT_FEE_PER_BYTE), + .unwrap_or(MINE_FINALIZER_FEE), peers: stored.peers, }) } @@ -160,9 +160,9 @@ mod tests { use tempfile::tempdir; - use crate::domain::MICRO_IUNA; + use crate::domain::{MICRO_IUNA, MINE_FINALIZER_FEE}; - use super::{DEFAULT_BURN_FEE, DEFAULT_FEE_PER_BYTE, UiConfig, load_or_create, save}; + use super::{DEFAULT_BURN_FEE, UiConfig, load_or_create, save}; #[test] fn creates_default_config_file() { @@ -181,7 +181,7 @@ mod tests { assert!(stored.contains("\"pow_mining_enabled\": false")); assert!(stored.contains("\"burn_per_block\": 0")); assert!(stored.contains("\"burn_fee\": 1")); - assert!(stored.contains("\"pow_mine_fee\": 1")); + assert!(stored.contains("\"pow_mine_fee\": 1000000")); assert!(stored.contains("\"peers\": []")); } @@ -250,7 +250,7 @@ mod tests { assert!(!config.pow_mining_enabled); assert_eq!(config.burn_per_block, 0); assert_eq!(config.burn_fee, DEFAULT_BURN_FEE); - assert_eq!(config.pow_mine_fee, DEFAULT_FEE_PER_BYTE); + assert_eq!(config.pow_mine_fee, MINE_FINALIZER_FEE); assert_eq!(config.peers, vec!["127.0.0.1:9444"]); } @@ -276,7 +276,7 @@ mod tests { assert!(config.mining_enabled); assert_eq!(config.burn_per_block, 2 * MICRO_IUNA); assert_eq!(config.burn_fee, MICRO_IUNA); - assert_eq!(config.pow_mine_fee, DEFAULT_FEE_PER_BYTE); + assert_eq!(config.pow_mine_fee, MINE_FINALIZER_FEE); } #[test] diff --git a/src/adapters/http.rs b/src/adapters/http.rs @@ -31,7 +31,9 @@ use crate::{ app::{ FeeEstimate, NodeStatus, PeerDirection, PeerInfo, SharedNode, SharedPeerBook, StratumStatus, }, - domain::{Amount, Block, OutPoint, Transaction, TxInput, TxOutput, hex_hash}, + domain::{ + Amount, Block, MINE_FINALIZER_FEE, OutPoint, Transaction, TxInput, TxOutput, hex_hash, + }, }; const EXPLORER_LIMIT: usize = 50; @@ -106,7 +108,6 @@ struct BurnSettingsForm { #[derive(Debug, Deserialize)] struct PowMiningForm { enabled: bool, - fee_per_byte: Option<Amount>, } #[derive(Debug, Deserialize)] @@ -610,11 +611,7 @@ async fn api_pow_mining_form( State(state): State<HttpState>, Form(form): Form<PowMiningForm>, ) -> Json<ActionResponse> { - let result = match required_fee_per_byte_mine(&form) { - Ok(fee_per_byte) => set_pow_mining(&state, form.enabled, fee_per_byte).await, - Err(error) => Err(error), - }; - action_json(result) + action_json(set_pow_mining(&state, form.enabled, MINE_FINALIZER_FEE).await) } async fn burn_per_block_form( @@ -718,18 +715,17 @@ async fn set_pow_mining(state: &HttpState, enabled: bool, fee: Amount) -> Result let mut node = state.node.lock().await; node.set_pow_mining_settings(enabled, fee)?; } - persist_pow_mining_config(&state.ui_config, &state.config_path, enabled, fee).await + persist_pow_mining_config(&state.ui_config, &state.config_path, enabled).await } async fn persist_pow_mining_config( ui_config: &Arc<Mutex<UiConfig>>, config_path: &Path, enabled: bool, - fee: Amount, ) -> Result<()> { let mut config = ui_config.lock().await; config.pow_mining_enabled = enabled; - config.pow_mine_fee = fee; + config.pow_mine_fee = MINE_FINALIZER_FEE; config_store::save(config_path, &config) } @@ -1328,9 +1324,12 @@ async fn estimate_burn_fee(state: &HttpState, form: BurnSettingsForm) -> Result< .estimate_burn_fee(form.amount, fee_per_byte) } -async fn estimate_mine_fee(state: &HttpState, form: PowMiningForm) -> Result<FeeEstimate> { - let fee_per_byte = required_fee_per_byte_mine(&form)?; - state.node.lock().await.estimate_mine_fee(fee_per_byte) +async fn estimate_mine_fee(state: &HttpState, _form: PowMiningForm) -> Result<FeeEstimate> { + state + .node + .lock() + .await + .estimate_mine_fee(MINE_FINALIZER_FEE) } fn required_fee_per_byte_transfer(form: &TransferForm) -> Result<Amount> { @@ -1341,10 +1340,6 @@ fn required_fee_per_byte_burn(form: &BurnSettingsForm) -> Result<Amount> { form.fee_per_byte.context("fee per byte is required") } -fn required_fee_per_byte_mine(form: &PowMiningForm) -> Result<Amount> { - form.fee_per_byte.context("fee per byte is required") -} - fn fee_estimate_json(result: Result<FeeEstimate>) -> Json<FeeEstimateResponse> { match result { Ok(estimate) => Json(FeeEstimateResponse { @@ -2076,7 +2071,7 @@ const INDEX_HTML: &str = r#"<!doctype html> </div> <div class="panel"> <h3>Mine</h3> - <div class="panel-description">Mine with PoW to introduce new IUNA. The miner chooses the fee paid to the block finalizer; the rest of the fixed mine reward goes to this wallet.</div> + <div class="panel-description">Mine with PoW to introduce new IUNA. Each mine action issues 2 IUNA: 1 IUNA goes to the miner and 1 IUNA is paid to the block finalizer.</div> <form class="mine-settings-form" @submit.prevent="savePowMining"> <div class="mine-action-row"> <div class="mine-stats" aria-label="PoW issuance settings"> @@ -2097,16 +2092,12 @@ const INDEX_HTML: &str = r#"<!doctype html> <div class="mine-stat-value"><span x-text="status.chain?.current_mine_difficulty_bits ?? status.launch_profile?.mine_difficulty_bits ?? '-'"></span> bits</div> </div> </div> - <label class="toggle-switch" :class="{ active: powMiningEnabled }" title="Automatically queue one PoW mine action per chain tip"> + <label class="toggle-switch" :class="{ active: powMiningEnabled }" title="Continuously search for PoW mine actions with a small local work budget"> <input type="checkbox" :checked="powMiningEnabled" @change="setPowMiningEnabled($event.target.checked)"> <span class="toggle-track"><span class="toggle-thumb"></span></span> <span class="toggle-text" x-text="powMiningEnabled ? 'On' : 'Off'"></span> </label> </div> - <div class="mine-fee-fields"> - <label>Fee / byte<input x-model="powMineFeeDraft" @input="powMineFeeDirty = true; scheduleFeeEstimates()" type="number" min="0" step="0.000001" required></label> - <button class="primary" type="submit">Save</button> - </div> <div class="fee-preview" x-text="feeEstimateLabel('mine')"></div> <div class="fee-preview" x-text="autoPowStatusLabel()"></div> </form> @@ -2545,15 +2536,18 @@ mod tests { use crate::{ adapters::{config_store, config_store::UiConfig, p2p::GossipNetwork, wallet_store}, app::{NodeCore, PeerBook, PeerDirection, PeerInfo, StratumStatus}, - domain::{Block, Ledger, MICRO_IUNA, MINE_REWARD, OutPoint, Transaction, Wallet}, + domain::{ + Block, Ledger, MICRO_IUNA, MINE_FINALIZER_FEE, MINE_REWARD, OutPoint, Transaction, + Wallet, + }, }; use super::{ AUTH_COOKIE_NAME, HttpState, PEER_STALE_AFTER_MS, TransferForm, api_auth_login_form, api_auth_setup_form, api_auth_status, dev_seed_verify_bypass_allowed, hash_password, hex_encode, pbkdf2_sha256, persist_burn_settings_config, persist_pow_mining_config, - require_auth_middleware, required_fee_per_byte_burn, required_fee_per_byte_mine, - validate_password, validate_transfer_form, verify_password, wallet_transaction_rows, + require_auth_middleware, required_fee_per_byte_burn, validate_password, + validate_transfer_form, verify_password, wallet_transaction_rows, }; #[test] @@ -2946,13 +2940,10 @@ mod tests { } #[test] - fn mine_transaction_views_include_miner_chosen_fee() { + fn mine_transaction_views_include_protocol_finalizer_fee() { let alice = Wallet::from_seed("wallet-mine-fee-alice"); let ledger = Ledger::new(BTreeMap::new(), 1); - let mine_fee = MICRO_IUNA / 5; - let mine = ledger - .build_mine_with_fee(alice.address(), mine_fee) - .unwrap(); + let mine = ledger.build_mine(alice.address()).unwrap(); let chain = vec![fake_block(1, vec![mine.clone()])]; let outputs = super::known_output_index(&BTreeMap::new(), &chain, &[]); @@ -2960,10 +2951,10 @@ mod tests { let transaction = super::ui_transaction(&mine, &outputs); assert_eq!(rows.len(), 1); - assert_eq!(rows[0].amount, MINE_REWARD - mine_fee); - assert_eq!(rows[0].fee, mine_fee); - assert_eq!(transaction.amount, MINE_REWARD - mine_fee); - assert_eq!(transaction.fee, mine_fee); + assert_eq!(rows[0].amount, MINE_REWARD - MINE_FINALIZER_FEE); + assert_eq!(rows[0].fee, MINE_FINALIZER_FEE); + assert_eq!(transaction.amount, MINE_REWARD - MINE_FINALIZER_FEE); + assert_eq!(transaction.fee, MINE_FINALIZER_FEE); } fn fake_block(height: u64, transactions: Vec<Transaction>) -> Block { @@ -3111,13 +3102,13 @@ mod tests { let initial_config = ui_config.lock().await.clone(); config_store::save(&config_path, &initial_config).expect("initial config should save"); - persist_pow_mining_config(&ui_config, &config_path, true, 2 * MICRO_IUNA) + persist_pow_mining_config(&ui_config, &config_path, true) .await .unwrap(); let config = config_store::load_or_create(&config_path).unwrap(); assert!(config.pow_mining_enabled); - assert_eq!(config.pow_mine_fee, 2 * MICRO_IUNA); + assert_eq!(config.pow_mine_fee, MINE_FINALIZER_FEE); } #[test] @@ -3163,13 +3154,6 @@ mod tests { }) .unwrap_err(); assert!(burn.to_string().contains("fee per byte is required")); - - let mine = required_fee_per_byte_mine(&super::PowMiningForm { - enabled: true, - fee_per_byte: None, - }) - .unwrap_err(); - assert!(mine.to_string().contains("fee per byte is required")); } #[test] diff --git a/src/app.rs b/src/app.rs @@ -6,12 +6,13 @@ use std::{ use anyhow::{Context, Result, bail}; use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; use tokio::sync::Mutex; use crate::domain::{ Amount, Block, ChainSnapshot, ChainStatus, DEFAULT_FEE_PER_BYTE, DEFAULT_TRANSACTION_FEE, - Ledger, OutPoint, PreparedBlock, StratumMineShare, StratumMineTemplate, Transaction, - TransactionSubmitOutcome, TxOutput, VDF_TARGET_BLOCK_MS, Wallet, run_vdf, + Ledger, MINE_FINALIZER_FEE, OutPoint, PreparedBlock, StratumMineShare, StratumMineTemplate, + Transaction, TransactionSubmitOutcome, VDF_TARGET_BLOCK_MS, Wallet, run_vdf, }; pub type SharedNode = Arc<Mutex<NodeCore>>; @@ -25,6 +26,7 @@ pub const BLOCK_REQUEST_LIMIT: usize = 128; const IMPORT_REBROADCAST_LIMIT: usize = 128; pub const PEER_MISBEHAVIOR_BAN_SCORE: u32 = 3; pub const PEER_MISBEHAVIOR_BAN_MS: u64 = 10 * 60 * 1_000; +const AUTO_POW_NONCE_ATTEMPTS_PER_TICK: u64 = 8; #[derive(Clone, Debug)] pub struct NodeConfig { @@ -199,6 +201,14 @@ pub struct AutoMinePlan { pub skipped_reason: Option<String>, } +#[derive(Clone, Debug, Eq, PartialEq)] +struct AutoPowMineCursor { + anchor: String, + salt: u64, + next_nonce: u64, + searched: u64, +} + #[derive(Clone, Debug)] pub struct NodeCore { wallet: NodeWallet, @@ -211,6 +221,7 @@ pub struct NodeCore { last_auto_burn_height: Option<u64>, last_auto_pow_mine_anchor: Option<String>, last_auto_pow_mine_status: Option<String>, + auto_pow_mine_cursor: Option<AutoPowMineCursor>, outbox: Vec<GossipEnvelope>, } @@ -290,12 +301,13 @@ impl NodeCore { ledger, automatic_mining_enabled, pow_mining_enabled: false, - pow_mine_fee: DEFAULT_FEE_PER_BYTE, + pow_mine_fee: MINE_FINALIZER_FEE, burn_per_block, burn_fee, last_auto_burn_height: None, last_auto_pow_mine_anchor: None, last_auto_pow_mine_status: None, + auto_pow_mine_cursor: None, outbox: Vec::new(), } } @@ -313,6 +325,7 @@ impl NodeCore { self.last_auto_burn_height = None; self.last_auto_pow_mine_anchor = None; self.last_auto_pow_mine_status = None; + self.auto_pow_mine_cursor = None; } pub fn ledger(&self) -> &Ledger { @@ -468,7 +481,7 @@ impl NodeCore { pow_mining_enabled: self.pow_mining_enabled, burn_per_block: self.burn_per_block, automatic_burn_fee: self.burn_fee, - automatic_pow_mine_fee: self.pow_mine_fee, + automatic_pow_mine_fee: MINE_FINALIZER_FEE, last_auto_pow_mine_anchor: self.last_auto_pow_mine_anchor.clone(), last_auto_pow_mine_status: if self.pow_mining_enabled && !self.has_real_chain() { Some("waiting for a real chain before PoW mining can start".to_string()) @@ -519,6 +532,7 @@ impl NodeCore { pub fn set_pow_mining_enabled(&mut self, enabled: bool) { self.pow_mining_enabled = enabled; + self.auto_pow_mine_cursor = None; if !enabled { self.last_auto_pow_mine_anchor = None; self.last_auto_pow_mine_status = None; @@ -528,9 +542,10 @@ impl NodeCore { } } - pub fn set_pow_mining_settings(&mut self, enabled: bool, fee: Amount) -> Result<()> { + pub fn set_pow_mining_settings(&mut self, enabled: bool, _fee: Amount) -> Result<()> { self.pow_mining_enabled = enabled; - self.pow_mine_fee = fee; + self.pow_mine_fee = MINE_FINALIZER_FEE; + self.auto_pow_mine_cursor = None; if !enabled { self.last_auto_pow_mine_anchor = None; self.last_auto_pow_mine_status = None; @@ -645,8 +660,8 @@ impl NodeCore { Ok(tx) } - pub fn estimate_mine_fee(&self, fee_per_byte: Amount) -> Result<FeeEstimate> { - self.build_mine_with_fee_rate(fee_per_byte) + pub fn estimate_mine_fee(&self, _fee_per_byte: Amount) -> Result<FeeEstimate> { + self.build_mine_with_fee_rate(MINE_FINALIZER_FEE) .map(|(_, estimate)| estimate) } @@ -732,48 +747,40 @@ impl NodeCore { }) } - fn build_mine_with_fee_rate(&self, fee_per_byte: Amount) -> Result<(Transaction, FeeEstimate)> { - converge_fee_by_byte(fee_per_byte, |fee| { - self.ledger.build_mine_with_fee(self.wallet.address(), fee) - }) + fn build_mine_with_fee_rate( + &self, + _fee_per_byte: Amount, + ) -> Result<(Transaction, FeeEstimate)> { + let tx = self + .ledger + .build_mine_with_fee(self.wallet.address(), MINE_FINALIZER_FEE)?; + Ok(( + tx.clone(), + FeeEstimate { + bytes: tx.economic_size_bytes(), + fee: tx.fee(), + }, + )) } fn estimate_external_mine_fee( &self, - recipient: &str, - anchor: &str, - difficulty_bits: u32, + _recipient: &str, + _anchor: &str, + _difficulty_bits: u32, ) -> Result<Amount> { - let fee_per_byte = self.pow_mine_fee; - let mine_reward = self.ledger.status().mine_reward; - let mut fee = 0; - for _ in 0..16 { - if fee > mine_reward { - bail!("mine transaction fee exceeds reward"); - } - let tx = Transaction::Mine { - output: TxOutput { - address: recipient.to_string(), - amount: mine_reward - fee, - }, - anchor: anchor.to_string(), - salt: 1, - nonce: u64::MAX, - difficulty_bits, - fee, - proof_header: Some("00".repeat(80)), - signature: "00".repeat(32), - }; - let bytes = tx.economic_size_bytes(); - let next_fee = fee_per_byte - .checked_mul(bytes as Amount) - .context("fee per byte times transaction bytes overflows")?; - if fee >= next_fee { - return Ok(fee); - } - fee = next_fee; - } - Ok(fee.min(mine_reward)) + Ok(MINE_FINALIZER_FEE) + } + + fn estimate_local_mine_fee( + &self, + _recipient: &str, + _anchor: &str, + _salt: u64, + _difficulty_bits: u32, + _fee_per_byte: Amount, + ) -> Result<Amount> { + Ok(MINE_FINALIZER_FEE) } pub fn mine_one(&mut self) -> Result<Block> { @@ -886,6 +893,7 @@ impl NodeCore { fn prepare_automatic_pow_mine(&mut self) -> Result<Option<Transaction>> { if !self.pow_mining_enabled { self.last_auto_pow_mine_status = None; + self.auto_pow_mine_cursor = None; return Ok(None); } let anchor = self @@ -894,22 +902,58 @@ impl NodeCore { .last() .map(|block| block.hash.clone()) .context("ledger has no anchor block")?; - if self.last_auto_pow_mine_anchor.as_deref() == Some(anchor.as_str()) { - self.last_auto_pow_mine_status = - Some("already queued a mine action for the current tip".to_string()); - return Ok(None); + let wallet_address = self.wallet.address().to_string(); + let difficulty_bits = self.ledger.current_mine_difficulty_bits(); + let needs_cursor = self + .auto_pow_mine_cursor + .as_ref() + .is_none_or(|cursor| cursor.anchor != anchor); + if needs_cursor { + self.auto_pow_mine_cursor = Some(AutoPowMineCursor { + salt: auto_pow_salt(&wallet_address, &anchor), + anchor: anchor.clone(), + next_nonce: 0, + searched: 0, + }); } - if self.wallet_has_mine_for_anchor(&anchor) { - self.last_auto_pow_mine_anchor = Some(anchor); - self.last_auto_pow_mine_status = - Some("mine action is already pending for the current tip".to_string()); - return Ok(None); + let cursor = self + .auto_pow_mine_cursor + .as_ref() + .context("automatic PoW cursor was not initialized")? + .clone(); + let fee = self.estimate_local_mine_fee( + &wallet_address, + &anchor, + cursor.salt, + difficulty_bits, + self.pow_mine_fee, + )?; + let outcome = self.ledger.search_mine_with_fee( + wallet_address, + fee, + cursor.salt, + cursor.next_nonce, + AUTO_POW_NONCE_ATTEMPTS_PER_TICK, + )?; + let mut searched = outcome.attempts; + if let Some(cursor) = &mut self.auto_pow_mine_cursor { + if cursor.anchor == anchor { + cursor.next_nonce = outcome.next_nonce; + cursor.searched = cursor.searched.saturating_add(outcome.attempts); + searched = cursor.searched; + } } - let (tx, _) = self.build_mine_with_fee_rate(self.pow_mine_fee)?; + let Some(tx) = outcome.transaction else { + self.last_auto_pow_mine_status = Some(format!( + "searched {searched} PoW nonces for the current tip; no proof yet" + )); + return Ok(None); + }; if self.ledger.submit_transaction(tx.clone())? { self.last_auto_pow_mine_anchor = Some(anchor); - self.last_auto_pow_mine_status = - Some("queued mine action for the current tip".to_string()); + self.last_auto_pow_mine_status = Some(format!( + "queued mine action after {searched} PoW nonce attempts for the current tip" + )); self.outbox.push(GossipEnvelope::Transaction(tx.clone())); return Ok(Some(tx)); } @@ -918,19 +962,6 @@ impl NodeCore { Ok(None) } - fn wallet_has_mine_for_anchor(&self, anchor: &str) -> bool { - self.ledger.pending().iter().any(|tx| { - matches!( - tx, - Transaction::Mine { - output, - anchor: tx_anchor, - .. - } if tx_anchor == anchor && output.address == self.wallet.address() - ) - }) - } - fn prepare_automatic_burn(&mut self) -> Result<Option<Transaction>> { let current_height = self.ledger.status().height; if !self.automatic_mining_enabled { @@ -1069,6 +1100,7 @@ impl NodeCore { self.last_auto_burn_height = None; self.last_auto_pow_mine_anchor = None; self.last_auto_pow_mine_status = None; + self.auto_pow_mine_cursor = None; self.enqueue_imported_blocks(previous_height); } Ok(()) @@ -1089,6 +1121,7 @@ impl NodeCore { self.last_auto_burn_height = None; self.last_auto_pow_mine_anchor = None; self.last_auto_pow_mine_status = None; + self.auto_pow_mine_cursor = None; self.enqueue_imported_blocks(previous_height); Ok(true) } @@ -1420,6 +1453,13 @@ pub fn now_ms() -> u64 { .as_millis() as u64 } +fn auto_pow_salt(wallet_address: &str, anchor: &str) -> u64 { + let digest = Sha256::digest(format!("iuna-auto-pow:{wallet_address}:{anchor}").as_bytes()); + let mut bytes = [0_u8; 8]; + bytes.copy_from_slice(&digest[..8]); + u64::from_be_bytes(bytes) +} + fn converge_fee_by_byte( fee_per_byte: Amount, mut build: impl FnMut(Amount) -> Result<Transaction>, @@ -1482,7 +1522,7 @@ fn converge_fee_by_byte( mod tests { use std::collections::BTreeMap; - use crate::domain::{Ledger, MICRO_IUNA, Transaction, Wallet}; + use crate::domain::{Ledger, MICRO_IUNA, MINE_FINALIZER_FEE, Transaction, Wallet}; use super::{NodeConfig, NodeCore}; @@ -1512,7 +1552,7 @@ mod tests { } #[test] - fn automatic_pow_mining_queues_one_mine_action_per_anchor() { + fn automatic_pow_mining_searches_bounded_nonce_batches_per_tip() { let wallet = Wallet::from_seed("automatic-pow-mining-wallet"); let mut node = NodeCore::new(NodeConfig { wallet: wallet.clone(), @@ -1531,6 +1571,11 @@ mod tests { node.set_pow_mining_enabled(true); let first = node.prepare_automatic_mining(2); + assert!(node.ledger().pending().len() <= 1); + let first = std::iter::once(first) + .chain((3..10_000).map(|timestamp| node.prepare_automatic_mining(timestamp))) + .find(|plan| plan.pow_mined.is_some()) + .expect("bounded PoW search should eventually find a proof"); let first_mine = first.pow_mined.as_ref().expect("PoW should be queued"); let Transaction::Mine { anchor, @@ -1545,32 +1590,34 @@ mod tests { assert_eq!(anchor, &node.chain().last().unwrap().hash); assert_eq!(output.address, wallet.address()); let minimum_fee = first_mine.economic_size_bytes() as u64; - let (_, expected_estimate) = node - .build_mine_with_fee_rate(node.status().mining.automatic_pow_mine_fee) - .unwrap(); assert!(*fee >= minimum_fee); - assert_eq!(*fee, expected_estimate.fee); assert_eq!( *difficulty_bits, node.ledger().current_mine_difficulty_bits() ); assert_eq!(node.ledger().pending().len(), 1); - assert_eq!( - node.status().mining.last_auto_pow_mine_status.as_deref(), - Some("queued mine action for the current tip") + assert!( + node.status() + .mining + .last_auto_pow_mine_status + .as_deref() + .unwrap_or_default() + .contains("queued mine action after") ); - let second = node.prepare_automatic_mining(3); - assert!(second.pow_mined.is_none()); - assert_eq!(node.ledger().pending().len(), 1); - assert_eq!( - node.status().mining.last_auto_pow_mine_status.as_deref(), - Some("already queued a mine action for the current tip") + let second = (10_000..20_000) + .map(|timestamp| node.prepare_automatic_mining(timestamp)) + .find(|plan| plan.pow_mined.is_some()) + .expect("automatic PoW should keep searching the same tip after one proof"); + assert_ne!( + second.pow_mined.as_ref().unwrap().signature(), + first_mine.signature() ); + assert_eq!(node.ledger().pending().len(), 2); } #[test] - fn automatic_pow_mining_uses_configured_mine_fee() { + fn automatic_pow_mining_uses_protocol_finalizer_fee() { let wallet = Wallet::from_seed("automatic-pow-mining-fee-wallet"); let mut node = NodeCore::new(NodeConfig { wallet, @@ -1580,25 +1627,21 @@ mod tests { burn_fee: 0, }); - let configured_fee_per_byte = 2; - node.set_pow_mining_settings(true, configured_fee_per_byte) - .unwrap(); - let plan = node.prepare_automatic_mining(1); + node.set_pow_mining_settings(true, 2).unwrap(); + let plan = (1..10_000) + .map(|timestamp| node.prepare_automatic_mining(timestamp)) + .find(|plan| plan.pow_mined.is_some()) + .expect("bounded PoW search should eventually find a proof"); let mine = plan.pow_mined.expect("PoW should be queued"); - let minimum_fee = mine.economic_size_bytes() as u64 * configured_fee_per_byte; - let (_, expected_estimate) = node - .build_mine_with_fee_rate(configured_fee_per_byte) - .unwrap(); - assert!(mine.fee() >= minimum_fee); - assert_eq!(mine.fee(), expected_estimate.fee); + assert_eq!(mine.fee(), MINE_FINALIZER_FEE); assert_eq!( mine.amount(), node.ledger().status().mine_reward - mine.fee() ); assert_eq!( node.status().mining.automatic_pow_mine_fee, - configured_fee_per_byte + MINE_FINALIZER_FEE ); } @@ -1617,47 +1660,6 @@ mod tests { } #[test] - fn automatic_pow_mining_reports_fee_rate_above_reward() { - let wallet = Wallet::from_seed("automatic-pow-mining-too-high-fee-wallet"); - let mut node = NodeCore::new(NodeConfig { - wallet: wallet.clone(), - genesis_allocations: BTreeMap::new(), - vdf_rounds: 10, - burn_per_block: 0, - burn_fee: 0, - }); - let mut genesis_allocations = BTreeMap::new(); - genesis_allocations.insert(wallet.address().to_string(), 1); - let ledger = Ledger::new_with_genesis_burns( - genesis_allocations, - vec![crate::domain::GenesisBurn::new(wallet.address(), 1)], - 10, - ) - .unwrap(); - node.import_verified_ledger(ledger).unwrap(); - - node.set_pow_mining_settings(true, MICRO_IUNA).unwrap(); - let plan = node.prepare_automatic_mining(1); - - assert!(plan.pow_mined.is_none()); - assert!( - plan.skipped_reason - .as_deref() - .unwrap_or_default() - .contains("fee exceeds reward") - ); - assert!(node.status().mining.pow_mining_enabled); - assert!( - node.status() - .mining - .last_auto_pow_mine_status - .as_deref() - .unwrap_or_default() - .contains("fee exceeds reward") - ); - } - - #[test] fn automatic_pow_status_reports_setup_placeholder_wait() { let wallet = Wallet::from_seed("automatic-pow-setup-placeholder-wallet"); let ledger = Ledger::new(BTreeMap::new(), 1); diff --git a/src/domain.rs b/src/domain.rs @@ -8,8 +8,9 @@ use sha2::{Digest, Sha256}; pub type Amount = u64; pub const MICRO_IUNA: Amount = 1_000_000; pub const BLOCK_REWARD: Amount = 100 * MICRO_IUNA; -pub const MINE_REWARD: Amount = MICRO_IUNA; -pub const DEFAULT_MINE_FEE: Amount = MINE_REWARD / 100; +pub const MINE_REWARD: Amount = 2 * MICRO_IUNA; +pub const MINE_FINALIZER_FEE: Amount = MICRO_IUNA; +pub const DEFAULT_MINE_FEE: Amount = MINE_FINALIZER_FEE; pub const DEFAULT_TRANSACTION_FEE: Amount = MICRO_IUNA; pub const DEFAULT_FEE_PER_BYTE: Amount = 1; pub const MAX_BLOCK_BYTES: usize = 100_000; @@ -128,6 +129,13 @@ pub enum Transaction { }, } +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct MineSearchOutcome { + pub transaction: Option<Transaction>, + pub next_nonce: u64, + pub attempts: u64, +} + impl Transaction { pub fn genesis_burn(from: impl Into<String>, amount: Amount) -> Self { let from = from.into(); @@ -669,8 +677,8 @@ fn stratum_mine_template( let recipient = recipient.into(); validate_address(&recipient, "mine recipient")?; validate_hash(anchor, "mine transaction anchor")?; - if fee > mine_reward { - bail!("mine transaction fee exceeds reward"); + if fee != MINE_FINALIZER_FEE { + bail!("mine transaction fee must be exactly the protocol finalizer fee"); } let output = TxOutput { address: recipient.clone(), @@ -1682,7 +1690,7 @@ impl Ledger { } pub fn build_mine(&self, recipient: impl Into<String>) -> Result<Transaction> { - self.build_mine_with_fee(recipient, 0) + self.build_mine_with_fee(recipient, MINE_FINALIZER_FEE) } pub fn build_mine_with_fee( @@ -1692,8 +1700,8 @@ impl Ledger { ) -> Result<Transaction> { let recipient = recipient.into(); validate_address(&recipient, "mine recipient")?; - if fee > self.mine_reward { - bail!("mine transaction fee exceeds reward"); + if fee != MINE_FINALIZER_FEE { + bail!("mine transaction fee must be exactly the protocol finalizer fee"); } let output = TxOutput { address: recipient, @@ -1726,6 +1734,60 @@ impl Ledger { bail!("could not find valid mine proof"); } + pub fn search_mine_with_fee( + &self, + recipient: impl Into<String>, + fee: Amount, + salt: u64, + start_nonce: u64, + max_attempts: u64, + ) -> Result<MineSearchOutcome> { + let recipient = recipient.into(); + validate_address(&recipient, "mine recipient")?; + if fee != MINE_FINALIZER_FEE { + bail!("mine transaction fee must be exactly the protocol finalizer fee"); + } + let output = TxOutput { + address: recipient, + amount: self.mine_reward - fee, + }; + let anchor = self.tip().hash.clone(); + let difficulty_bits = self.current_mine_difficulty_bits(); + let mut attempts = 0_u64; + let mut nonce = start_nonce; + while attempts < max_attempts { + let signature = mine_signature(&output, &anchor, salt, nonce, difficulty_bits, fee); + attempts = attempts.saturating_add(1); + let next_nonce = nonce.checked_add(1).unwrap_or(0); + if hash_meets_difficulty(&signature, difficulty_bits) { + let transaction = Transaction::Mine { + output: output.clone(), + anchor: anchor.clone(), + salt, + nonce, + difficulty_bits, + fee, + proof_header: None, + signature, + }; + if !self.has_transaction(transaction.signature()) { + self.validate_new_transaction(&transaction)?; + return Ok(MineSearchOutcome { + transaction: Some(transaction), + next_nonce, + attempts, + }); + } + } + nonce = next_nonce; + } + Ok(MineSearchOutcome { + transaction: None, + next_nonce: nonce, + attempts, + }) + } + pub fn stratum_mine_template( &self, recipient: impl Into<String>, @@ -2179,6 +2241,9 @@ impl Ledger { if let Some(proof_header) = proof_header { validate_stratum_header(proof_header)?; } + if *fee != MINE_FINALIZER_FEE { + bail!("mine transaction fee must be exactly the protocol finalizer fee"); + } if output .amount .checked_add(*fee) @@ -3551,6 +3616,20 @@ mod tests { } #[test] + fn mine_search_respects_nonce_attempt_limit() { + let alice = Wallet::from_seed("bounded-mine-search-alice"); + let ledger = Ledger::new(BTreeMap::new(), 1); + + let outcome = ledger + .search_mine_with_fee(alice.address(), MINE_FINALIZER_FEE, 1, 0, 0) + .unwrap(); + + assert!(outcome.transaction.is_none()); + assert_eq!(outcome.next_nonce, 0); + assert_eq!(outcome.attempts, 0); + } + + #[test] fn mempool_rejects_invalid_input_outpoint_id() { let alice = Wallet::from_seed("invalid-outpoint-alice"); let bob = Wallet::from_seed("invalid-outpoint-bob"); @@ -3778,22 +3857,23 @@ mod tests { } #[test] - fn mine_fee_cannot_exceed_reward() { - let alice = Wallet::from_seed("mine-fee-too-high-alice"); + fn mine_fee_must_match_protocol_finalizer_fee() { + let alice = Wallet::from_seed("mine-fee-fixed-alice"); let ledger = ledger_with_allocation(&alice, MICRO_IUNA); let error = ledger - .build_mine_with_fee(alice.address(), MINE_REWARD + 1) + .build_mine_with_fee(alice.address(), MINE_FINALIZER_FEE - 1) .unwrap_err(); - assert!(format!("{error:#}").contains("fee exceeds reward")); + assert!(format!("{error:#}").contains("protocol finalizer fee")); } #[test] fn mine_output_plus_fee_must_equal_reward_even_with_valid_pow() { let alice = Wallet::from_seed("mine-invalid-split-alice"); let mut ledger = ledger_with_allocation(&alice, MICRO_IUNA); - let forged = mine_with_output_and_fee(&ledger, alice.address(), MINE_REWARD, 1); + let forged = + mine_with_output_and_fee(&ledger, alice.address(), MINE_REWARD, MINE_FINALIZER_FEE); let error = ledger.submit_transaction(forged).unwrap_err(); @@ -3801,53 +3881,35 @@ mod tests { } #[test] - fn mine_fee_can_take_entire_reward_for_finalizer() { - let alice = Wallet::from_seed("mine-full-fee-alice"); + fn mine_action_uses_fixed_split_between_miner_and_finalizer() { + let alice = Wallet::from_seed("mine-fixed-split-alice"); let mut ledger = ledger_with_allocation(&alice, MICRO_IUNA); - let mine = ledger - .build_mine_with_fee(alice.address(), MINE_REWARD) - .unwrap(); + let mine = ledger.build_mine(alice.address()).unwrap(); - assert_eq!(mine.amount(), 0); - assert_eq!(mine.fee(), MINE_REWARD); + assert_eq!(mine.amount(), MICRO_IUNA); + assert_eq!(mine.fee(), MINE_FINALIZER_FEE); assert!(ledger.submit_transaction(mine).unwrap()); } #[test] - fn block_selection_prefers_higher_fee_mine_action_when_space_is_limited() { - let alice = Wallet::from_seed("mine-fee-priority-alice"); + fn block_selection_can_skip_mine_action_when_space_is_limited() { + let alice = Wallet::from_seed("mine-space-limit-alice"); let mut ledger = ledger_with_allocation(&alice, 10 * MICRO_IUNA); ledger.launch_profile.max_block_transactions = 2; - let low_fee_mine = ledger - .build_mine_with_fee(alice.address(), MICRO_IUNA / 100) - .unwrap(); - let high_fee_mine = ledger - .build_mine_with_fee(alice.address(), MICRO_IUNA / 2) - .unwrap(); let burn = ledger.build_burn(&alice, MICRO_IUNA, 0).unwrap(); - ledger.submit_transaction(low_fee_mine.clone()).unwrap(); - ledger.submit_transaction(high_fee_mine.clone()).unwrap(); ledger.submit_transaction(burn).unwrap(); + let first_mine = ledger.build_mine(alice.address()).unwrap(); + ledger.submit_transaction(first_mine).unwrap(); + let second_mine = ledger.build_mine(alice.address()).unwrap(); + ledger.submit_transaction(second_mine).unwrap(); let block = ledger.mine_next_block(&alice, 1).unwrap(); assert_eq!(block.transactions.len(), 2); assert!(block.transactions.iter().any(Transaction::is_burn)); - assert!( - block - .transactions - .iter() - .any(|tx| tx.signature() == high_fee_mine.signature()) - ); - assert!( - block - .transactions - .iter() - .all(|tx| tx.signature() != low_fee_mine.signature()) - ); - assert_eq!(block.reward, high_fee_mine.fee()); + assert_eq!(block.reward, MINE_FINALIZER_FEE); } #[test] @@ -3857,13 +3919,9 @@ mod tests { let burn = ledger.build_burn(&alice, MICRO_IUNA, 0).unwrap(); ledger.submit_transaction(burn).unwrap(); - let first_mine = ledger - .build_mine_with_fee(alice.address(), MICRO_IUNA / 100) - .unwrap(); + let first_mine = ledger.build_mine(alice.address()).unwrap(); ledger.submit_transaction(first_mine.clone()).unwrap(); - let second_mine = ledger - .build_mine_with_fee(alice.address(), MICRO_IUNA / 100) - .unwrap(); + let second_mine = ledger.build_mine(alice.address()).unwrap(); ledger.submit_transaction(second_mine.clone()).unwrap(); assert_eq!(ledger.pending().len(), 3); @@ -3967,7 +4025,13 @@ mod tests { let anchor = ledger.tip().hash.clone(); let difficulty_bits = ledger.current_mine_difficulty_bits(); let template = ledger - .stratum_mine_template(alice.address(), 0, anchor, 1, difficulty_bits) + .stratum_mine_template( + alice.address(), + MINE_FINALIZER_FEE, + anchor, + 1, + difficulty_bits, + ) .unwrap(); let mut accepted = None; @@ -4007,7 +4071,13 @@ mod tests { for salt in [1, 2] { let template = ledger - .stratum_mine_template(alice.address(), 0, anchor.clone(), salt, difficulty_bits) + .stratum_mine_template( + alice.address(), + MINE_FINALIZER_FEE, + anchor.clone(), + salt, + difficulty_bits, + ) .unwrap(); let mut accepted = None; for nonce in 0_u32..50_000 { diff --git a/tests/iuna.rs b/tests/iuna.rs @@ -5,8 +5,7 @@ use iuna::{ app::{DEFAULT_BURN_PER_BLOCK, InMemoryNetwork, NodeConfig, NodeCore, PeerBook, PeerDirection}, domain::{ Amount, BLOCK_REWARD, DEFAULT_FEE_PER_BYTE, GenesisBurn, Ledger, MAX_BLOCK_BYTES, - MICRO_IUNA, MINE_REWARD, TransactionSubmitOutcome, VDF_TARGET_BLOCK_MS, Wallet, run_vdf, - verify_vdf, + MICRO_IUNA, TransactionSubmitOutcome, VDF_TARGET_BLOCK_MS, Wallet, run_vdf, verify_vdf, }, }; use tempfile::tempdir; @@ -291,48 +290,46 @@ fn block_with_forged_transaction_is_rejected() { } #[test] -fn mine_action_introduces_one_iuna_and_block_author_gets_fees_only() { +fn mine_action_introduces_two_iuna_and_splits_one_to_miner_one_to_finalizer() { let alice = Wallet::from_seed("alice"); let mut allocations = BTreeMap::new(); allocations.insert(alice.address().to_string(), 2 * MICRO_IUNA); let mut ledger = Ledger::new(allocations, 10); let mine = ledger.build_mine(alice.address()).unwrap(); - assert_eq!(mine.amount(), MINE_REWARD); + assert_eq!(mine.amount(), MICRO_IUNA); + assert_eq!(mine.fee(), MICRO_IUNA); ledger.submit_transaction(mine.clone()).unwrap(); submit_burn(&mut ledger, &alice, MICRO_IUNA); let block = ledger.mine_next_block(&alice, 1).unwrap(); - assert_eq!(block.reward, 0); + assert_eq!(block.reward, MICRO_IUNA); ledger.apply_block(block).unwrap(); - assert_eq!(ledger.balance_of(alice.address()), 2 * MICRO_IUNA); + assert_eq!(ledger.balance_of(alice.address()), 3 * MICRO_IUNA); assert!(!ledger.submit_transaction(mine).unwrap()); - assert_eq!(ledger.balance_of(alice.address()), 2 * MICRO_IUNA); + assert_eq!(ledger.balance_of(alice.address()), 3 * MICRO_IUNA); } #[test] -fn mine_action_fee_is_chosen_by_pow_miner_and_paid_to_block_finalizer() { +fn mine_action_protocol_fee_is_paid_to_block_finalizer() { let alice = Wallet::from_seed("mine-fee-alice"); let bob = Wallet::from_seed("mine-fee-bob"); let mut allocations = BTreeMap::new(); allocations.insert(bob.address().to_string(), 2 * MICRO_IUNA); let mut ledger = Ledger::new(allocations, 10); - let mine_fee = MICRO_IUNA / 4; - let mine = ledger - .build_mine_with_fee(alice.address(), mine_fee) - .unwrap(); - assert_eq!(mine.amount(), MINE_REWARD - mine_fee); - assert_eq!(mine.fee(), mine_fee); + let mine = ledger.build_mine(alice.address()).unwrap(); + assert_eq!(mine.amount(), MICRO_IUNA); + assert_eq!(mine.fee(), MICRO_IUNA); ledger.submit_transaction(mine).unwrap(); submit_burn(&mut ledger, &bob, MICRO_IUNA); let block = ledger.mine_next_block(&bob, 1).unwrap(); - assert_eq!(block.reward, mine_fee); + assert_eq!(block.reward, MICRO_IUNA); ledger.apply_block(block).unwrap(); - assert_eq!(ledger.balance_of(alice.address()), MINE_REWARD - mine_fee); - assert_eq!(ledger.balance_of(bob.address()), MICRO_IUNA + mine_fee); + assert_eq!(ledger.balance_of(alice.address()), MICRO_IUNA); + assert_eq!(ledger.balance_of(bob.address()), 2 * MICRO_IUNA); } #[test] @@ -811,7 +808,10 @@ fn pow_only_node_gossips_mine_action_to_pob_only_finalizer() { .set_pow_mining_settings(true, DEFAULT_FEE_PER_BYTE) .unwrap(); - let bob_plan = bob_node.prepare_automatic_mining(1); + let bob_plan = (1..10_000) + .map(|timestamp| bob_node.prepare_automatic_mining(timestamp)) + .find(|plan| plan.pow_mined.is_some()) + .expect("B should eventually queue a mine action"); let mine = bob_plan.pow_mined.expect("B should queue a mine action"); assert_eq!( bob_plan.skipped_reason.as_deref(), diff --git a/tests/properties.rs b/tests/properties.rs @@ -371,7 +371,7 @@ fn try_random_transaction( } 2 if round % 4 == 0 => { let wallet = &wallets[rng.index(wallets.len())]; - if let Ok(tx) = ledger.build_mine_with_fee(wallet.address(), rng.next_u64() % 3) { + if let Ok(tx) = ledger.build_mine(wallet.address()) { let _ = ledger.submit_transaction(tx); } } diff --git a/www/assets/iuna-ui.js b/www/assets/iuna-ui.js @@ -38,8 +38,8 @@ window.iunaApp = function iunaApp() { burnFeeDraft: "0.000001", miningEnabled: false, powMiningEnabled: false, - powMineFee: 1, - powMineFeeDraft: "0.000001", + powMineFee: 1000000, + powMineFeeDraft: "1", powMineFeeDirty: false, burnAmountDirty: false, transferTo: "", @@ -655,10 +655,8 @@ window.iunaApp = function iunaApp() { }, async refreshMineFeeEstimate() { - const feePerByte = this.parseiunaAmount(this.powMineFeeDraft); this.feeEstimates.mine = await this.fetchFeeEstimate("/api/fee-estimate/mine", { enabled: this.powMiningEnabled, - fee_per_byte: feePerByte, }); }, @@ -751,15 +749,13 @@ window.iunaApp = function iunaApp() { async setPowMiningEnabled(enabled) { const previous = this.powMiningEnabled; try { - const fee = this.parseiunaAmountRequired(this.powMineFeeDraft, "Mine fee per byte is required"); this.powMiningEnabled = enabled; await this.postForm( "/api/settings/pow-mining", - { enabled, fee_per_byte: fee }, + { enabled }, enabled ? "PoW mining turned on" : "PoW mining turned off" ); this.powMineFeeDirty = false; - this.powMineFee = fee; } catch (error) { this.powMiningEnabled = previous; this.showFlash(error.message, "error"); @@ -768,17 +764,14 @@ window.iunaApp = function iunaApp() { async savePowMining() { try { - const fee = this.parseiunaAmountRequired(this.powMineFeeDraft, "Mine fee per byte is required"); - this.powMineFeeDraft = this.amountLabel(fee); await this.postForm( "/api/settings/pow-mining", - { enabled: this.powMiningEnabled, fee_per_byte: fee }, + { enabled: this.powMiningEnabled }, this.powMiningEnabled - ? `Mine fee rate set to ${this.amountLabel(fee)} IUNA per byte` + ? "PoW mining settings saved" : `Mine settings saved while off` ); this.powMineFeeDirty = false; - this.powMineFee = fee; } catch (error) { this.showFlash(error.message, "error"); } diff --git a/www/index.html b/www/index.html @@ -286,7 +286,7 @@ <div class="card"> <span class="tag blue">PoW</span> <h3>Proof Of Work Issuance</h3> - <p>PoW mine actions introduce new IUNA. Miners create 1 IUNA at a time and choose the fee paid to the block finalizer that includes the action.</p> + <p>PoW mine actions introduce new IUNA. Each action issues 2 IUNA: 1 IUNA goes to the miner and 1 IUNA is paid as a fixed fee to the block finalizer.</p> </div> </div> </div>