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:
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() {