iuna

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

commit dd4c74b397ba70e2834efbbe5477215e4d7af941
parent 2f1d5baff6679c52324edca7246a8ea0f2bc2c79
Author: Joris Hartog <jorishartog@hotmail.com>
Date:   Fri, 24 Jul 2026 22:34:36 +0200

Add miner-chosen mine fees

Diffstat:
Massets/luun-ui.js | 43++++++++++++++++++++++++++++++++++++++++++-
Msrc/adapters/config_store.rs | 18++++++++++++++++--
Msrc/adapters/http.rs | 99+++++++++++++++++++++++++++++++++++++++++++++++++++++++------------------------
Msrc/adapters/p2p.rs | 23++++++++++++++++++++---
Msrc/app.rs | 77+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------
Msrc/domain.rs | 163+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------
Msrc/main.rs | 2+-
Mtests/luun.rs | 82+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
8 files changed, 453 insertions(+), 54 deletions(-)

diff --git a/assets/luun-ui.js b/assets/luun-ui.js @@ -28,6 +28,9 @@ window.luunApp = function luunApp() { burnFeeDraft: "1", miningEnabled: false, powMiningEnabled: false, + powMineFee: 10000, + powMineFeeDraft: "0.01", + powMineFeeDirty: false, burnAmountDirty: false, transferTo: "", transferAmount: null, @@ -282,10 +285,14 @@ window.luunApp = function luunApp() { 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; if (!this.burnAmountDirty) { this.burnAmountDraft = this.amountLabel(this.burnAmount); this.burnFeeDraft = this.amountLabel(this.burnFee); } + if (!this.powMineFeeDirty) { + this.powMineFeeDraft = this.amountLabel(this.powMineFee); + } this.lastUpdated = new Date(); } catch (error) { this.showFlash(error.message, "error"); @@ -512,22 +519,56 @@ window.luunApp = function luunApp() { async setPowMiningEnabled(enabled) { const previous = this.powMiningEnabled; try { + const fee = this.parseLuunAmount(this.powMineFeeDraft); this.powMiningEnabled = enabled; await this.postForm( "/api/settings/pow-mining", - { enabled }, + { enabled, fee }, 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"); } }, + async savePowMining() { + try { + const fee = this.parseLuunAmount(this.powMineFeeDraft); + this.powMineFeeDraft = this.amountLabel(fee); + await this.postForm( + "/api/settings/pow-mining", + { enabled: this.powMiningEnabled, fee }, + this.powMiningEnabled + ? `Mine fee set to ${this.amountLabel(fee)} LUUN` + : `Mine settings saved while off` + ); + this.powMineFeeDirty = false; + this.powMineFee = fee; + } catch (error) { + this.showFlash(error.message, "error"); + } + }, + automaticBurnFeeDraft() { return this.parseLuunAmount(this.burnFeeDraft); }, + powMineFeeValue() { + try { + return this.parseLuunAmount(this.powMineFeeDraft); + } catch { + return this.powMineFee; + } + }, + + powMineNetReward() { + const reward = Math.max(0, Math.trunc(Number(this.status.chain?.mine_reward ?? 0))); + return Math.max(0, reward - this.powMineFeeValue()); + }, + amountLabel(value) { const microluun = Math.max(0, Math.trunc(Number(value) || 0)); const whole = Math.floor(microluun / 1000000); diff --git a/src/adapters/config_store.rs b/src/adapters/config_store.rs @@ -7,7 +7,7 @@ use std::{ use anyhow::{Context, Result, bail}; use serde::{Deserialize, Serialize}; -use crate::domain::{Amount, DEFAULT_TRANSACTION_FEE, MICRO_LUUN}; +use crate::domain::{Amount, DEFAULT_MINE_FEE, DEFAULT_TRANSACTION_FEE, MICRO_LUUN}; const CONFIG_FILE_VERSION: u32 = 1; const AMOUNT_UNIT_MICROLUUN: &str = "microluun"; @@ -20,6 +20,7 @@ pub struct UiConfig { pub pow_mining_enabled: bool, pub burn_per_block: Amount, pub burn_fee: Amount, + pub pow_mine_fee: Amount, pub peers: Vec<String>, } @@ -31,6 +32,7 @@ impl Default for UiConfig { pow_mining_enabled: false, burn_per_block: 0, burn_fee: DEFAULT_BURN_FEE, + pow_mine_fee: DEFAULT_MINE_FEE, peers: Vec::new(), } } @@ -51,6 +53,8 @@ struct ConfigFile { #[serde(default = "default_burn_fee")] burn_fee: Amount, #[serde(default)] + pow_mine_fee: Option<Amount>, + #[serde(default)] peers: Vec<String>, } @@ -82,6 +86,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: config.burn_fee, + pow_mine_fee: Some(config.pow_mine_fee), peers: config.peers.clone(), }; let bytes = serde_json::to_vec_pretty(&stored).context("failed to serialize config file")?; @@ -119,6 +124,10 @@ fn load(path: &Path) -> Result<UiConfig> { pow_mining_enabled: stored.pow_mining_enabled, burn_per_block: stored.burn_per_block.saturating_mul(scale), burn_fee: stored.burn_fee.saturating_mul(scale), + pow_mine_fee: stored + .pow_mine_fee + .map(|fee| fee.saturating_mul(scale)) + .unwrap_or(DEFAULT_MINE_FEE), peers: stored.peers, }) } @@ -140,7 +149,7 @@ mod tests { use crate::domain::MICRO_LUUN; - use super::{DEFAULT_BURN_FEE, UiConfig, load_or_create, save}; + use super::{DEFAULT_BURN_FEE, DEFAULT_MINE_FEE, UiConfig, load_or_create, save}; #[test] fn creates_default_config_file() { @@ -158,6 +167,7 @@ mod tests { assert!(stored.contains("\"pow_mining_enabled\": false")); assert!(stored.contains("\"burn_per_block\": 0")); assert!(stored.contains("\"burn_fee\": 1000000")); + assert!(stored.contains("\"pow_mine_fee\": 10000")); assert!(stored.contains("\"peers\": []")); } @@ -174,6 +184,7 @@ mod tests { pow_mining_enabled: true, burn_per_block: 50 * MICRO_LUUN, burn_fee: 3 * MICRO_LUUN, + pow_mine_fee: 2 * MICRO_LUUN, peers: vec!["127.0.0.1:9444".to_string()], }, ) @@ -185,6 +196,7 @@ mod tests { assert!(config.pow_mining_enabled); assert_eq!(config.burn_per_block, 50 * MICRO_LUUN); assert_eq!(config.burn_fee, 3 * MICRO_LUUN); + assert_eq!(config.pow_mine_fee, 2 * MICRO_LUUN); assert_eq!(config.peers, vec!["127.0.0.1:9444"]); } @@ -210,6 +222,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_MINE_FEE); assert_eq!(config.peers, vec!["127.0.0.1:9444"]); } @@ -235,5 +248,6 @@ mod tests { assert!(config.mining_enabled); assert_eq!(config.burn_per_block, 2 * MICRO_LUUN); assert_eq!(config.burn_fee, MICRO_LUUN); + assert_eq!(config.pow_mine_fee, DEFAULT_MINE_FEE); } } diff --git a/src/adapters/http.rs b/src/adapters/http.rs @@ -50,6 +50,7 @@ struct BurnSettingsForm { #[derive(Debug, Deserialize)] struct PowMiningForm { enabled: bool, + fee: Amount, } #[derive(Debug, Deserialize)] @@ -368,7 +369,7 @@ async fn api_pow_mining_form( State(state): State<HttpState>, Form(form): Form<PowMiningForm>, ) -> Json<ActionResponse> { - let result = set_pow_mining(&state, form.enabled).await; + let result = set_pow_mining(&state, form.enabled, form.fee).await; action_json(result) } @@ -456,21 +457,23 @@ async fn persist_burn_settings_config( config_store::save(config_path, &config) } -async fn set_pow_mining(state: &HttpState, enabled: bool) -> Result<()> { +async fn set_pow_mining(state: &HttpState, enabled: bool, fee: Amount) -> Result<()> { { let mut node = state.node.lock().await; - node.set_pow_mining_enabled(enabled); + node.set_pow_mining_settings(enabled, fee)?; } - persist_pow_mining_config(&state.ui_config, &state.config_path, enabled).await + persist_pow_mining_config(&state.ui_config, &state.config_path, enabled, fee).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_store::save(config_path, &config) } @@ -574,6 +577,7 @@ fn wallet_transaction_row( Transaction::Mine { output, difficulty_bits, + fee, signature, .. } if output.address == wallet => Some(WalletTransactionRow { @@ -581,7 +585,7 @@ fn wallet_transaction_row( from: "pow".to_string(), to: Some(output.address.clone()), amount: output.amount, - fee: 0, + fee: *fee, inputs: Vec::new(), outputs: vec![output.clone()], change: Vec::new(), @@ -677,6 +681,7 @@ fn ui_transaction( Transaction::Mine { output, difficulty_bits, + fee, signature, .. } => UiTransaction { @@ -684,7 +689,7 @@ fn ui_transaction( from: "pow".to_string(), to: Some(output.address.clone()), amount: output.amount, - fee: 0, + fee: *fee, inputs: Vec::new(), outputs: vec![output.clone()], change: Vec::new(), @@ -1062,7 +1067,9 @@ const INDEX_HTML: &str = r#"<!doctype html> .mining-form { width: 100%; display: flex; flex-wrap: wrap; gap: 10px; align-items: end; } .burn-fields { display: flex; flex-wrap: wrap; gap: 10px; align-items: end; } .mine-action-row { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 12px; align-items: center; } - .mine-stats { display: grid; grid-template-columns: repeat(3, minmax(112px, 1fr)); gap: 8px; min-width: 0; } + .mine-settings-form { display: grid; gap: 10px; } + .mine-fee-fields { display: flex; flex-wrap: wrap; gap: 10px; align-items: end; } + .mine-stats { display: grid; grid-template-columns: repeat(4, minmax(112px, 1fr)); gap: 8px; min-width: 0; } .mine-stat { min-width: 0; border: 1px solid #2f363c; border-radius: 8px; padding: 9px 10px; background: #111316; } .mine-stat-label { display: flex; gap: 5px; align-items: center; color: #879198; font-size: 10px; font-weight: 850; text-transform: uppercase; } .mine-stat-value { margin-top: 5px; color: #dce4e7; font-size: 14px; font-weight: 850; font-variant-numeric: tabular-nums; overflow-wrap: anywhere; } @@ -1173,7 +1180,7 @@ const INDEX_HTML: &str = r#"<!doctype html> .block-card { flex-basis: 108px; } } </style> - <script defer src="/assets/luun-ui.js?v=45"></script> + <script defer src="/assets/luun-ui.js?v=46"></script> <script defer src="/assets/alpine.min.js"></script> </head> <body x-data="luunApp()" x-init="init()" @keydown.window.escape="closeModals()" x-cloak> @@ -1325,28 +1332,38 @@ const INDEX_HTML: &str = r#"<!doctype html> </div> <div class="panel"> <h3>Mine</h3> - <div class="panel-description">Mine with PoW to introduce new LUUN. Accepted mine actions pay the fixed mine reward to this wallet when included in a block.</div> - <div class="mine-action-row"> - <div class="mine-stats" aria-label="PoW issuance settings"> - <div class="mine-stat"> - <div class="mine-stat-label">Reward</div> - <div class="mine-stat-value money">LUUN <span x-text="amountLabel(status.chain?.mine_reward ?? 0)"></span></div> - </div> - <div class="mine-stat"> - <div class="mine-stat-label">Difficulty <button class="info-button" type="button" @click="openPowDifficultyInfo" title="How difficulty is adjusted" aria-label="How PoW difficulty is adjusted">i</button></div> - <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 class="mine-stat"> - <div class="mine-stat-label">Status</div> - <div class="mine-stat-value" x-text="powMiningEnabled ? 'On' : 'Off'"></div> + <div class="panel-description">Mine with PoW to introduce new LUUN. The miner chooses the fee paid to the block finalizer; the rest of the fixed mine reward goes to this wallet.</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-value money">LUUN <span x-text="amountLabel(status.chain?.mine_reward ?? 0)"></span></div> + </div> + <div class="mine-stat"> + <div class="mine-stat-label">Finalizer fee</div> + <div class="mine-stat-value">LUUN <span x-text="amountLabel(powMineFeeValue())"></span></div> + </div> + <div class="mine-stat"> + <div class="mine-stat-label">Miner receives</div> + <div class="mine-stat-value money">LUUN <span x-text="amountLabel(powMineNetReward())"></span></div> + </div> + <div class="mine-stat"> + <div class="mine-stat-label">Difficulty <button class="info-button" type="button" @click="openPowDifficultyInfo" title="How difficulty is adjusted" aria-label="How PoW difficulty is adjusted">i</button></div> + <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"> + <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> - <label class="toggle-switch" :class="{ active: powMiningEnabled }" title="Automatically queue one PoW mine action per chain tip"> - <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<input x-model="powMineFeeDraft" @input="powMineFeeDirty = true" type="number" min="0" step="0.000001"></label> + <button class="primary" type="submit">Save</button> + </div> + </form> </div> </div> </section> @@ -1699,7 +1716,7 @@ mod tests { use crate::{ adapters::{config_store, config_store::UiConfig}, - domain::{Block, MICRO_LUUN, OutPoint, Transaction, Wallet}, + domain::{Block, Ledger, MICRO_LUUN, MINE_REWARD, OutPoint, Transaction, Wallet}, }; use super::{ @@ -1752,6 +1769,27 @@ mod tests { assert_eq!(rows[0].direction, "received"); } + #[test] + fn mine_transaction_views_include_miner_chosen_fee() { + let alice = Wallet::from_seed("wallet-mine-fee-alice"); + let ledger = Ledger::new(BTreeMap::new(), 1); + let mine_fee = MICRO_LUUN / 5; + let mine = ledger + .build_mine_with_fee(alice.address(), mine_fee) + .unwrap(); + let chain = vec![fake_block(1, vec![mine.clone()])]; + let outputs = super::known_output_index(&BTreeMap::new(), &chain, &[]); + + let rows = wallet_transaction_rows(alice.address(), Vec::new(), &chain, &outputs); + 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); + } + fn fake_block(height: u64, transactions: Vec<Transaction>) -> Block { Block { height, @@ -1805,12 +1843,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, 2 * MICRO_LUUN) .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_LUUN); } #[test] diff --git a/src/adapters/p2p.rs b/src/adapters/p2p.rs @@ -360,13 +360,17 @@ impl GossipNetwork { } async fn prepare_gossip(&self, envelopes: Vec<GossipEnvelope>) -> Vec<GossipEnvelope> { + let mut full_transactions = Vec::new(); let mut txs = Vec::new(); let mut blocks = Vec::new(); let mut passthrough = Vec::new(); for envelope in envelopes { match envelope { - GossipEnvelope::Transaction(tx) => txs.push(tx.signature().to_string()), + GossipEnvelope::Transaction(tx) => { + txs.push(tx.signature().to_string()); + full_transactions.push(tx); + } GossipEnvelope::Transactions { transactions } => { txs.extend( transactions @@ -397,6 +401,12 @@ impl GossipNetwork { } } + if !full_transactions.is_empty() { + passthrough.push(GossipEnvelope::Transactions { + transactions: full_transactions, + }); + } + txs.sort(); txs.dedup(); blocks.sort_by(|left, right| { @@ -1929,7 +1939,7 @@ mod tests { } #[tokio::test] - async fn tx_and_block_gossip_is_announced_as_inventory() { + async fn tx_and_block_gossip_sends_full_transaction_and_inventory() { let alice = Wallet::from_seed("inventory-alice"); let allocations = allocations(std::slice::from_ref(&alice), 1_000); let node = Arc::new(tokio::sync::Mutex::new(node("alice", alice, allocations))); @@ -1968,8 +1978,15 @@ mod tests { ]) .await; - assert_eq!(prepared.len(), 1); + assert_eq!(prepared.len(), 2); match &prepared[0] { + GossipEnvelope::Transactions { transactions } => { + assert_eq!(transactions.len(), 1); + assert_eq!(transactions[0].signature(), tx_signature); + } + other => panic!("expected transaction batch, got {other:?}"), + } + match &prepared[1] { GossipEnvelope::Inventory { txs, blocks } => { assert_eq!(txs, &[tx_signature]); assert_eq!(blocks.len(), 1); diff --git a/src/app.rs b/src/app.rs @@ -4,13 +4,13 @@ use std::{ time::{SystemTime, UNIX_EPOCH}, }; -use anyhow::{Context, Result}; +use anyhow::{Context, Result, bail}; use serde::{Deserialize, Serialize}; use tokio::sync::Mutex; use crate::domain::{ - Amount, Block, ChainSnapshot, ChainStatus, DEFAULT_TRANSACTION_FEE, Ledger, OutPoint, - PreparedBlock, Transaction, VDF_TARGET_BLOCK_MS, Wallet, run_vdf, + Amount, Block, ChainSnapshot, ChainStatus, DEFAULT_MINE_FEE, DEFAULT_TRANSACTION_FEE, Ledger, + OutPoint, PreparedBlock, Transaction, VDF_TARGET_BLOCK_MS, Wallet, run_vdf, }; pub type SharedNode = Arc<Mutex<NodeCore>>; @@ -122,6 +122,7 @@ pub struct MiningStatus { pub pow_mining_enabled: bool, pub burn_per_block: Amount, pub automatic_burn_fee: Amount, + pub automatic_pow_mine_fee: Amount, pub vdf_rounds: u32, pub vdf_target_block_ms: u64, pub current_leader: Option<String>, @@ -151,6 +152,7 @@ pub struct NodeCore { ledger: Ledger, automatic_mining_enabled: bool, pow_mining_enabled: bool, + pow_mine_fee: Amount, burn_per_block: Amount, burn_fee: Amount, last_auto_burn_height: Option<u64>, @@ -200,6 +202,7 @@ impl NodeCore { ledger, automatic_mining_enabled, pow_mining_enabled: false, + pow_mine_fee: DEFAULT_MINE_FEE, burn_per_block, burn_fee, last_auto_burn_height: None, @@ -364,6 +367,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, vdf_rounds: self.ledger.vdf_rounds(), vdf_target_block_ms: VDF_TARGET_BLOCK_MS, current_leader, @@ -409,6 +413,18 @@ impl NodeCore { } } + pub fn set_pow_mining_settings(&mut self, enabled: bool, fee: Amount) -> Result<()> { + if fee > self.ledger.status().mine_reward { + bail!("mine fee cannot exceed mine reward"); + } + self.pow_mining_enabled = enabled; + self.pow_mine_fee = fee; + if !enabled { + self.last_auto_pow_mine_anchor = None; + } + Ok(()) + } + pub fn burn(&mut self, amount: Amount) -> Result<Transaction> { self.burn_with_fee(amount, 0) } @@ -455,7 +471,9 @@ impl NodeCore { } pub fn mine_pow_reward(&mut self) -> Result<Transaction> { - let tx = self.ledger.build_mine(self.wallet.address())?; + let tx = self + .ledger + .build_mine_with_fee(self.wallet.address(), self.pow_mine_fee)?; if self.ledger.submit_transaction(tx.clone())? { self.outbox.push(GossipEnvelope::Transaction(tx.clone())); } @@ -576,7 +594,9 @@ impl NodeCore { { return Ok(None); } - let tx = self.ledger.build_mine(self.wallet.address())?; + let tx = self + .ledger + .build_mine_with_fee(self.wallet.address(), self.pow_mine_fee)?; if self.ledger.submit_transaction(tx.clone())? { self.last_auto_pow_mine_anchor = Some(anchor); self.outbox.push(GossipEnvelope::Transaction(tx.clone())); @@ -870,7 +890,7 @@ pub fn now_ms() -> u64 { mod tests { use std::collections::BTreeMap; - use crate::domain::{Transaction, Wallet}; + use crate::domain::{DEFAULT_MINE_FEE, Transaction, Wallet}; use super::{NodeConfig, NodeCore}; @@ -924,6 +944,7 @@ mod tests { anchor, output, difficulty_bits, + fee, .. } = first_mine else { @@ -931,6 +952,7 @@ mod tests { }; assert_eq!(anchor, &node.chain().last().unwrap().hash); assert_eq!(output.address, wallet.address()); + assert_eq!(*fee, DEFAULT_MINE_FEE); assert_eq!( *difficulty_bits, node.ledger().current_mine_difficulty_bits() @@ -941,6 +963,49 @@ mod tests { assert!(second.pow_mined.is_none()); assert_eq!(node.ledger().pending().len(), 1); } + + #[test] + fn automatic_pow_mining_uses_configured_mine_fee() { + let wallet = Wallet::from_seed("automatic-pow-mining-fee-wallet"); + let mut node = NodeCore::new(NodeConfig { + wallet, + genesis_allocations: BTreeMap::new(), + vdf_rounds: 10, + burn_per_block: 0, + burn_fee: 0, + }); + + let configured_fee = DEFAULT_MINE_FEE * 2; + node.set_pow_mining_settings(true, configured_fee).unwrap(); + let plan = node.prepare_automatic_mining(1); + let mine = plan.pow_mined.expect("PoW should be queued"); + + assert_eq!(mine.fee(), configured_fee); + assert_eq!( + mine.amount(), + node.ledger().status().mine_reward - configured_fee + ); + assert_eq!(node.status().mining.automatic_pow_mine_fee, configured_fee); + } + + #[test] + fn automatic_pow_mining_rejects_fee_above_reward() { + let wallet = Wallet::from_seed("automatic-pow-mining-too-high-fee-wallet"); + let mut node = NodeCore::new(NodeConfig { + wallet, + genesis_allocations: BTreeMap::new(), + vdf_rounds: 10, + burn_per_block: 0, + burn_fee: 0, + }); + + let error = node + .set_pow_mining_settings(true, node.ledger().status().mine_reward + 1) + .unwrap_err(); + + assert!(format!("{error:#}").contains("mine fee cannot exceed mine reward")); + assert!(!node.status().mining.pow_mining_enabled); + } } #[derive(Debug, Default)] diff --git a/src/domain.rs b/src/domain.rs @@ -9,6 +9,7 @@ pub type Amount = u64; pub const MICRO_LUUN: Amount = 1_000_000; pub const BLOCK_REWARD: Amount = 100 * MICRO_LUUN; pub const MINE_REWARD: Amount = MICRO_LUUN; +pub const DEFAULT_MINE_FEE: Amount = MINE_REWARD / 100; pub const DEFAULT_TRANSACTION_FEE: Amount = MICRO_LUUN; pub const MAX_BLOCK_BYTES: usize = 100_000; pub const VDF_TARGET_BLOCK_MS: u64 = 60_000; @@ -111,6 +112,8 @@ pub enum Transaction { anchor: String, nonce: u64, difficulty_bits: u32, + #[serde(default)] + fee: Amount, signature: String, }, } @@ -195,7 +198,7 @@ impl Transaction { pub fn fee(&self) -> Amount { match self { Self::Transfer { fee, .. } | Self::Burn { fee, .. } => *fee, - Self::Mine { .. } => 0, + Self::Mine { fee, .. } => *fee, } } @@ -254,8 +257,9 @@ impl Transaction { anchor, nonce, difficulty_bits, + fee, .. - } => mine_payload(output, anchor, *nonce, *difficulty_bits), + } => mine_payload(output, anchor, *nonce, *difficulty_bits, *fee), } } @@ -265,10 +269,11 @@ impl Transaction { anchor, nonce, difficulty_bits, + fee, signature, } = self { - let expected = mine_signature(output, anchor, *nonce, *difficulty_bits); + let expected = mine_signature(output, anchor, *nonce, *difficulty_bits, *fee); if *signature != expected { bail!("mine transaction proof hash is invalid"); } @@ -448,15 +453,32 @@ fn canonical_outputs(outputs: &[TxOutput]) -> String { .join("|") } -fn mine_payload(output: &TxOutput, anchor: &str, nonce: u64, difficulty_bits: u32) -> String { - format!( +fn mine_payload( + output: &TxOutput, + anchor: &str, + nonce: u64, + difficulty_bits: u32, + fee: Amount, +) -> String { + let payload = format!( "luun-mine:{}:{}:{}:{}", output.address, output.amount, anchor, nonce - ) + &format!(":{difficulty_bits}") + ) + &format!(":{difficulty_bits}"); + if fee == 0 { + payload + } else { + format!("{payload}:{fee}") + } } -fn mine_signature(output: &TxOutput, anchor: &str, nonce: u64, difficulty_bits: u32) -> String { - hex_hash(mine_payload(output, anchor, nonce, difficulty_bits)) +fn mine_signature( + output: &TxOutput, + anchor: &str, + nonce: u64, + difficulty_bits: u32, + fee: Amount, +) -> String { + hex_hash(mine_payload(output, anchor, nonce, difficulty_bits, fee)) } fn hash_meets_difficulty(hash: &str, difficulty_bits: u32) -> bool { @@ -1373,15 +1395,26 @@ impl Ledger { } pub fn build_mine(&self, recipient: impl Into<String>) -> Result<Transaction> { + self.build_mine_with_fee(recipient, 0) + } + + pub fn build_mine_with_fee( + &self, + recipient: impl Into<String>, + fee: Amount, + ) -> Result<Transaction> { let recipient = recipient.into(); + if fee > self.mine_reward { + bail!("mine transaction fee exceeds reward"); + } let output = TxOutput { address: recipient, - amount: self.mine_reward, + amount: self.mine_reward - fee, }; let anchor = self.tip().hash.clone(); let difficulty_bits = self.current_mine_difficulty_bits(); for nonce in 0..u64::MAX { - let signature = mine_signature(&output, &anchor, nonce, difficulty_bits); + let signature = mine_signature(&output, &anchor, nonce, difficulty_bits, fee); if !hash_meets_difficulty(&signature, difficulty_bits) { continue; } @@ -1390,6 +1423,7 @@ impl Ledger { anchor: anchor.clone(), nonce, difficulty_bits, + fee, signature, }; if self.has_transaction(transaction.signature()) { @@ -1757,10 +1791,16 @@ impl Ledger { output, anchor, difficulty_bits, + fee, .. } = transaction { - if output.amount != self.mine_reward { + 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 @@ -2662,6 +2702,34 @@ mod tests { ledger.apply_block(block).unwrap(); } + fn mine_with_output_and_fee( + ledger: &Ledger, + recipient: &str, + output_amount: Amount, + fee: 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 signature = mine_signature(&output, &anchor, nonce, difficulty_bits, fee); + if hash_meets_difficulty(&signature, difficulty_bits) { + return Transaction::Mine { + output, + anchor, + nonce, + difficulty_bits, + fee, + signature, + }; + } + } + panic!("expected to find mine proof"); + } + #[test] fn wallet_utxos_only_include_outputs_owned_by_address() { let alice = Wallet::from_seed("wallet-utxos-alice"); @@ -2938,6 +3006,79 @@ mod tests { } #[test] + fn mine_fee_cannot_exceed_reward() { + let alice = Wallet::from_seed("mine-fee-too-high-alice"); + let ledger = ledger_with_allocation(&alice, MICRO_LUUN); + + let error = ledger + .build_mine_with_fee(alice.address(), MINE_REWARD + 1) + .unwrap_err(); + + assert!(format!("{error:#}").contains("fee exceeds reward")); + } + + #[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_LUUN); + let forged = mine_with_output_and_fee(&ledger, alice.address(), MINE_REWARD, 1); + + let error = ledger.submit_transaction(forged).unwrap_err(); + + assert!(format!("{error:#}").contains("mine transaction reward is invalid")); + } + + #[test] + fn mine_fee_can_take_entire_reward_for_finalizer() { + let alice = Wallet::from_seed("mine-full-fee-alice"); + let mut ledger = ledger_with_allocation(&alice, MICRO_LUUN); + + let mine = ledger + .build_mine_with_fee(alice.address(), MINE_REWARD) + .unwrap(); + + assert_eq!(mine.amount(), 0); + assert_eq!(mine.fee(), MINE_REWARD); + 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"); + let mut ledger = ledger_with_allocation(&alice, 10 * MICRO_LUUN); + ledger.launch_profile.max_block_transactions = 2; + + let low_fee_mine = ledger + .build_mine_with_fee(alice.address(), MICRO_LUUN / 100) + .unwrap(); + let high_fee_mine = ledger + .build_mine_with_fee(alice.address(), MICRO_LUUN / 2) + .unwrap(); + let burn = ledger.build_burn(&alice, MICRO_LUUN, 0).unwrap(); + ledger.submit_transaction(low_fee_mine.clone()).unwrap(); + ledger.submit_transaction(high_fee_mine.clone()).unwrap(); + ledger.submit_transaction(burn).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()); + } + + #[test] fn mine_difficulty_increases_when_issuance_exceeds_target_window() { let alice = Wallet::from_seed("mine-difficulty-up-alice"); let mut ledger = ledger_with_allocation(&alice, 100 * MICRO_LUUN); diff --git a/src/main.rs b/src/main.rs @@ -61,7 +61,7 @@ async fn main() -> Result<()> { initial_burn_per_block, initial_burn_fee, ); - node_core.set_pow_mining_enabled(ui_config.pow_mining_enabled); + node_core.set_pow_mining_settings(ui_config.pow_mining_enabled, ui_config.pow_mine_fee)?; 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/luun.rs b/tests/luun.rs @@ -310,6 +310,31 @@ fn mine_action_introduces_one_luun_and_block_author_gets_fees_only() { } #[test] +fn mine_action_fee_is_chosen_by_pow_miner_and_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_LUUN); + + let mut ledger = Ledger::new(allocations, 10); + let mine_fee = MICRO_LUUN / 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); + ledger.submit_transaction(mine).unwrap(); + submit_burn(&mut ledger, &bob, MICRO_LUUN); + + let block = ledger.mine_next_block(&bob, 1).unwrap(); + assert_eq!(block.reward, mine_fee); + ledger.apply_block(block).unwrap(); + + assert_eq!(ledger.balance_of(alice.address()), MINE_REWARD - mine_fee); + assert_eq!(ledger.balance_of(bob.address()), MICRO_LUUN + mine_fee); +} + +#[test] fn forged_mine_action_cannot_introduce_luun() { let alice = Wallet::from_seed("forged-mine-alice"); let mut allocations = BTreeMap::new(); @@ -778,6 +803,63 @@ fn waiting_wallet_gossips_pending_burn_to_selected_leader() { } #[test] +fn pow_only_node_gossips_mine_action_to_pob_only_finalizer() { + let alice = Wallet::from_seed("pob-only-finalizer-alice"); + let bob = Wallet::from_seed("pow-only-miner-bob"); + let mut allocations = BTreeMap::new(); + allocations.insert(alice.address().to_string(), 2 * MICRO_LUUN); + let ledger = Ledger::new_with_genesis_burns( + allocations, + vec![GenesisBurn::new(alice.address(), MICRO_LUUN)], + 25, + ) + .unwrap(); + + assert_eq!( + ledger.expected_leader_for_next_block().as_deref(), + Some(alice.address()) + ); + let mut alice_node = NodeCore::from_ledger_with_burn_fee_and_enabled( + alice, + ledger.clone(), + true, + DEFAULT_BURN_PER_BLOCK, + MICRO_LUUN, + ); + let mut bob_node = NodeCore::from_ledger_with_burn_fee_and_enabled( + bob.clone(), + ledger, + false, + DEFAULT_BURN_PER_BLOCK, + MICRO_LUUN, + ); + bob_node + .set_pow_mining_settings(true, MICRO_LUUN / 100) + .unwrap(); + + let bob_plan = bob_node.prepare_automatic_mining(1); + let mine = bob_plan.pow_mined.expect("B should queue a mine action"); + assert_eq!( + bob_plan.skipped_reason.as_deref(), + Some("automatic mining is off") + ); + assert!(alice_node.ledger().pending().is_empty()); + + for envelope in bob_node.drain_outbox() { + alice_node.receive(envelope).unwrap(); + } + + assert!( + alice_node + .ledger() + .pending() + .iter() + .any(|pending| pending.signature() == mine.signature()), + "A did not receive B's mine action" + ); +} + +#[test] fn block_with_wrong_vdf_rounds_is_rejected() { let wallet = Wallet::from_seed("alice"); let mut genesis = BTreeMap::new();