iuna

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

commit cbf1eba2475b85b3d7d5ec4f8750edfc3d125a93
parent 4f3f4601fb018129259b2d72a592eee9e5221cbb
Author: Joris Hartog <jorishartog@hotmail.com>
Date:   Sat,  1 Aug 2026 09:30:12 +0200

Add required burn for mine actions

Diffstat:
Msrc/adapters/chain_store.rs | 9++++++---
Msrc/adapters/config_store.rs | 35+++++++++++++++++++----------------
Msrc/adapters/http.rs | 94+++++++++++++++++++++++++++++++++++++++++++++++++++----------------------------
Msrc/adapters/p2p.rs | 3++-
Msrc/app.rs | 121++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----------------------
Msrc/domain.rs | 714+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------------------
Msrc/main.rs | 5++++-
Mtests/iuna.rs | 27+++++++++++++++++----------
Mtests/properties.rs | 62+++++++++++++++++++++++++++++++++++++-------------------------
Mwww/assets/iuna-ui.js | 44++++++++++++++++++++++++++------------------
10 files changed, 798 insertions(+), 316 deletions(-)

diff --git a/src/adapters/chain_store.rs b/src/adapters/chain_store.rs @@ -331,11 +331,14 @@ fn metrics_from_snapshot(snapshot: &ChainSnapshot) -> Result<Vec<BlockMetricRow> .checked_add(*amount) .context("block metric burns overflow")?; } - Transaction::Mine { output, fee, .. } => { + Transaction::Mine { + required_burn_amount, + .. + } => { mine_count += 1; mine_issued_amount = mine_issued_amount - .checked_add(output.amount) - .and_then(|amount| amount.checked_add(*fee)) + .checked_add(*required_burn_amount) + .and_then(|amount| amount.checked_add(transaction.fee())) .context("block metric mine issuance overflow")?; } } diff --git a/src/adapters/config_store.rs b/src/adapters/config_store.rs @@ -9,7 +9,9 @@ use std::{ use anyhow::{Context, Result, bail}; use serde::{Deserialize, Serialize}; -use crate::domain::{Amount, DEFAULT_FEE_PER_BYTE, MICRO_IUNA, MINE_FINALIZER_FEE}; +use crate::domain::{ + Amount, DEFAULT_FEE_PER_BYTE, DEFAULT_MINE_REQUIRED_BURN_MULTIPLIER_BPS, MICRO_IUNA, +}; const CONFIG_FILE_VERSION: u32 = 1; const AMOUNT_UNIT_MICROIUNA: &str = "microiuna"; @@ -25,7 +27,7 @@ pub struct UiConfig { pub pow_mining_enabled: bool, pub burn_per_block: Amount, pub burn_fee: Amount, - pub pow_mine_fee: Amount, + pub mine_required_burn_multiplier_bps: u16, pub keep_track_of_metrics: bool, pub p2p_accept_inbound: bool, pub p2p_announce_addr: Option<String>, @@ -41,7 +43,7 @@ impl Default for UiConfig { pow_mining_enabled: false, burn_per_block: 0, burn_fee: DEFAULT_BURN_FEE, - pow_mine_fee: MINE_FINALIZER_FEE, + mine_required_burn_multiplier_bps: DEFAULT_MINE_REQUIRED_BURN_MULTIPLIER_BPS, keep_track_of_metrics: false, p2p_accept_inbound: false, p2p_announce_addr: None, @@ -67,7 +69,7 @@ struct ConfigFile { #[serde(default)] burn_fee: Option<Amount>, #[serde(default)] - pow_mine_fee: Option<Amount>, + mine_required_burn_multiplier_bps: Option<u16>, #[serde(default)] keep_track_of_metrics: bool, #[serde(default)] @@ -103,7 +105,7 @@ pub fn save(path: &Path, config: &UiConfig) -> Result<()> { pow_mining_enabled: config.pow_mining_enabled, burn_per_block: config.burn_per_block, burn_fee: Some(config.burn_fee), - pow_mine_fee: Some(config.pow_mine_fee), + mine_required_burn_multiplier_bps: Some(config.mine_required_burn_multiplier_bps), keep_track_of_metrics: config.keep_track_of_metrics, p2p_accept_inbound: Some(config.p2p_accept_inbound), p2p_announce_addr: config.p2p_announce_addr.clone(), @@ -155,10 +157,9 @@ fn load(path: &Path) -> Result<UiConfig> { .burn_fee .map(|fee| fee.saturating_mul(scale)) .unwrap_or(DEFAULT_BURN_FEE), - pow_mine_fee: stored - .pow_mine_fee - .map(|fee| fee.saturating_mul(scale)) - .unwrap_or(MINE_FINALIZER_FEE), + mine_required_burn_multiplier_bps: stored + .mine_required_burn_multiplier_bps + .unwrap_or(DEFAULT_MINE_REQUIRED_BURN_MULTIPLIER_BPS), keep_track_of_metrics: stored.keep_track_of_metrics, p2p_accept_inbound, p2p_announce_addr: stored.p2p_announce_addr, @@ -182,7 +183,7 @@ mod tests { use tempfile::tempdir; - use crate::domain::{MICRO_IUNA, MINE_FINALIZER_FEE}; + use crate::domain::MICRO_IUNA; use super::{DEFAULT_BURN_FEE, UiConfig, load_or_create, save}; @@ -203,7 +204,8 @@ 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\": 1000000")); + assert!(!stored.contains("\"pow_mine_fee\"")); + assert!(stored.contains("\"mine_required_burn_multiplier_bps\": 8000")); assert!(stored.contains("\"keep_track_of_metrics\": false")); assert!(stored.contains("\"p2p_accept_inbound\": false")); assert!(stored.contains("\"p2p_announce_addr\": null")); @@ -224,7 +226,7 @@ mod tests { pow_mining_enabled: true, burn_per_block: 50 * MICRO_IUNA, burn_fee: 3 * MICRO_IUNA, - pow_mine_fee: 2 * MICRO_IUNA, + mine_required_burn_multiplier_bps: 9_000, keep_track_of_metrics: true, p2p_accept_inbound: true, p2p_announce_addr: Some("203.0.113.10:9444".to_string()), @@ -240,7 +242,7 @@ mod tests { assert!(config.pow_mining_enabled); assert_eq!(config.burn_per_block, 50 * MICRO_IUNA); assert_eq!(config.burn_fee, 3 * MICRO_IUNA); - assert_eq!(config.pow_mine_fee, 2 * MICRO_IUNA); + assert_eq!(config.mine_required_burn_multiplier_bps, 9_000); assert!(config.keep_track_of_metrics); assert!(config.p2p_accept_inbound); assert_eq!( @@ -284,7 +286,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, MINE_FINALIZER_FEE); + assert_eq!(config.mine_required_burn_multiplier_bps, 8_000); assert!(!config.keep_track_of_metrics); assert!(!config.p2p_accept_inbound); assert_eq!(config.peers, vec!["127.0.0.1:9444"]); @@ -337,7 +339,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, MINE_FINALIZER_FEE); + assert_eq!(config.mine_required_burn_multiplier_bps, 8_000); } #[test] @@ -356,6 +358,7 @@ mod tests { "burn_per_block": 2000000, "burn_fee": 3, "pow_mine_fee": 5, + "mine_required_burn_multiplier_bps": 7500, "peers": [] }} "# @@ -368,6 +371,6 @@ mod tests { assert!(config.mining_enabled); assert_eq!(config.burn_per_block, 2 * MICRO_IUNA); assert_eq!(config.burn_fee, 3); - assert_eq!(config.pow_mine_fee, 5); + assert_eq!(config.mine_required_burn_multiplier_bps, 7_500); } } diff --git a/src/adapters/http.rs b/src/adapters/http.rs @@ -33,8 +33,8 @@ use crate::{ FeeEstimate, NodeStatus, PeerDirection, PeerInfo, SharedNode, SharedPeerBook, StratumStatus, }, domain::{ - Amount, Block, Ledger, MINE_FINALIZER_FEE, OutPoint, Transaction, TxInput, TxOutput, - hex_hash, + Amount, Block, DEFAULT_MINE_REQUIRED_BURN_MULTIPLIER_BPS, Ledger, MINE_FINALIZER_FEE, + OutPoint, Transaction, TxInput, TxOutput, hex_hash, }, }; @@ -138,6 +138,7 @@ struct BurnSettingsForm { #[derive(Debug, Deserialize)] struct PowMiningForm { enabled: bool, + multiplier_bps: Option<u16>, } #[derive(Debug, Deserialize)] @@ -963,7 +964,15 @@ async fn api_pow_mining_form( State(state): State<HttpState>, Form(form): Form<PowMiningForm>, ) -> Json<ActionResponse> { - action_json(set_pow_mining(&state, form.enabled, MINE_FINALIZER_FEE).await) + action_json( + set_pow_mining( + &state, + form.enabled, + form.multiplier_bps + .unwrap_or(DEFAULT_MINE_REQUIRED_BURN_MULTIPLIER_BPS), + ) + .await, + ) } async fn api_metrics_settings_form( @@ -1086,22 +1095,29 @@ async fn persist_burn_settings_config( config_store::save(config_path, &config) } -async fn set_pow_mining(state: &HttpState, enabled: bool, fee: Amount) -> Result<()> { +async fn set_pow_mining(state: &HttpState, enabled: bool, multiplier_bps: u16) -> Result<()> { { let mut node = state.node.lock().await; - node.set_pow_mining_settings(enabled, fee)?; + node.set_pow_mining_settings(enabled, multiplier_bps)?; } - persist_pow_mining_config(&state.ui_config, &state.config_path, enabled).await + persist_pow_mining_config( + &state.ui_config, + &state.config_path, + enabled, + multiplier_bps, + ) + .await } async fn persist_pow_mining_config( ui_config: &Arc<Mutex<UiConfig>>, config_path: &Path, enabled: bool, + multiplier_bps: u16, ) -> Result<()> { let mut config = ui_config.lock().await; config.pow_mining_enabled = enabled; - config.pow_mine_fee = MINE_FINALIZER_FEE; + config.mine_required_burn_multiplier_bps = multiplier_bps; config_store::save(config_path, &config) } @@ -1619,19 +1635,22 @@ fn wallet_transaction_row( proof_hash: None, }), Transaction::Mine { - output, + recipient, + required_burn_amount, difficulty_bits, - fee, signature, .. - } if output.address == wallet => Some(WalletTransactionRow { + } if recipient == wallet => Some(WalletTransactionRow { kind: "mine", from: "pow".to_string(), - to: Some(output.address.clone()), - amount: output.amount, - fee: *fee, + to: Some(recipient.clone()), + amount: *required_burn_amount, + fee: tx.fee(), inputs: Vec::new(), - outputs: vec![output.clone()], + outputs: vec![TxOutput { + address: recipient.clone(), + amount: *required_burn_amount, + }], change: Vec::new(), signature: signature.clone(), status, @@ -1725,19 +1744,22 @@ fn ui_transaction( proof_hash: None, }, Transaction::Mine { - output, + recipient, + required_burn_amount, difficulty_bits, - fee, signature, .. } => UiTransaction { kind: "mine", from: "pow".to_string(), - to: Some(output.address.clone()), - amount: output.amount, - fee: *fee, + to: Some(recipient.clone()), + amount: *required_burn_amount, + fee: transaction.fee(), inputs: Vec::new(), - outputs: vec![output.clone()], + outputs: vec![TxOutput { + address: recipient.clone(), + amount: *required_burn_amount, + }], change: Vec::new(), signature: signature.clone(), difficulty_bits: Some(*difficulty_bits), @@ -1834,9 +1856,16 @@ fn index_transaction_outputs( transaction: &Transaction, ) { let created_outputs = match transaction { - Transaction::Transfer { outputs, .. } => outputs, - Transaction::Burn { change, .. } => change, - Transaction::Mine { output, .. } => std::slice::from_ref(output), + Transaction::Transfer { outputs, .. } => outputs.clone(), + Transaction::Burn { change, .. } => change.clone(), + Transaction::Mine { + recipient, + required_burn_amount, + .. + } => vec![TxOutput { + address: recipient.clone(), + amount: *required_burn_amount, + }], }; for (index, output) in created_outputs.iter().enumerate() { outputs.insert( @@ -3011,12 +3040,12 @@ 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. Each mine action issues 2 IUNA: 1 IUNA goes to the miner and 1 IUNA is paid to the block finalizer.</div> + <div class="panel-description">Mine with PoW to introduce new IUNA. Your mine action sets a minimum burn amount; the block finalizer must include that much burn to use it.</div> <form class="mine-settings-form" @submit.prevent="savePowMining"> <div class="mine-action-row"> <div class="mine-stats" aria-label="PoW issuance settings"> <div class="mine-stat"> - <div class="mine-stat-label">Total reward</div> + <div class="mine-stat-label">Miner cap</div> <div class="mine-stat-value money">IUNA <span x-text="amountLabel(status.chain?.mine_reward ?? 0)"></span></div> </div> <div class="mine-stat"> @@ -3038,6 +3067,8 @@ const INDEX_HTML: &str = r#"<!doctype html> <span class="toggle-text" x-text="powMiningEnabled ? 'On' : 'Off'"></span> </label> </div> + <label>Burn requirement multiplier<input x-model="powRequiredBurnMultiplierDraft" @input="powRequiredBurnMultiplierDirty = true" type="number" min="0.1" max="1" step="0.05"></label> + <div class="muted">Required burn is this multiplier times the minimum total burn amount from the last 10 non-genesis blocks, clamped between 0.1 and 1 IUNA.</div> <div class="fee-preview" x-text="feeEstimateLabel('mine')"></div> <div class="fee-preview" x-text="autoPowStatusLabel()"></div> </form> @@ -3639,10 +3670,7 @@ mod tests { p2p::GossipNetwork, wallet_store, }, app::{GossipEnvelope, NodeCore, PeerBook, PeerDirection, PeerInfo, StratumStatus}, - domain::{ - Block, Ledger, MICRO_IUNA, MINE_FINALIZER_FEE, MINE_REWARD, OutPoint, Transaction, - Wallet, - }, + domain::{Block, Ledger, MICRO_IUNA, MINE_FINALIZER_FEE, OutPoint, Transaction, Wallet}, }; use super::{ @@ -4486,9 +4514,9 @@ mod tests { let transaction = super::ui_transaction(&mine, &outputs); assert_eq!(rows.len(), 1); - assert_eq!(rows[0].amount, MINE_REWARD - MINE_FINALIZER_FEE); + assert_eq!(rows[0].amount, mine.amount()); assert_eq!(rows[0].fee, MINE_FINALIZER_FEE); - assert_eq!(transaction.amount, MINE_REWARD - MINE_FINALIZER_FEE); + assert_eq!(transaction.amount, mine.amount()); assert_eq!(transaction.fee, MINE_FINALIZER_FEE); } @@ -4861,13 +4889,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) + persist_pow_mining_config(&ui_config, &config_path, true, 7_500) .await .unwrap(); let config = config_store::load_or_create(&config_path).unwrap(); assert!(config.pow_mining_enabled); - assert_eq!(config.pow_mine_fee, MINE_FINALIZER_FEE); + assert_eq!(config.mine_required_burn_multiplier_bps, 7_500); } #[test] diff --git a/src/adapters/p2p.rs b/src/adapters/p2p.rs @@ -1696,6 +1696,7 @@ fn transaction_rejection_is_structurally_invalid(reason: &str) -> bool { "proof hash is invalid", "proof does not meet difficulty", "reward is invalid", + "required burn amount", "difficulty is invalid", "inputs do not balance", "duplicate input", @@ -3595,7 +3596,7 @@ mod tests { "transaction signature is invalid", "mine transaction proof hash is invalid", "mine transaction proof does not meet difficulty", - "mine transaction reward is invalid", + "mine required burn amount must be between", "mine transaction difficulty is invalid", "transaction inputs do not balance outputs, burn, and fee", "duplicate input in transaction", diff --git a/src/app.rs b/src/app.rs @@ -13,10 +13,11 @@ use sha2::{Digest, Sha256}; use tokio::sync::Mutex; use crate::domain::{ - Amount, Block, ChainSnapshot, ChainStatus, DEFAULT_FEE_PER_BYTE, DEFAULT_TRANSACTION_FEE, - Ledger, MAX_PENDING_TRANSACTIONS, MINE_FINALIZER_FEE, OutPoint, PreparedBlock, - StratumMineShare, StratumMineTemplate, Transaction, TransactionSubmitOutcome, - VDF_TARGET_BLOCK_MS, Wallet, hex_hash, run_vdf, + Amount, Block, ChainSnapshot, ChainStatus, DEFAULT_FEE_PER_BYTE, + DEFAULT_MINE_REQUIRED_BURN_MULTIPLIER_BPS, DEFAULT_TRANSACTION_FEE, Ledger, + MAX_PENDING_TRANSACTIONS, MINE_FINALIZER_FEE, OutPoint, PreparedBlock, StratumMineShare, + StratumMineTemplate, Transaction, TransactionSubmitOutcome, VDF_TARGET_BLOCK_MS, Wallet, + hex_hash, run_vdf, }; pub type SharedNode = Arc<Mutex<NodeCore>>; @@ -34,6 +35,8 @@ pub const PEER_MISBEHAVIOR_BAN_SCORE: u32 = 3; pub const PEER_MISBEHAVIOR_BAN_MS: u64 = 10 * 60 * 1_000; pub const PEER_CLOCK_OFFSET_ACCEPTANCE_MS: i64 = 10 * 60 * 1_000; const PEER_CLOCK_OFFSET_STALE_MS: u64 = 20 * 60 * 1_000; +const MIN_MINE_REQUIRED_BURN_MULTIPLIER_BPS: u16 = 1_000; +const MAX_MINE_REQUIRED_BURN_MULTIPLIER_BPS: u16 = 10_000; const AUTO_POW_NONCE_ATTEMPTS_PER_TICK: u64 = 8; static DEBUG_LOGGING: AtomicBool = AtomicBool::new(false); @@ -209,6 +212,8 @@ pub struct MiningStatus { pub burn_per_block: Amount, pub automatic_burn_fee: Amount, pub automatic_pow_mine_fee: Amount, + pub automatic_pow_required_burn_amount: Amount, + pub mine_required_burn_multiplier_bps: u16, pub last_auto_pow_mine_anchor: Option<String>, pub last_auto_pow_mine_status: Option<String>, pub vdf_rounds: u64, @@ -254,7 +259,7 @@ pub struct NodeCore { ledger: Ledger, automatic_mining_enabled: bool, pow_mining_enabled: bool, - pow_mine_fee: Amount, + mine_required_burn_multiplier_bps: u16, burn_per_block: Amount, burn_fee: Amount, last_auto_burn_height: Option<u64>, @@ -340,7 +345,7 @@ impl NodeCore { ledger, automatic_mining_enabled, pow_mining_enabled: false, - pow_mine_fee: MINE_FINALIZER_FEE, + mine_required_burn_multiplier_bps: DEFAULT_MINE_REQUIRED_BURN_MULTIPLIER_BPS, burn_per_block, burn_fee, last_auto_burn_height: None, @@ -558,6 +563,8 @@ impl NodeCore { burn_per_block: self.burn_per_block, automatic_burn_fee: self.burn_fee, automatic_pow_mine_fee: MINE_FINALIZER_FEE, + automatic_pow_required_burn_amount: self.pow_required_burn_amount(), + mine_required_burn_multiplier_bps: self.mine_required_burn_multiplier_bps, 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()) @@ -618,9 +625,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, multiplier_bps: u16) -> Result<()> { + validate_mine_required_burn_multiplier(multiplier_bps)?; self.pow_mining_enabled = enabled; - self.pow_mine_fee = MINE_FINALIZER_FEE; + self.mine_required_burn_multiplier_bps = multiplier_bps; self.auto_pow_mine_cursor = None; if !enabled { self.last_auto_pow_mine_anchor = None; @@ -729,7 +737,7 @@ impl NodeCore { } pub fn mine_pow_reward(&mut self) -> Result<Transaction> { - let (tx, _) = self.build_mine_with_fee_rate(self.pow_mine_fee)?; + let (tx, _) = self.build_mine_with_required_burn_estimate()?; if self.ledger.submit_transaction(tx.clone())? { self.outbox.push(GossipEnvelope::Transaction(tx.clone())); } @@ -737,7 +745,7 @@ impl NodeCore { } pub fn estimate_mine_fee(&self, _fee_per_byte: Amount) -> Result<FeeEstimate> { - self.build_mine_with_fee_rate(MINE_FINALIZER_FEE) + self.build_mine_with_required_burn_estimate() .map(|(_, estimate)| estimate) } @@ -752,11 +760,12 @@ impl NodeCore { .last() .context("cannot build mine job without a chain tip")?; let difficulty_bits = self.ledger.current_mine_difficulty_bits(); - let fee = self.estimate_external_mine_fee(&recipient, &tip.hash, difficulty_bits)?; + let required_burn_amount = + self.external_mine_required_burn_amount(&recipient, &tip.hash, difficulty_bits)?; Ok(ExternalMineJob { template: self.ledger.stratum_mine_template( recipient, - fee, + required_burn_amount, &tip.hash, salt, difficulty_bits, @@ -823,13 +832,11 @@ impl NodeCore { }) } - 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)?; + fn build_mine_with_required_burn_estimate(&self) -> Result<(Transaction, FeeEstimate)> { + let tx = self.ledger.build_mine_with_required_burn( + self.wallet.address(), + self.pow_required_burn_amount(), + )?; Ok(( tx.clone(), FeeEstimate { @@ -839,16 +846,16 @@ impl NodeCore { )) } - fn estimate_external_mine_fee( + fn external_mine_required_burn_amount( &self, _recipient: &str, _anchor: &str, _difficulty_bits: u32, ) -> Result<Amount> { - Ok(MINE_FINALIZER_FEE) + Ok(self.pow_required_burn_amount()) } - fn estimate_local_mine_fee( + fn local_mine_required_burn_amount( &self, _recipient: &str, _anchor: &str, @@ -856,7 +863,14 @@ impl NodeCore { _difficulty_bits: u32, _fee_per_byte: Amount, ) -> Result<Amount> { - Ok(MINE_FINALIZER_FEE) + Ok(self.pow_required_burn_amount()) + } + + fn pow_required_burn_amount(&self) -> Amount { + self.ledger + .recommended_mine_required_burn_amount_with_multiplier( + self.mine_required_burn_multiplier_bps, + ) } pub fn mine_one(&mut self) -> Result<Block> { @@ -1070,16 +1084,16 @@ impl NodeCore { .as_ref() .context("automatic PoW cursor was not initialized")? .clone(); - let fee = self.estimate_local_mine_fee( + let required_burn_amount = self.local_mine_required_burn_amount( &wallet_address, &anchor, cursor.salt, difficulty_bits, - self.pow_mine_fee, + MINE_FINALIZER_FEE, )?; - let outcome = self.ledger.search_mine_with_fee( + let outcome = self.ledger.search_mine_with_required_burn( wallet_address, - fee, + required_burn_amount, cursor.salt, cursor.next_nonce, AUTO_POW_NONCE_ATTEMPTS_PER_TICK, @@ -1805,6 +1819,15 @@ fn auto_pow_salt(wallet_address: &str, anchor: &str) -> u64 { u64::from_be_bytes(bytes) } +fn validate_mine_required_burn_multiplier(multiplier_bps: u16) -> Result<()> { + if !(MIN_MINE_REQUIRED_BURN_MULTIPLIER_BPS..=MAX_MINE_REQUIRED_BURN_MULTIPLIER_BPS) + .contains(&multiplier_bps) + { + bail!("mine required burn multiplier must be between 0.1 and 1.0"); + } + Ok(()) +} + fn converge_fee_by_byte( fee_per_byte: Amount, mut build: impl FnMut(Amount) -> Result<Transaction>, @@ -1867,7 +1890,10 @@ fn converge_fee_by_byte( mod tests { use std::collections::BTreeMap; - use crate::domain::{Ledger, MICRO_IUNA, MINE_FINALIZER_FEE, Transaction, Wallet}; + use crate::domain::{ + DEFAULT_MINE_REQUIRED_BURN_MULTIPLIER_BPS, Ledger, MICRO_IUNA, MINE_FINALIZER_FEE, + Transaction, Wallet, + }; use super::{NodeConfig, NodeCore}; @@ -1924,18 +1950,17 @@ mod tests { let first_mine = first.pow_mined.as_ref().expect("PoW should be queued"); let Transaction::Mine { anchor, - output, + recipient, + required_burn_amount, difficulty_bits, - fee, .. } = first_mine else { panic!("expected mine transaction"); }; assert_eq!(anchor, &node.chain().last().unwrap().hash); - assert_eq!(output.address, wallet.address()); - let minimum_fee = first_mine.economic_size_bytes() as u64; - assert!(*fee >= minimum_fee); + assert_eq!(recipient, wallet.address()); + assert!(*required_burn_amount >= crate::domain::MIN_MINE_REQUIRED_BURN); assert_eq!( *difficulty_bits, node.ledger().current_mine_difficulty_bits() @@ -2020,7 +2045,8 @@ mod tests { burn_fee: 0, }); - node.set_pow_mining_settings(true, 2).unwrap(); + node.set_pow_mining_settings(true, DEFAULT_MINE_REQUIRED_BURN_MULTIPLIER_BPS) + .unwrap(); let plan = (1..10_000) .map(|timestamp| node.prepare_automatic_mining(timestamp)) .find(|plan| plan.pow_mined.is_some()) @@ -2030,7 +2056,7 @@ mod tests { assert_eq!(mine.fee(), MINE_FINALIZER_FEE); assert_eq!( mine.amount(), - node.ledger().status().mine_reward - mine.fee() + node.status().mining.automatic_pow_required_burn_amount ); assert_eq!( node.status().mining.automatic_pow_mine_fee, @@ -2039,6 +2065,31 @@ mod tests { } #[test] + fn pow_required_burn_multiplier_must_stay_in_configured_range() { + let wallet = Wallet::from_seed("pow-required-burn-multiplier-wallet"); + let mut node = NodeCore::new(NodeConfig { + wallet, + genesis_allocations: BTreeMap::new(), + vdf_rounds: 10, + burn_per_block: 0, + burn_fee: 0, + }); + + assert!(node.set_pow_mining_settings(true, 999).is_err()); + node.set_pow_mining_settings(true, 1_000).unwrap(); + assert_eq!( + node.status().mining.mine_required_burn_multiplier_bps, + 1_000 + ); + node.set_pow_mining_settings(true, 10_000).unwrap(); + assert_eq!( + node.status().mining.mine_required_burn_multiplier_bps, + 10_000 + ); + assert!(node.set_pow_mining_settings(true, 10_001).is_err()); + } + + #[test] fn status_reports_package_version() { let wallet = Wallet::from_seed("status-version-wallet"); let node = NodeCore::new(NodeConfig { diff --git a/src/domain.rs b/src/domain.rs @@ -11,8 +11,10 @@ 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 = 2 * MICRO_IUNA; +pub const MINE_REWARD: Amount = MICRO_IUNA; pub const MINE_FINALIZER_FEE: Amount = MICRO_IUNA; +pub const MIN_MINE_REQUIRED_BURN: Amount = MICRO_IUNA / 10; +pub const MAX_MINE_REQUIRED_BURN: Amount = MINE_REWARD; 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; @@ -26,6 +28,8 @@ const MINE_MAX_RETARGET_STEP_BITS: u32 = 2; const MINE_MIN_DIFFICULTY_BITS: u32 = 1; const MINE_MAX_DIFFICULTY_BITS: u32 = 32; const MINE_MAX_ANCHOR_AGE_BLOCKS: u64 = MINE_RETARGET_WINDOW_BLOCKS; +pub const DEFAULT_MINE_REQUIRED_BURN_MULTIPLIER_BPS: u16 = 8_000; +pub const MINE_REQUIRED_BURN_WINDOW_BLOCKS: usize = 10; pub const MAX_PENDING_TRANSACTIONS: usize = 10_000; const MAX_ORPHAN_TRANSACTIONS: usize = 1_024; const MAX_BLOCK_TRANSACTIONS: usize = 1_000; @@ -125,14 +129,13 @@ pub enum Transaction { signature: String, }, Mine { - output: TxOutput, + recipient: String, + required_burn_amount: Amount, anchor: String, #[serde(default)] salt: u64, nonce: u64, difficulty_bits: u32, - #[serde(default)] - fee: Amount, #[serde(default, skip_serializing_if = "Option::is_none")] proof_header: Option<String>, signature: String, @@ -201,7 +204,7 @@ impl Transaction { .first() .map(|input| input.owner.as_str()) .unwrap_or(""), - Self::Mine { output, .. } => output.address.as_str(), + Self::Mine { recipient, .. } => recipient.as_str(), } } @@ -209,7 +212,7 @@ impl Transaction { match self { Self::Transfer { outputs, .. } => outputs.first().map(|output| output.address.as_str()), Self::Burn { .. } => None, - Self::Mine { output, .. } => Some(output.address.as_str()), + Self::Mine { recipient, .. } => Some(recipient.as_str()), } } @@ -219,14 +222,17 @@ impl Transaction { outputs.first().map(|output| output.amount).unwrap_or(0) } Self::Burn { amount, .. } => *amount, - Self::Mine { output, .. } => output.amount, + Self::Mine { + required_burn_amount, + .. + } => *required_burn_amount, } } pub fn fee(&self) -> Amount { match self { Self::Transfer { fee, .. } | Self::Burn { fee, .. } => *fee, - Self::Mine { fee, .. } => *fee, + Self::Mine { .. } => MINE_FINALIZER_FEE, } } @@ -250,6 +256,13 @@ impl Transaction { matches!(self, Self::Burn { .. }) } + fn burn_amount(&self) -> Amount { + match self { + Self::Burn { amount, .. } => *amount, + Self::Transfer { .. } | Self::Mine { .. } => 0, + } + } + pub fn canonical(&self) -> String { format!("{}:{}", self.signing_payload(), self.signature()) } @@ -291,37 +304,44 @@ impl Transaction { } .canonical(), Self::Mine { - output, + recipient, + required_burn_amount, anchor, salt, nonce, difficulty_bits, - fee, .. - } => mine_payload(output, anchor, *salt, *nonce, *difficulty_bits, *fee), + } => mine_payload( + recipient, + *required_burn_amount, + anchor, + *salt, + *nonce, + *difficulty_bits, + ), } } fn verify_signature(&self) -> Result<()> { if let Self::Mine { - output, + recipient, + required_burn_amount, anchor, salt, nonce, difficulty_bits, - fee, proof_header, signature, } = self { let expected = if let Some(proof_header) = proof_header { let header = stratum_mine_header_bytes( - output, + recipient, + *required_burn_amount, anchor, *salt, *nonce, *difficulty_bits, - *fee, )?; let expected_header = hex_encode(header); if *proof_header != expected_header { @@ -329,7 +349,14 @@ impl Transaction { } stratum_mine_signature(&header) } else { - mine_signature(output, anchor, *salt, *nonce, *difficulty_bits, *fee) + mine_signature( + recipient, + *required_burn_amount, + anchor, + *salt, + *nonce, + *difficulty_bits, + ) }; if *signature != expected { bail!("mine transaction proof hash is invalid"); @@ -369,11 +396,18 @@ impl Transaction { } } - fn outputs(&self) -> &[TxOutput] { + fn outputs(&self) -> Vec<TxOutput> { match self { - Self::Transfer { outputs, .. } => outputs, - Self::Burn { change, .. } => change, - Self::Mine { output, .. } => std::slice::from_ref(output), + Self::Transfer { outputs, .. } => outputs.clone(), + Self::Burn { change, .. } => change.clone(), + Self::Mine { + recipient, + required_burn_amount, + .. + } => vec![TxOutput { + address: recipient.clone(), + amount: *required_burn_amount, + }], } } @@ -511,46 +545,33 @@ fn canonical_outputs(outputs: &[TxOutput]) -> String { } fn mine_payload( - output: &TxOutput, + recipient: &str, + required_burn_amount: Amount, anchor: &str, salt: u64, nonce: u64, difficulty_bits: u32, - fee: Amount, ) -> String { - let payload = if salt == 0 { - format!( - "iuna-mine:{}:{}:{}:{}", - output.address, output.amount, anchor, nonce - ) - } else { - format!( - "iuna-mine:{}:{}:{}:{}:{}", - output.address, output.amount, anchor, salt, nonce - ) - } + &format!(":{difficulty_bits}"); - if fee == 0 { - payload - } else { - format!("{payload}:{fee}") - } + format!( + "iuna-mine:{recipient}:{required_burn_amount}:{anchor}:{salt}:{nonce}:{difficulty_bits}" + ) } fn mine_signature( - output: &TxOutput, + recipient: &str, + required_burn_amount: Amount, anchor: &str, salt: u64, nonce: u64, difficulty_bits: u32, - fee: Amount, ) -> String { hex_hash(mine_payload( - output, + recipient, + required_burn_amount, anchor, salt, nonce, difficulty_bits, - fee, )) } @@ -562,8 +583,7 @@ const STRATUM_MINE_NTIME: [u8; 4] = [0, 0, 0, 0]; #[derive(Clone, Debug, Eq, PartialEq)] pub struct StratumMineTemplate { pub recipient: String, - pub output_amount: Amount, - pub fee: Amount, + pub required_burn_amount: Amount, pub anchor: String, pub salt: u64, pub difficulty_bits: u32, @@ -600,36 +620,34 @@ fn unpack_stratum_nonce(nonce: u64) -> ([u8; 4], [u8; 4]) { } fn stratum_coinbase_prefix( - output: &TxOutput, + recipient: &str, + required_burn_amount: Amount, anchor: &str, salt: u64, difficulty_bits: u32, - fee: Amount, ) -> Vec<u8> { - if salt == 0 { - format!( - "iuna-stratum-mine:{}:{}:{}:{}:{}:", - output.address, output.amount, fee, anchor, difficulty_bits - ) - } else { - format!( - "iuna-stratum-mine:{}:{}:{}:{}:{}:{}:", - output.address, output.amount, fee, anchor, salt, difficulty_bits - ) - } + format!( + "iuna-stratum-mine:{recipient}:{required_burn_amount}:{anchor}:{salt}:{difficulty_bits}:" + ) .into_bytes() } fn stratum_coinbase_bytes( - output: &TxOutput, + recipient: &str, + required_burn_amount: Amount, anchor: &str, salt: u64, nonce: u64, difficulty_bits: u32, - fee: Amount, ) -> Vec<u8> { let (extranonce2, _) = unpack_stratum_nonce(nonce); - let mut coinbase = stratum_coinbase_prefix(output, anchor, salt, difficulty_bits, fee); + let mut coinbase = stratum_coinbase_prefix( + recipient, + required_burn_amount, + anchor, + salt, + difficulty_bits, + ); coinbase.extend_from_slice(&[0, 0, 0, 0]); coinbase.extend_from_slice(&extranonce2); coinbase @@ -642,12 +660,12 @@ fn double_sha256(bytes: &[u8]) -> [u8; 32] { } fn stratum_mine_header_bytes( - output: &TxOutput, + recipient: &str, + required_burn_amount: Amount, anchor: &str, salt: u64, nonce: u64, difficulty_bits: u32, - fee: Amount, ) -> Result<[u8; 80]> { let mut header = [0_u8; 80]; header[0..4].copy_from_slice(&STRATUM_MINE_VERSION); @@ -655,12 +673,12 @@ fn stratum_mine_header_bytes( decode_hex_array::<HASH_BYTES>(anchor).context("mine transaction anchor is not hex")?; header[4..36].copy_from_slice(&anchor_bytes); let merkle_root = double_sha256(&stratum_coinbase_bytes( - output, + recipient, + required_burn_amount, anchor, salt, nonce, difficulty_bits, - fee, )); header[36..68].copy_from_slice(&merkle_root); header[68..72].copy_from_slice(&STRATUM_MINE_NTIME); @@ -678,8 +696,7 @@ fn stratum_mine_signature(header: &[u8; 80]) -> String { fn stratum_mine_template( recipient: impl Into<String>, - mine_reward: Amount, - fee: Amount, + required_burn_amount: Amount, anchor: &str, salt: u64, difficulty_bits: u32, @@ -687,23 +704,22 @@ fn stratum_mine_template( let recipient = recipient.into(); validate_address(&recipient, "mine recipient")?; validate_hash(anchor, "mine transaction anchor")?; - if fee != MINE_FINALIZER_FEE { - bail!("mine transaction fee must be exactly the protocol finalizer fee"); - } - let output = TxOutput { - address: recipient.clone(), - amount: mine_reward - fee, - }; + validate_mine_required_burn_amount(required_burn_amount)?; let anchor_bytes = decode_hex_array::<HASH_BYTES>(anchor).context("mine transaction anchor is not hex")?; Ok(StratumMineTemplate { - recipient, - output_amount: output.amount, - fee, + recipient: recipient.clone(), + required_burn_amount, anchor: anchor.to_string(), salt, difficulty_bits, - coinbase_prefix: stratum_coinbase_prefix(&output, anchor, salt, difficulty_bits, fee), + coinbase_prefix: stratum_coinbase_prefix( + &recipient, + required_burn_amount, + anchor, + salt, + difficulty_bits, + ), version_hex: hex_encode(STRATUM_MINE_VERSION), prev_hash_hex: hex_encode(anchor_bytes), nbits_hex: hex_encode(difficulty_bits.to_le_bytes()), @@ -1584,6 +1600,34 @@ impl Ledger { self.mine_difficulty_bits_for_anchor_height(self.tip().height) } + pub fn recommended_mine_required_burn_amount(&self) -> Amount { + self.recommended_mine_required_burn_amount_with_multiplier( + DEFAULT_MINE_REQUIRED_BURN_MULTIPLIER_BPS, + ) + } + + pub fn recommended_mine_required_burn_amount_with_multiplier( + &self, + multiplier_bps: u16, + ) -> Amount { + let Some(min_burn) = self + .chain + .iter() + .rev() + .filter(|block| block.height > 0) + .take(MINE_REQUIRED_BURN_WINDOW_BLOCKS) + .map(block_burn_amount) + .min() + else { + return MIN_MINE_REQUIRED_BURN; + }; + let scaled = u128::from(min_burn) + .saturating_mul(u128::from(multiplier_bps)) + .saturating_div(10_000) + .min(u128::from(Amount::MAX)) as Amount; + scaled.clamp(MIN_MINE_REQUIRED_BURN, MAX_MINE_REQUIRED_BURN) + } + pub fn mine_difficulty_bits_at_height(&self, height: u64) -> u32 { self.mine_difficulty_bits_for_anchor_height(height.min(self.tip().height)) } @@ -1606,7 +1650,7 @@ impl Ledger { pub fn available_utxos_for_address(&self, address: &str) -> Result<Vec<(OutPoint, TxOutput)>> { Ok(self - .utxos_after_valid_pending()? + .utxos_after_spendable_pending()? .into_iter() .filter(|(_, output)| output.address == address) .collect()) @@ -1727,38 +1771,39 @@ impl Ledger { } pub fn build_mine(&self, recipient: impl Into<String>) -> Result<Transaction> { - self.build_mine_with_fee(recipient, MINE_FINALIZER_FEE) + self.build_mine_with_required_burn(recipient, self.recommended_mine_required_burn_amount()) } - pub fn build_mine_with_fee( + pub fn build_mine_with_required_burn( &self, recipient: impl Into<String>, - fee: Amount, + required_burn_amount: Amount, ) -> Result<Transaction> { 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, - }; + validate_mine_required_burn_amount(required_burn_amount)?; let anchor = self.tip().hash.clone(); let salt = 1; let difficulty_bits = self.current_mine_difficulty_bits(); for nonce in 0..u64::MAX { - let signature = mine_signature(&output, &anchor, salt, nonce, difficulty_bits, fee); + let signature = mine_signature( + &recipient, + required_burn_amount, + &anchor, + salt, + nonce, + difficulty_bits, + ); if !hash_meets_difficulty(&signature, difficulty_bits) { continue; } let transaction = Transaction::Mine { - output: output.clone(), + recipient: recipient.clone(), + required_burn_amount, anchor: anchor.clone(), salt, nonce, difficulty_bits, - fee, proof_header: None, signature, }; @@ -1771,39 +1816,40 @@ impl Ledger { bail!("could not find valid mine proof"); } - pub fn search_mine_with_fee( + pub fn search_mine_with_required_burn( &self, recipient: impl Into<String>, - fee: Amount, + required_burn_amount: 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, - }; + validate_mine_required_burn_amount(required_burn_amount)?; 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); + let signature = mine_signature( + &recipient, + required_burn_amount, + &anchor, + salt, + nonce, + difficulty_bits, + ); 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(), + recipient: recipient.clone(), + required_burn_amount, anchor: anchor.clone(), salt, nonce, difficulty_bits, - fee, proof_header: None, signature, }; @@ -1828,15 +1874,14 @@ impl Ledger { pub fn stratum_mine_template( &self, recipient: impl Into<String>, - fee: Amount, + required_burn_amount: Amount, anchor: impl AsRef<str>, salt: u64, difficulty_bits: u32, ) -> Result<StratumMineTemplate> { stratum_mine_template( recipient, - self.mine_reward, - fee, + required_burn_amount, anchor.as_ref(), salt, difficulty_bits, @@ -1848,26 +1893,22 @@ impl Ledger { template: StratumMineTemplate, share: StratumMineShare, ) -> Result<Transaction> { - let output = TxOutput { - address: template.recipient, - amount: template.output_amount, - }; let nonce = pack_stratum_nonce(share.extranonce2, share.header_nonce); let header = stratum_mine_header_bytes( - &output, + &template.recipient, + template.required_burn_amount, &template.anchor, template.salt, nonce, template.difficulty_bits, - template.fee, )?; let transaction = Transaction::Mine { - output, + recipient: template.recipient, + required_burn_amount: template.required_burn_amount, anchor: template.anchor, salt: template.salt, nonce, difficulty_bits: template.difficulty_bits, - fee: template.fee, proof_header: Some(hex_encode(header)), signature: stratum_mine_signature(&header), }; @@ -1933,6 +1974,7 @@ impl Ledger { let transactions = self.select_block_transactions()?; ensure_block_has_burn(&transactions)?; + ensure_mine_actions_have_required_burns(&transactions)?; let tip = self.tip(); let prev_hash = tip.hash.clone(); @@ -2096,6 +2138,7 @@ impl Ledger { bail!("block exceeds max block size"); } ensure_block_has_burn(&block.transactions)?; + ensure_mine_actions_have_required_burns(&block.transactions)?; let selected_ticket = self .ticket_for_finalizer_rank(block.height, block.finalizer_rank) .context("no selected ticket for block finalizer rank")?; @@ -2205,6 +2248,7 @@ impl Ledger { let mut utxos = self.utxos.clone(); let mut remaining = self.valid_pending_transactions(); let mut selected = Vec::new(); + let mut selected_burn_amount = 0_u64; if let Some(index) = best_selectable_transaction_index(&remaining, &utxos, Some(TransactionKind::Burn)) @@ -2214,12 +2258,20 @@ impl Ledger { candidate.push(tx.clone()); if estimated_block_size_bytes(&candidate)? <= self.launch_profile.max_block_bytes { apply_transaction(&tx, &mut utxos)?; + selected_burn_amount = selected_burn_amount + .checked_add(tx.burn_amount()) + .context("selected block burns overflow")?; selected.push(tx); } } while selected.len() < self.launch_profile.max_block_transactions { - let Some(index) = best_selectable_transaction_index(&remaining, &utxos, None) else { + let Some(index) = best_selectable_transaction_index_for_burn_amount( + &remaining, + &utxos, + None, + selected_burn_amount, + ) else { break; }; let tx = remaining.remove(index); @@ -2227,6 +2279,9 @@ impl Ledger { candidate.push(tx.clone()); if estimated_block_size_bytes(&candidate)? <= self.launch_profile.max_block_bytes { apply_transaction(&tx, &mut utxos)?; + selected_burn_amount = selected_burn_amount + .checked_add(tx.burn_amount()) + .context("selected block burns overflow")?; selected.push(tx); } } @@ -2238,7 +2293,7 @@ impl Ledger { address: &str, amount: Amount, ) -> Result<(Vec<UnsignedTxInput>, Amount)> { - let utxos = self.utxos_after_valid_pending()?; + let utxos = self.utxos_after_spendable_pending()?; let mut selected = Vec::new(); let mut total = 0_u64; for (outpoint, output) in &utxos { @@ -2268,7 +2323,7 @@ impl Ledger { if outpoints.is_empty() { bail!("at least one UTXO must be selected"); } - let utxos = self.utxos_after_valid_pending()?; + let utxos = self.utxos_after_spendable_pending()?; let mut seen = BTreeSet::new(); let mut selected = Vec::new(); let mut total = 0_u64; @@ -2298,7 +2353,7 @@ impl Ledger { fn validate_new_transaction(&self, transaction: &Transaction) -> Result<()> { self.validate_transaction_terms(transaction)?; - let mut utxos = self.utxos_after_valid_pending()?; + let mut utxos = self.utxos_after_spendable_pending()?; apply_transaction(transaction, &mut utxos) } @@ -2354,31 +2409,21 @@ impl Ledger { validate_signature(signature, "transaction signature")?; } Transaction::Mine { - output, + recipient, + required_burn_amount, anchor, difficulty_bits, - fee, proof_header, signature, .. } => { - validate_transaction_outputs(std::slice::from_ref(output))?; + validate_address(recipient, "mine recipient")?; + validate_mine_required_burn_amount(*required_burn_amount)?; validate_hash(anchor, "mine transaction anchor")?; validate_hash(signature, "mine transaction proof hash")?; 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) - .context("mine transaction output plus fee overflows")? - != self.mine_reward - { - bail!("mine transaction reward is invalid"); - } let anchor_block = self .chain .iter() @@ -2406,6 +2451,20 @@ impl Ledger { Ok(utxos) } + fn utxos_after_spendable_pending(&self) -> Result<BTreeMap<OutPoint, TxOutput>> { + let mut utxos = self.utxos.clone(); + for pending in self.valid_pending_transactions() { + if matches!(pending, Transaction::Mine { .. }) { + continue; + } + let mut candidate = utxos.clone(); + if apply_transaction(&pending, &mut candidate).is_ok() { + utxos = candidate; + } + } + Ok(utxos) + } + fn selected_ticket_for_height(&self, height: u64) -> Option<BurnTicket> { self.ticket_for_finalizer_rank(height, 0) } @@ -2725,6 +2784,38 @@ fn ensure_block_has_burn(transactions: &[Transaction]) -> Result<()> { Ok(()) } +fn block_burn_amount(block: &Block) -> Amount { + transactions_burn_amount(&block.transactions).unwrap_or(Amount::MAX) +} + +fn transactions_burn_amount(transactions: &[Transaction]) -> Result<Amount> { + transactions.iter().try_fold(0_u64, |total, tx| { + total + .checked_add(tx.burn_amount()) + .context("burns overflow") + }) +} + +fn ensure_mine_actions_have_required_burns(transactions: &[Transaction]) -> Result<()> { + let burn_amount = transactions_burn_amount(transactions)?; + for transaction in transactions { + if let Transaction::Mine { + required_burn_amount, + .. + } = transaction + { + if burn_amount < *required_burn_amount { + bail!( + "mine transaction requires {} burn but block only includes {}", + required_burn_amount, + burn_amount + ); + } + } + } + Ok(()) +} + fn fee_rate_key(transaction: &Transaction) -> u128 { let size = transaction.economic_size_bytes(); if size == 0 { @@ -2738,6 +2829,20 @@ fn best_selectable_transaction_index( utxos: &BTreeMap<OutPoint, TxOutput>, required_kind: Option<TransactionKind>, ) -> Option<usize> { + best_selectable_transaction_index_for_burn_amount( + transactions, + utxos, + required_kind, + Amount::MAX, + ) +} + +fn best_selectable_transaction_index_for_burn_amount( + transactions: &[Transaction], + utxos: &BTreeMap<OutPoint, TxOutput>, + required_kind: Option<TransactionKind>, + selected_burn_amount: Amount, +) -> Option<usize> { transactions .iter() .enumerate() @@ -2746,6 +2851,15 @@ fn best_selectable_transaction_index( None => true, }) .filter(|(_, tx)| { + !matches!( + tx, + Transaction::Mine { + required_burn_amount, + .. + } if *required_burn_amount > selected_burn_amount + ) + }) + .filter(|(_, tx)| { let mut utxos = utxos.clone(); apply_transaction(tx, &mut utxos).is_ok() }) @@ -2819,6 +2933,17 @@ fn validate_hash(hash: &str, label: &str) -> Result<()> { Ok(()) } +fn validate_mine_required_burn_amount(amount: Amount) -> Result<()> { + if !(MIN_MINE_REQUIRED_BURN..=MAX_MINE_REQUIRED_BURN).contains(&amount) { + bail!( + "mine required burn amount must be between {} and {}", + MIN_MINE_REQUIRED_BURN, + MAX_MINE_REQUIRED_BURN + ); + } + Ok(()) +} + fn validate_signature(signature: &str, label: &str) -> Result<()> { decode_hex_array::<SIGNATURE_BYTES>(signature).with_context(|| format!("invalid {label}"))?; Ok(()) @@ -2869,21 +2994,21 @@ fn canonical_transaction_size_bytes(transaction: &Transaction) -> usize { + signature_size_bytes(signature) } Transaction::Mine { - output, + recipient, + required_burn_amount, anchor, salt, nonce, difficulty_bits, - fee, proof_header, signature, } => { - 1 + compact_output_size_bytes(output) + 1 + address_size_bytes(recipient) + + compact_len(u128::from(*required_burn_amount)) + hash_size_bytes(anchor) + compact_len(u128::from(*salt)) + compact_len(u128::from(*nonce)) + compact_len(u128::from(*difficulty_bits)) - + compact_len(u128::from(*fee)) + 1 + proof_header .as_ref() @@ -3026,27 +3151,34 @@ fn apply_transaction( utxos: &mut BTreeMap<OutPoint, TxOutput>, ) -> Result<()> { transaction.verify_signature()?; - if let Transaction::Mine { output, .. } = transaction { - ensure_outputs_do_not_overflow(utxos, std::slice::from_ref(output))?; + if let Transaction::Mine { + recipient, + required_burn_amount, + .. + } = transaction + { + let output = TxOutput { + address: recipient.clone(), + amount: *required_burn_amount, + }; + ensure_outputs_do_not_overflow(utxos, std::slice::from_ref(&output))?; utxos.insert( OutPoint { txid: transaction.signature().to_string(), index: 0, }, - output.clone(), + output, ); return Ok(()); } ensure_single_input_owner(transaction)?; let input_total = spend_inputs(transaction, utxos)?; - let output_total = transaction - .outputs() - .iter() - .try_fold(0_u64, |total, output| { - total - .checked_add(output.amount) - .context("transaction outputs overflow") - })?; + let outputs = transaction.outputs(); + let output_total = outputs.iter().try_fold(0_u64, |total, output| { + total + .checked_add(output.amount) + .context("transaction outputs overflow") + })?; let required = output_total .checked_add(transaction.fee()) .context("transaction outputs plus fee overflow")? @@ -3058,8 +3190,8 @@ fn apply_transaction( if input_total != required { bail!("transaction inputs do not balance outputs, burn, and fee"); } - ensure_outputs_do_not_overflow(utxos, transaction.outputs())?; - for (index, output) in transaction.outputs().iter().enumerate() { + ensure_outputs_do_not_overflow(utxos, &outputs)?; + for (index, output) in outputs.iter().enumerate() { utxos.insert( OutPoint { txid: transaction.signature().to_string(), @@ -3585,29 +3717,31 @@ mod tests { block } - fn mine_with_output_and_fee( + fn mine_with_required_burn( ledger: &Ledger, recipient: &str, - output_amount: Amount, - fee: Amount, + required_burn_amount: Amount, ) -> Transaction { - let output = TxOutput { - address: recipient.to_string(), - amount: output_amount, - }; let anchor = ledger.tip().hash.clone(); let difficulty_bits = ledger.current_mine_difficulty_bits(); for nonce in 0..u64::MAX { let salt = 1; - let signature = mine_signature(&output, &anchor, salt, nonce, difficulty_bits, fee); + let signature = mine_signature( + recipient, + required_burn_amount, + &anchor, + salt, + nonce, + difficulty_bits, + ); if hash_meets_difficulty(&signature, difficulty_bits) { return Transaction::Mine { - output, + recipient: recipient.to_string(), + required_burn_amount, anchor, salt, nonce, difficulty_bits, - fee, proof_header: None, signature, }; @@ -3795,7 +3929,7 @@ mod tests { let ledger = Ledger::new(BTreeMap::new(), 1); let outcome = ledger - .search_mine_with_fee(alice.address(), MINE_FINALIZER_FEE, 1, 0, 0) + .search_mine_with_required_burn(alice.address(), MIN_MINE_REQUIRED_BURN, 1, 0, 0) .unwrap(); assert!(outcome.transaction.is_none()); @@ -4191,42 +4325,155 @@ mod tests { } #[test] - fn mine_fee_must_match_protocol_finalizer_fee() { - let alice = Wallet::from_seed("mine-fee-fixed-alice"); + fn mine_required_burn_amount_must_be_in_protocol_range() { + let alice = Wallet::from_seed("mine-required-burn-range-alice"); let ledger = ledger_with_allocation(&alice, MICRO_IUNA); - let error = ledger - .build_mine_with_fee(alice.address(), MINE_FINALIZER_FEE - 1) + let low = ledger + .build_mine_with_required_burn(alice.address(), MIN_MINE_REQUIRED_BURN - 1) + .unwrap_err(); + let high = ledger + .build_mine_with_required_burn(alice.address(), MAX_MINE_REQUIRED_BURN + 1) .unwrap_err(); - assert!(format!("{error:#}").contains("protocol finalizer fee")); + assert!(format!("{low:#}").contains("required burn amount")); + assert!(format!("{high:#}").contains("required burn amount")); } #[test] - fn mine_output_plus_fee_must_equal_reward_even_with_valid_pow() { - let alice = Wallet::from_seed("mine-invalid-split-alice"); + fn mine_required_burn_amount_is_bound_to_proof_hash() { + let alice = Wallet::from_seed("mine-required-burn-proof-alice"); let mut ledger = ledger_with_allocation(&alice, MICRO_IUNA); - let forged = - mine_with_output_and_fee(&ledger, alice.address(), MINE_REWARD, MINE_FINALIZER_FEE); + let mut forged = mine_with_required_burn(&ledger, alice.address(), MIN_MINE_REQUIRED_BURN); + if let Transaction::Mine { + required_burn_amount, + .. + } = &mut forged + { + *required_burn_amount = required_burn_amount.saturating_add(1); + } let error = ledger.submit_transaction(forged).unwrap_err(); - assert!(format!("{error:#}").contains("mine transaction reward is invalid")); + assert!(format!("{error:#}").contains("proof hash is invalid")); } #[test] - fn mine_action_uses_fixed_split_between_miner_and_finalizer() { - let alice = Wallet::from_seed("mine-fixed-split-alice"); + fn mine_action_uses_required_burn_reward_and_fixed_finalizer_fee() { + let alice = Wallet::from_seed("mine-required-burn-reward-alice"); let mut ledger = ledger_with_allocation(&alice, MICRO_IUNA); - let mine = ledger.build_mine(alice.address()).unwrap(); + let mine = ledger + .build_mine_with_required_burn(alice.address(), MIN_MINE_REQUIRED_BURN) + .unwrap(); - assert_eq!(mine.amount(), MICRO_IUNA); + assert_eq!(mine.amount(), MIN_MINE_REQUIRED_BURN); assert_eq!(mine.fee(), MINE_FINALIZER_FEE); assert!(ledger.submit_transaction(mine).unwrap()); } #[test] + fn recommended_mine_required_burn_uses_multiplier_times_recent_minimum() { + let alice = Wallet::from_seed("mine-required-burn-policy-alice"); + let mut ledger = ledger_with_allocation(&alice, 10 * MICRO_IUNA); + + assert_eq!( + ledger.recommended_mine_required_burn_amount_with_multiplier(8_000), + MIN_MINE_REQUIRED_BURN + ); + + for amount in [MICRO_IUNA, MICRO_IUNA / 2, 3 * MICRO_IUNA / 2] { + let burn = ledger.build_burn(&alice, amount, 0).unwrap(); + ledger.submit_transaction(burn).unwrap(); + let block = ledger.mine_next_block(&alice, ledger.height() + 1).unwrap(); + ledger.apply_locally_mined_block(block).unwrap(); + } + + assert_eq!( + ledger.recommended_mine_required_burn_amount_with_multiplier(8_000), + 400_000 + ); + assert_eq!( + ledger.recommended_mine_required_burn_amount_with_multiplier(100), + MIN_MINE_REQUIRED_BURN + ); + assert_eq!( + ledger.recommended_mine_required_burn_amount_with_multiplier(10_000), + MICRO_IUNA / 2 + ); + } + + #[test] + fn recommended_mine_required_burn_uses_last_ten_non_genesis_blocks() { + let alice = Wallet::from_seed("mine-required-burn-window-alice"); + let mut ledger = ledger_with_allocation(&alice, 20 * MICRO_IUNA); + + for amount in std::iter::once(MIN_MINE_REQUIRED_BURN).chain([MICRO_IUNA; 10]) { + let burn = ledger.build_burn(&alice, amount, 0).unwrap(); + ledger.submit_transaction(burn).unwrap(); + let block = ledger.mine_next_block(&alice, ledger.height() + 1).unwrap(); + ledger.apply_locally_mined_block(block).unwrap(); + } + + assert_eq!( + ledger.recommended_mine_required_burn_amount_with_multiplier(10_000), + MICRO_IUNA + ); + } + + #[test] + fn block_with_mine_action_requires_enough_burn_amount() { + let alice = Wallet::from_seed("mine-required-burn-block-alice"); + let mut ledger = ledger_with_allocation(&alice, 10 * MICRO_IUNA); + + let burn = ledger + .build_burn(&alice, MIN_MINE_REQUIRED_BURN, 0) + .unwrap(); + ledger.submit_transaction(burn.clone()).unwrap(); + let mine = ledger + .build_mine_with_required_burn(alice.address(), MAX_MINE_REQUIRED_BURN) + .unwrap(); + let mut prepared = ledger.prepare_next_block(alice.address(), 1).unwrap(); + prepared.reward = prepared.reward.checked_add(mine.fee()).unwrap(); + prepared.transactions.push(mine); + let vdf_output = run_vdf(prepared.vdf_seed(), prepared.vdf_rounds()); + let block = prepared.finish(&alice, vdf_output); + + let error = ledger.apply_block(block).unwrap_err(); + + assert!(format!("{error:#}").contains("requires")); + } + + #[test] + fn block_selection_includes_mine_action_when_cumulative_burns_meet_requirement() { + let alice = Wallet::from_seed("mine-cumulative-burn-alice"); + let mut ledger = ledger_with_allocation(&alice, 10 * MICRO_IUNA); + + for _ in 0..2 { + let burn = ledger.build_burn(&alice, MICRO_IUNA / 2, 0).unwrap(); + ledger.submit_transaction(burn).unwrap(); + } + let mine = ledger + .build_mine_with_required_burn(alice.address(), MICRO_IUNA) + .unwrap(); + ledger.submit_transaction(mine.clone()).unwrap(); + + let block = ledger.mine_next_block(&alice, 1).unwrap(); + + assert_eq!( + block.transactions.iter().filter(|tx| tx.is_burn()).count(), + 2 + ); + assert!( + block + .transactions + .iter() + .any(|tx| tx.signature() == mine.signature()) + ); + assert_eq!(block.reward, MINE_FINALIZER_FEE); + } + + #[test] 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); @@ -4247,6 +4494,125 @@ mod tests { } #[test] + fn block_selection_keeps_mine_action_pending_when_required_burn_is_not_met() { + let alice = Wallet::from_seed("mine-required-burn-pending-alice"); + let mut ledger = ledger_with_allocation(&alice, 10 * MICRO_IUNA); + + let burn = ledger + .build_burn(&alice, MIN_MINE_REQUIRED_BURN, 0) + .unwrap(); + ledger.submit_transaction(burn).unwrap(); + let mine = ledger + .build_mine_with_required_burn(alice.address(), MAX_MINE_REQUIRED_BURN) + .unwrap(); + ledger.submit_transaction(mine.clone()).unwrap(); + + let block = ledger.mine_next_block(&alice, 1).unwrap(); + + assert!(block.transactions.iter().any(Transaction::is_burn)); + assert!( + !block + .transactions + .iter() + .any(|tx| tx.signature() == mine.signature()) + ); + assert!( + ledger + .pending() + .iter() + .any(|tx| tx.signature() == mine.signature()) + ); + } + + #[test] + fn pending_mine_outputs_are_not_spendable_until_confirmed() { + let alice = Wallet::from_seed("pending-mine-spend-alice"); + let bob = Wallet::from_seed("pending-mine-spend-bob"); + let mut ledger = ledger_with_allocation(&alice, 10 * MICRO_IUNA); + + let mine = ledger + .build_mine_with_required_burn(alice.address(), MIN_MINE_REQUIRED_BURN) + .unwrap(); + let mine_outpoint = OutPoint { + txid: mine.signature().to_string(), + index: 0, + }; + ledger.submit_transaction(mine.clone()).unwrap(); + + assert!( + !ledger + .available_utxos_for_address(alice.address()) + .unwrap() + .iter() + .any(|(outpoint, _)| outpoint == &mine_outpoint) + ); + let pending_error = ledger + .build_transfer_with_inputs( + &alice, + bob.address(), + MIN_MINE_REQUIRED_BURN, + 0, + std::slice::from_ref(&mine_outpoint), + ) + .unwrap_err(); + assert!(format!("{pending_error:#}").contains("not spendable")); + + let burn = ledger + .build_burn(&alice, MIN_MINE_REQUIRED_BURN, 0) + .unwrap(); + ledger.submit_transaction(burn).unwrap(); + let block = ledger.mine_next_block(&alice, 1).unwrap(); + assert!( + block + .transactions + .iter() + .any(|tx| tx.signature() == mine.signature()) + ); + ledger.apply_locally_mined_block(block).unwrap(); + + assert!( + ledger + .available_utxos_for_address(alice.address()) + .unwrap() + .iter() + .any(|(outpoint, _)| outpoint == &mine_outpoint) + ); + ledger + .build_transfer_with_inputs( + &alice, + bob.address(), + MIN_MINE_REQUIRED_BURN, + 0, + std::slice::from_ref(&mine_outpoint), + ) + .unwrap(); + } + + #[test] + fn burns_built_after_pending_mine_do_not_spend_pending_mine_output() { + let alice = Wallet::from_seed("pending-mine-burn-alice"); + let mut ledger = ledger_with_allocation(&alice, 10 * MICRO_IUNA); + + let mine = ledger + .build_mine_with_required_burn(alice.address(), MIN_MINE_REQUIRED_BURN) + .unwrap(); + let mine_outpoint = OutPoint { + txid: mine.signature().to_string(), + index: 0, + }; + ledger.submit_transaction(mine).unwrap(); + + let burn = ledger + .build_burn(&alice, MIN_MINE_REQUIRED_BURN, 0) + .unwrap(); + + let Transaction::Burn { inputs, .. } = &burn else { + panic!("expected burn transaction"); + }; + assert!(!inputs.iter().any(|input| input.outpoint == mine_outpoint)); + } + + #[test] fn block_selection_can_include_multiple_mine_actions() { let alice = Wallet::from_seed("mine-multiple-actions-alice"); let mut ledger = ledger_with_allocation(&alice, 10 * MICRO_IUNA); diff --git a/src/main.rs b/src/main.rs @@ -87,7 +87,10 @@ async fn main() -> Result<()> { initial_burn_fee, ), }; - node_core.set_pow_mining_settings(ui_config.pow_mining_enabled, ui_config.pow_mine_fee)?; + node_core.set_pow_mining_settings( + ui_config.pow_mining_enabled, + ui_config.mine_required_burn_multiplier_bps, + )?; let node: SharedNode = Arc::new(Mutex::new(node_core)); let ui_config = Arc::new(Mutex::new(ui_config)); let mut peers = ui_config.lock().await.peers.clone(); diff --git a/tests/iuna.rs b/tests/iuna.rs @@ -10,8 +10,9 @@ use iuna::{ PeerDirection, TRANSACTION_BATCH_LIMIT, }, domain::{ - Amount, BLOCK_REWARD, DEFAULT_FEE_PER_BYTE, GenesisBurn, Ledger, MAX_BLOCK_BYTES, - MICRO_IUNA, TransactionSubmitOutcome, VDF_TARGET_BLOCK_MS, Wallet, run_vdf, verify_vdf, + Amount, BLOCK_REWARD, DEFAULT_FEE_PER_BYTE, DEFAULT_MINE_REQUIRED_BURN_MULTIPLIER_BPS, + GenesisBurn, Ledger, MAX_BLOCK_BYTES, MICRO_IUNA, MIN_MINE_REQUIRED_BURN, + TransactionSubmitOutcome, VDF_TARGET_BLOCK_MS, Wallet, run_vdf, verify_vdf, }, }; use tempfile::tempdir; @@ -305,24 +306,30 @@ fn block_with_forged_transaction_is_rejected() { } #[test] -fn mine_action_introduces_two_iuna_and_splits_one_to_miner_one_to_finalizer() { +fn mine_action_uses_required_burn_reward_and_finalizer_fee() { 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); + submit_burn(&mut ledger, &alice, MICRO_IUNA); let mine = ledger.build_mine(alice.address()).unwrap(); - assert_eq!(mine.amount(), MICRO_IUNA); + assert_eq!(mine.amount(), MIN_MINE_REQUIRED_BURN); 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, MICRO_IUNA); ledger.apply_block(block).unwrap(); - assert_eq!(ledger.balance_of(alice.address()), 3 * MICRO_IUNA); + assert_eq!( + ledger.balance_of(alice.address()), + 2 * MICRO_IUNA + MIN_MINE_REQUIRED_BURN + ); assert!(!ledger.submit_transaction(mine).unwrap()); - assert_eq!(ledger.balance_of(alice.address()), 3 * MICRO_IUNA); + assert_eq!( + ledger.balance_of(alice.address()), + 2 * MICRO_IUNA + MIN_MINE_REQUIRED_BURN + ); } #[test] @@ -334,7 +341,7 @@ fn mine_action_protocol_fee_is_paid_to_block_finalizer() { let mut ledger = Ledger::new(allocations, 10); let mine = ledger.build_mine(alice.address()).unwrap(); - assert_eq!(mine.amount(), MICRO_IUNA); + assert_eq!(mine.amount(), MIN_MINE_REQUIRED_BURN); assert_eq!(mine.fee(), MICRO_IUNA); ledger.submit_transaction(mine).unwrap(); submit_burn(&mut ledger, &bob, MICRO_IUNA); @@ -343,7 +350,7 @@ fn mine_action_protocol_fee_is_paid_to_block_finalizer() { assert_eq!(block.reward, MICRO_IUNA); ledger.apply_block(block).unwrap(); - assert_eq!(ledger.balance_of(alice.address()), MICRO_IUNA); + assert_eq!(ledger.balance_of(alice.address()), MIN_MINE_REQUIRED_BURN); assert_eq!(ledger.balance_of(bob.address()), 2 * MICRO_IUNA); } @@ -820,7 +827,7 @@ fn pow_only_node_gossips_mine_action_to_pob_only_finalizer() { DEFAULT_FEE_PER_BYTE, ); bob_node - .set_pow_mining_settings(true, DEFAULT_FEE_PER_BYTE) + .set_pow_mining_settings(true, DEFAULT_MINE_REQUIRED_BURN_MULTIPLIER_BPS) .unwrap(); let bob_plan = (1..10_000) diff --git a/tests/properties.rs b/tests/properties.rs @@ -3,8 +3,8 @@ use std::collections::{BTreeMap, BTreeSet}; use iuna::{ app::{InMemoryNetwork, NodeCore}, domain::{ - Amount, ChainSnapshot, GenesisBurn, Ledger, MICRO_IUNA, MINE_REWARD, OutPoint, Transaction, - TxInput, TxOutput, VDF_TARGET_BLOCK_MS, Wallet, hex_hash, verify_vdf, + Amount, ChainSnapshot, GenesisBurn, Ledger, MICRO_IUNA, MINE_FINALIZER_FEE, OutPoint, + Transaction, TxInput, TxOutput, VDF_TARGET_BLOCK_MS, Wallet, hex_hash, verify_vdf, }, }; @@ -166,9 +166,12 @@ fn expected_confirmed_supply(snapshot: &ChainSnapshot) -> Amount { supply = supply.checked_sub(*amount).expect("burn is funded"); supply = supply.checked_sub(*fee).expect("burn fee is funded"); } - Transaction::Mine { output, .. } => { + Transaction::Mine { + required_burn_amount, + .. + } => { supply = supply - .checked_add(output.amount) + .checked_add(*required_burn_amount) .expect("mine output does not overflow supply"); } } @@ -260,15 +263,18 @@ fn apply_reference_transaction( transaction: &Transaction, utxos: &mut BTreeMap<OutPoint, TxOutput>, ) { - if let Transaction::Mine { output, fee, .. } = transaction { - assert_eq!( - output - .amount - .checked_add(*fee) - .expect("mine output plus fee does not overflow"), - MINE_REWARD - ); - insert_reference_outputs(transaction, std::slice::from_ref(output), utxos); + if let Transaction::Mine { + recipient, + required_burn_amount, + .. + } = transaction + { + let output = TxOutput { + address: recipient.clone(), + amount: *required_burn_amount, + }; + assert_eq!(transaction.fee(), MINE_FINALIZER_FEE); + insert_reference_outputs(transaction, &[output], utxos); return; } @@ -288,13 +294,12 @@ fn apply_reference_transaction( .expect("reference input total does not overflow"); } - let output_total = reference_outputs(transaction) - .iter() - .fold(0_u64, |total, output| { - total - .checked_add(output.amount) - .expect("reference output total does not overflow") - }); + let outputs = reference_outputs(transaction); + let output_total = outputs.iter().fold(0_u64, |total, output| { + total + .checked_add(output.amount) + .expect("reference output total does not overflow") + }); let burn_amount = match transaction { Transaction::Burn { amount, .. } => *amount, Transaction::Transfer { .. } | Transaction::Mine { .. } => 0, @@ -306,7 +311,7 @@ fn apply_reference_transaction( .expect("reference outputs plus burn do not overflow"); assert_eq!(input_total, required); - insert_reference_outputs(transaction, reference_outputs(transaction), utxos); + insert_reference_outputs(transaction, &outputs, utxos); } fn insert_reference_outputs( @@ -333,11 +338,18 @@ fn reference_inputs(transaction: &Transaction) -> &[TxInput] { } } -fn reference_outputs(transaction: &Transaction) -> &[TxOutput] { +fn reference_outputs(transaction: &Transaction) -> Vec<TxOutput> { match transaction { - Transaction::Transfer { outputs, .. } => outputs, - Transaction::Burn { change, .. } => change, - Transaction::Mine { output, .. } => std::slice::from_ref(output), + Transaction::Transfer { outputs, .. } => outputs.clone(), + Transaction::Burn { change, .. } => change.clone(), + Transaction::Mine { + recipient, + required_burn_amount, + .. + } => vec![TxOutput { + address: recipient.clone(), + amount: *required_burn_amount, + }], } } diff --git a/www/assets/iuna-ui.js b/www/assets/iuna-ui.js @@ -65,9 +65,9 @@ window.iunaApp = function iunaApp() { burnFeeDraft: "0.000001", miningEnabled: false, powMiningEnabled: false, - powMineFee: 1000000, - powMineFeeDraft: "1", - powMineFeeDirty: false, + powRequiredBurnMultiplier: 0.8, + powRequiredBurnMultiplierDraft: "0.8", + powRequiredBurnMultiplierDirty: false, burnAmountDirty: false, transferTo: "", transferAmount: null, @@ -545,13 +545,14 @@ window.iunaApp = function iunaApp() { this.burnFee = status.mining?.automatic_burn_fee ?? this.burnFee; this.miningEnabled = status.mining?.automatic ?? this.miningEnabled; this.powMiningEnabled = status.mining?.pow_mining_enabled ?? this.powMiningEnabled; - this.powMineFee = status.mining?.automatic_pow_mine_fee ?? this.powMineFee; + this.powRequiredBurnMultiplier = + (status.mining?.mine_required_burn_multiplier_bps ?? Math.round(this.powRequiredBurnMultiplier * 10000)) / 10000; if (!this.burnAmountDirty) { this.burnAmountDraft = this.amountLabel(this.burnAmount); this.burnFeeDraft = this.amountLabel(this.burnFee); } - if (!this.powMineFeeDirty) { - this.powMineFeeDraft = this.amountLabel(this.powMineFee); + if (!this.powRequiredBurnMultiplierDirty) { + this.powRequiredBurnMultiplierDraft = this.powRequiredBurnMultiplier.toString(); } this.lastUpdated = new Date(); this.scheduleFeeEstimates(); @@ -1039,14 +1040,17 @@ window.iunaApp = function iunaApp() { async setPowMiningEnabled(enabled) { const previous = this.powMiningEnabled; + const multiplier = this.powRequiredBurnMultiplierValue(); try { this.powMiningEnabled = enabled; await this.postForm( "/api/settings/pow-mining", - { enabled }, + { enabled, multiplier_bps: Math.round(multiplier * 10000) }, enabled ? "PoW mining turned on" : "PoW mining turned off" ); - this.powMineFeeDirty = false; + this.powRequiredBurnMultiplier = multiplier; + this.powRequiredBurnMultiplierDraft = multiplier.toString(); + this.powRequiredBurnMultiplierDirty = false; } catch (error) { this.powMiningEnabled = previous; this.showFlash(error.message, "error"); @@ -1054,15 +1058,18 @@ window.iunaApp = function iunaApp() { }, async savePowMining() { + const multiplier = this.powRequiredBurnMultiplierValue(); try { await this.postForm( "/api/settings/pow-mining", - { enabled: this.powMiningEnabled }, + { enabled: this.powMiningEnabled, multiplier_bps: Math.round(multiplier * 10000) }, this.powMiningEnabled ? "PoW mining settings saved" : `Mine settings saved while off` ); - this.powMineFeeDirty = false; + this.powRequiredBurnMultiplier = multiplier; + this.powRequiredBurnMultiplierDraft = multiplier.toString(); + this.powRequiredBurnMultiplierDirty = false; } catch (error) { this.showFlash(error.message, "error"); } @@ -1126,17 +1133,18 @@ window.iunaApp = function iunaApp() { return this.parseiunaAmount(this.burnFeeDraft); }, - powMineFeeValue() { - try { - return this.parseiunaAmount(this.powMineFeeDraft); - } catch { - return this.powMineFee; - } + powRequiredBurnMultiplierValue() { + const value = Number(this.powRequiredBurnMultiplierDraft); + if (!Number.isFinite(value)) return this.powRequiredBurnMultiplier; + return Math.min(1, Math.max(0.1, value)); + }, + + powRequiredBurnMultiplierBps() { + return Math.round(this.powRequiredBurnMultiplierValue() * 10000); }, powMineNetReward() { - const reward = Math.max(0, Math.trunc(Number(this.status.chain?.mine_reward ?? 0))); - return Math.max(0, reward - (this.feeEstimates.mine?.fee ?? this.powMineFeeValue())); + return Math.max(0, Math.trunc(Number(this.status.mining?.automatic_pow_required_burn_amount ?? 0))); }, autoPowStatusLabel() {