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