commit 1fdd3fb252c109f04b577521268ac6b1ca8de38a
parent 8b029352111dd8ac0db2dd66de67614965cf6fbd
Author: Joris Hartog <jorishartog@hotmail.com>
Date: Wed, 22 Jul 2026 22:52:20 +0200
Add mining economics break-even
Diffstat:
8 files changed, 432 insertions(+), 57 deletions(-)
diff --git a/README.md b/README.md
@@ -75,13 +75,13 @@ cargo run -- --data-dir .luun-friend --p2p 0.0.0.0:9445 --http 127.0.0.1:18661 -
Friends who join after you start will adopt your genesis and current chain. The starter wallet begins with 100 spendable LUUN after the bootstrap burn and genesis reward, and `--genesis` starts it with a 100-LUUN automatic burn rate. After the starter mines additional block rewards, send friends LUUN from the UI; then they can choose a burn amount and compete for future blocks. Every joining node starts with a 0-LUUN automatic burn unless it is configured otherwise.
-The genesis block bootstraps the chain with a 1-LUUN burn from the starter wallet. Genesis turns that burn into launch tickets for blocks 1 through 3 so the chain can move until normal burn tickets mature. Burns included after genesis create one-shot tickets through a deterministic ticket lottery. The selected leader creates the next block content, signs a proof for the selected ticket, and runs a hash-chain VDF before gossiping the block.
+The genesis block bootstraps the chain with a 1-LUUN burn from the starter wallet. Genesis turns that burn into launch tickets for blocks 1 through 3 so the chain can move until normal burn tickets mature. Burns included after genesis create one-shot tickets through a deterministic weighted lottery: a burn of `X` among total eligible burn weight `Y` has `X / Y` chance for that block. The selected leader creates the next block content, signs a proof for the selected ticket, and runs a hash-chain VDF before gossiping the block.
Every non-genesis block must consume the selected eligible ticket, include at least one burn transaction, and fit under the 100kB serialized block limit. The VDF seed is bound to the parent hash and child height; the block hash separately commits to the miner, timestamp, miner payout, rounds, previous hash, leader proof, VDF output, and transactions.
The protocol targets 60-second blocks by retargeting the expected VDF rounds after each block. It uses a rolling average of recent block intervals and only moves the next round count by about 10% per block, so short bursts do not make the delay swing wildly. Every node derives the same next-round count from the validated chain.
-The base block reward is fixed at 100 LUUN, and miners collect transfer fees on top. Burns do not need an extra fee because the burned amount is already the cost of entering the leader lottery. The miner includes the best valid burn for liveness, then fills the remaining block space by fee-rate while respecting nonce and balance validity. The default burn is 0 LUUN per block, so new nodes can join before they own LUUN. Genesis starters begin at 100 LUUN per block; after another wallet has LUUN, raise its burn from the Mining screen.
+The base block reward is fixed at 100 LUUN, and miners collect transaction fees on top. The automatic burn setting has a burn amount and a fee; the burn amount is the ticket weight, while the fee is paid to the miner that includes it. The miner includes the best valid burn for liveness, then fills the remaining block space by fee-rate while respecting nonce and balance validity. The default burn is 0 LUUN per block with a 1-LUUN fee, so new nodes can join before they own LUUN. Genesis starters begin at 100 LUUN per block; after another wallet has LUUN, raise its burn from the Mining screen.
The measured VDF round count is only the initial delay. After the first blocks, the protocol steers rounds toward the 60-second target.
@@ -93,7 +93,7 @@ There is no default wallet seed in the binary. Keep the wallet file private; it
## Node Config
-Luun stores UI setup state, configured peers, and the configured automatic burn rate in `<data-dir>/config.json`. If `setup_complete` is false, the management UI opens the initial setup screen for wallet and peer setup. Completing setup and later runtime changes write the file through the HTTP API, so the choices follow the node data directory instead of a browser session.
+Luun stores UI setup state, configured peers, the configured automatic burn rate, and the automatic burn fee in `<data-dir>/config.json`. If `setup_complete` is false, the management UI opens the initial setup screen for wallet and peer setup. Completing setup and later runtime changes write the file through the HTTP API, so the choices follow the node data directory instead of a browser session.
## Chain Storage
@@ -101,7 +101,7 @@ Luun stores the latest validated `ChainSnapshot` in SQLite at `<data-dir>/chain.
## Architecture
-- `src/domain.rs`: wallet, fee-paying transfers, fee-free burns, balances, genesis burn bootstrap, 100-LUUN base rewards, 100kB blocks, rolling-window leader tickets, leader proofs, fork choice, launch profile, and VDF checks.
+- `src/domain.rs`: wallet, fee-paying transactions, balances, genesis burn bootstrap, 100-LUUN base rewards, 100kB blocks, rolling-window leader tickets, leader proofs, fork choice, launch profile, and VDF checks.
- `src/app.rs`: node use cases, automatic VDF-paced mining, peer bookkeeping, and an in-memory network harness.
- `src/adapters/http.rs`: HTTP management UI and status endpoint.
- `src/adapters/p2p.rs`: line-delimited JSON gossip, block-range catch-up, and chain snapshots over one TCP port.
diff --git a/assets/luun-ui.js b/assets/luun-ui.js
@@ -20,6 +20,8 @@ window.luunApp = function luunApp() {
setupFeedback: null,
burnAmount: 0,
burnAmountDraft: 0,
+ burnFee: 1,
+ burnFeeDraft: 1,
burnAmountDirty: false,
transferTo: "",
transferAmount: null,
@@ -262,8 +264,10 @@ window.luunApp = function luunApp() {
this.mempool = mempool;
this.peers = peers;
this.burnAmount = status.mining?.burn_per_block ?? this.burnAmount;
+ this.burnFee = status.mining?.automatic_burn_fee ?? this.burnFee;
if (!this.burnAmountDirty) {
this.burnAmountDraft = this.burnAmount;
+ this.burnFeeDraft = this.burnFee;
}
this.lastUpdated = new Date();
} catch (error) {
@@ -404,23 +408,83 @@ window.luunApp = function luunApp() {
async saveBurn() {
try {
const amount = Math.max(0, Math.trunc(Number(this.burnAmountDraft) || 0));
+ const fee = Math.max(0, Math.trunc(Number(this.burnFeeDraft) || 0));
this.burnAmountDraft = amount;
+ this.burnFeeDraft = fee;
await this.postForm(
"/api/settings/burn-per-block",
- { amount },
- `Burn rate set to ${amount} LUUN per block`
+ { amount, fee },
+ `Burn rate set to ${amount} LUUN per block with ${fee} fee`
);
this.burnAmountDirty = false;
this.burnAmount = amount;
+ this.burnFee = fee;
} catch (error) {
this.showFlash(error.message, "error");
}
},
automaticBurnFeeDraft() {
- const amount = Math.max(0, Math.trunc(Number(this.burnAmountDraft) || 0));
- const savedFee = this.status.mining?.automatic_burn_fee ?? 0;
- return Math.min(savedFee, Math.max(amount - 1, 0));
+ return Math.max(0, Math.trunc(Number(this.burnFeeDraft) || 0));
+ },
+
+ miningEconomics() {
+ return this.status.mining?.economics || {};
+ },
+
+ burnSliderMax() {
+ return Math.max(0, Math.trunc(Number(this.miningEconomics().slider_max ?? this.latestBlockReward())));
+ },
+
+ latestBlock() {
+ return this.blocks[0] || null;
+ },
+
+ latestBlockReward() {
+ const latest = this.latestBlock();
+ return Math.max(0, Math.trunc(Number(latest?.reward ?? this.status.chain?.block_reward ?? 0)));
+ },
+
+ ticketWindow() {
+ return Math.max(
+ 1,
+ Math.trunc(Number(this.status.launch_profile?.ticket_expiry_window_heights) || 1)
+ );
+ },
+
+ estimatedActiveBurnTotal() {
+ return Math.max(0, Math.trunc(Number(this.miningEconomics().estimated_active_burn ?? 0)));
+ },
+
+ breakEvenBurn() {
+ const microluun = Math.max(
+ 0,
+ Math.trunc(Number(this.miningEconomics().break_even_burn_microluun) || 0)
+ );
+ return microluun / 1000000;
+ },
+
+ breakEvenPercent() {
+ const max = this.burnSliderMax();
+ return max > 0 ? Math.min(100, Math.max(0, (this.breakEvenBurn() / max) * 100)) : 0;
+ },
+
+ breakEvenStyle() {
+ return `left: ${this.breakEvenPercent()}%`;
+ },
+
+ burnBreakEvenLabel() {
+ const marker = this.breakEvenBurn();
+ const payout = Math.max(0, Math.trunc(Number(this.miningEconomics().last_payout ?? this.latestBlockReward())));
+ const burned = this.estimatedActiveBurnTotal();
+ const fee = this.status.mining?.automatic_burn_fee ?? this.automaticBurnFeeDraft();
+ const window = this.ticketWindow();
+ return `Marker: break-even near ${this.formatBurn(marker)} LUUN, using last payout ${payout}, estimated active burns ${this.formatBurn(burned)}, fee ${fee}, and ${window} eligible blocks.`;
+ },
+
+ formatBurn(value) {
+ const amount = Math.max(0, Number(value) || 0);
+ return amount >= 10 ? amount.toFixed(1) : amount.toFixed(2);
},
async sendTransfer() {
diff --git a/src/adapters/config_store.rs b/src/adapters/config_store.rs
@@ -10,24 +10,43 @@ use serde::{Deserialize, Serialize};
use crate::domain::Amount;
const CONFIG_FILE_VERSION: u32 = 1;
+pub const DEFAULT_BURN_FEE: Amount = 1;
-#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
+#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
pub struct UiConfig {
pub setup_complete: bool,
pub burn_per_block: Amount,
+ pub burn_fee: Amount,
pub peers: Vec<String>,
}
+impl Default for UiConfig {
+ fn default() -> Self {
+ Self {
+ setup_complete: false,
+ burn_per_block: 0,
+ burn_fee: DEFAULT_BURN_FEE,
+ peers: Vec::new(),
+ }
+ }
+}
+
#[derive(Debug, Deserialize, Serialize)]
struct ConfigFile {
version: u32,
setup_complete: bool,
#[serde(default)]
burn_per_block: Amount,
+ #[serde(default = "default_burn_fee")]
+ burn_fee: Amount,
#[serde(default)]
peers: Vec<String>,
}
+fn default_burn_fee() -> Amount {
+ DEFAULT_BURN_FEE
+}
+
pub fn load_or_create(path: &Path) -> Result<UiConfig> {
if path.exists() {
return load(path);
@@ -48,6 +67,7 @@ pub fn save(path: &Path, config: &UiConfig) -> Result<()> {
version: CONFIG_FILE_VERSION,
setup_complete: config.setup_complete,
burn_per_block: config.burn_per_block,
+ burn_fee: config.burn_fee,
peers: config.peers.clone(),
};
let bytes = serde_json::to_vec_pretty(&stored).context("failed to serialize config file")?;
@@ -76,6 +96,7 @@ fn load(path: &Path) -> Result<UiConfig> {
Ok(UiConfig {
setup_complete: stored.setup_complete,
burn_per_block: stored.burn_per_block,
+ burn_fee: stored.burn_fee,
peers: stored.peers,
})
}
@@ -95,7 +116,7 @@ mod tests {
use tempfile::tempdir;
- use super::{UiConfig, load_or_create, save};
+ use super::{DEFAULT_BURN_FEE, UiConfig, load_or_create, save};
#[test]
fn creates_default_config_file() {
@@ -109,6 +130,7 @@ mod tests {
assert!(stored.contains("\"version\": 1"));
assert!(stored.contains("\"setup_complete\": false"));
assert!(stored.contains("\"burn_per_block\": 0"));
+ assert!(stored.contains("\"burn_fee\": 1"));
assert!(stored.contains("\"peers\": []"));
}
@@ -122,6 +144,7 @@ mod tests {
&UiConfig {
setup_complete: true,
burn_per_block: 50,
+ burn_fee: 3,
peers: vec!["127.0.0.1:9444".to_string()],
},
)
@@ -130,6 +153,7 @@ mod tests {
assert!(config.setup_complete);
assert_eq!(config.burn_per_block, 50);
+ assert_eq!(config.burn_fee, 3);
assert_eq!(config.peers, vec!["127.0.0.1:9444"]);
}
@@ -152,6 +176,7 @@ mod tests {
assert!(config.setup_complete);
assert_eq!(config.burn_per_block, 0);
+ assert_eq!(config.burn_fee, DEFAULT_BURN_FEE);
assert_eq!(config.peers, vec!["127.0.0.1:9444"]);
}
}
diff --git a/src/adapters/http.rs b/src/adapters/http.rs
@@ -35,8 +35,9 @@ struct HttpState {
}
#[derive(Debug, Deserialize)]
-struct AmountForm {
+struct BurnSettingsForm {
amount: Amount,
+ fee: Amount,
}
#[derive(Debug, Deserialize)]
@@ -211,17 +212,17 @@ async fn api_wallet_import_form(
async fn api_burn_per_block_form(
State(state): State<HttpState>,
- Form(form): Form<AmountForm>,
+ Form(form): Form<BurnSettingsForm>,
) -> Json<ActionResponse> {
- let result = set_burn_per_block(&state, form.amount).await;
+ let result = set_burn_settings(&state, form.amount, form.fee).await;
action_json(result)
}
async fn burn_per_block_form(
State(state): State<HttpState>,
- Form(form): Form<AmountForm>,
+ Form(form): Form<BurnSettingsForm>,
) -> Response {
- match set_burn_per_block(&state, form.amount).await {
+ match set_burn_settings(&state, form.amount, form.fee).await {
Ok(_) => Redirect::to("/").into_response(),
Err(error) => api_error(error).into_response(),
}
@@ -257,30 +258,32 @@ async fn peer_form(State(state): State<HttpState>, Form(form): Form<PeerForm>) -
}
}
-async fn set_burn_per_block(state: &HttpState, amount: Amount) -> Result<()> {
+async fn set_burn_settings(state: &HttpState, amount: Amount, fee: Amount) -> Result<()> {
let result = {
let mut node = state.node.lock().await;
- let result = node.set_burn_per_block(amount);
+ let result = node.set_automatic_burn(amount, fee);
let outbox = node.drain_outbox();
(result, outbox)
};
match result.0 {
Ok(_) => {
- persist_burn_per_block_config(&state.ui_config, &state.config_path, amount).await?;
+ persist_burn_settings_config(&state.ui_config, &state.config_path, amount, fee).await?;
state.gossip.broadcast(result.1).await
}
Err(error) => Err(error),
}
}
-async fn persist_burn_per_block_config(
+async fn persist_burn_settings_config(
ui_config: &Arc<Mutex<UiConfig>>,
config_path: &Path,
amount: Amount,
+ fee: Amount,
) -> Result<()> {
let mut config = ui_config.lock().await;
config.burn_per_block = amount;
+ config.burn_fee = fee;
config_store::save(config_path, &config)
}
@@ -516,7 +519,17 @@ const INDEX_HTML: &str = r#"<!doctype html>
.setup-status { border: 1px solid #566d25; border-radius: 8px; padding: 10px; background: #1c2516; color: #d5f55f; font-weight: 800; }
.wallet-grid { width: 100%; display: grid; grid-template-columns: minmax(0, 1fr) minmax(300px, .8fr); gap: 12px; align-items: start; }
.wallet-actions { display: grid; gap: 12px; }
+ .wallet-balance-line { display: inline-grid; grid-template-columns: auto auto; gap: 10px; align-items: baseline; padding: 8px 10px; border: 1px solid #2f363c; border-radius: 8px; background: #111316; }
+ .wallet-balance-line .tx-value { font-size: 16px; font-weight: 850; }
.mining-grid { width: 100%; display: grid; grid-template-columns: minmax(0, 1fr); gap: 12px; align-items: start; }
+ .mining-form { width: 100%; display: grid; grid-template-columns: minmax(220px, .45fr) minmax(280px, 1fr); gap: 14px; align-items: end; }
+ .burn-fields { display: flex; flex-wrap: wrap; gap: 10px; align-items: end; }
+ .burn-slider-panel { display: grid; gap: 8px; min-width: 0; }
+ .burn-slider-head, .burn-slider-scale { display: flex; justify-content: space-between; gap: 10px; color: #8d989f; font-size: 11px; font-weight: 800; text-transform: uppercase; }
+ .burn-slider-track { position: relative; min-height: 28px; display: flex; align-items: center; }
+ .burn-range { width: 100%; min-width: 0; accent-color: #d5f55f; }
+ .break-even-marker { position: absolute; top: 2px; bottom: 2px; width: 2px; transform: translateX(-1px); background: #ffd070; box-shadow: 0 0 0 1px #111316, 0 0 0 4px rgba(255, 208, 112, .18); pointer-events: none; }
+ .burn-slider-note { color: #a8b2b8; font-size: 12px; line-height: 1.4; }
.receive-address { display: grid; gap: 8px; }
.address-box { border: 1px solid #2f363c; border-radius: 8px; padding: 11px; background: #111316; }
.panel-head { display: flex; justify-content: space-between; gap: 12px; align-items: center; margin-bottom: 12px; }
@@ -567,6 +580,7 @@ const INDEX_HTML: &str = r#"<!doctype html>
.mempool-strip { display: flex; gap: 8px; overflow-x: auto; padding-bottom: 4px; }
.mempool-item { flex: 0 0 220px; }
@media (max-width: 920px) { .setup-grid, .wallet-grid, .mining-grid, .detail-grid { grid-template-columns: 1fr; } }
+ @media (max-width: 920px) { .mining-form { grid-template-columns: 1fr; } }
@media (max-width: 760px) {
.app-shell { grid-template-columns: 1fr; }
.sidebar { position: sticky; z-index: 5; bottom: 0; top: auto; height: auto; flex-direction: row; justify-content: space-between; padding: 8px; border-right: 0; border-bottom: 1px solid #262b2f; }
@@ -583,7 +597,7 @@ const INDEX_HTML: &str = r#"<!doctype html>
.block-card { flex-basis: 108px; }
}
</style>
- <script defer src="/assets/luun-ui.js?v=31"></script>
+ <script defer src="/assets/luun-ui.js?v=32"></script>
<script defer src="/assets/alpine.min.js"></script>
</head>
<body x-data="luunApp()" x-init="init()" x-cloak>
@@ -622,7 +636,10 @@ const INDEX_HTML: &str = r#"<!doctype html>
<section x-show="tab === 'wallet'">
<div class="page-title">
- <div class="muted">Balance <strong x-text="status.wallet_balance ?? '-'"></strong></div>
+ <div class="wallet-balance-line">
+ <span class="tx-label">Balance</span>
+ <span class="tx-value money">LUUN <span x-text="status.wallet_balance ?? '-'"></span></span>
+ </div>
</div>
<div class="wallet-grid">
<div class="wallet-actions">
@@ -687,10 +704,21 @@ const INDEX_HTML: &str = r#"<!doctype html>
</div>
<div class="panel">
<h3>Mining</h3>
- <form @submit.prevent="saveBurn">
- <label>LUUN per block<input x-model.number="burnAmountDraft" @input="burnAmountDirty = true" type="number" min="0"></label>
- <label>Fee<input :value="automaticBurnFeeDraft()" type="number" readonly></label>
- <button class="primary" type="submit">Save</button>
+ <form class="mining-form" @submit.prevent="saveBurn">
+ <div class="burn-fields">
+ <label>LUUN per block<input x-model.number="burnAmountDraft" @input="burnAmountDirty = true" type="number" min="0" :max="burnSliderMax()"></label>
+ <label>Fee<input x-model.number="burnFeeDraft" @input="burnAmountDirty = true" type="number" min="0"></label>
+ <button class="primary" type="submit">Save</button>
+ </div>
+ <div class="burn-slider-panel">
+ <div class="burn-slider-head"><span>Burn range</span><span><span x-text="burnAmountDraft"></span> LUUN</span></div>
+ <div class="burn-slider-track">
+ <input class="burn-range" x-model.number="burnAmountDraft" @input="burnAmountDirty = true" type="range" min="0" :max="burnSliderMax()" step="1">
+ <div class="break-even-marker" :style="breakEvenStyle()" title="Estimated break-even burn"></div>
+ </div>
+ <div class="burn-slider-scale"><span>0</span><span x-text="`${burnSliderMax()} LUUN`"></span></div>
+ <div class="burn-slider-note" x-text="burnBreakEvenLabel()"></div>
+ </div>
</form>
</div>
</div>
@@ -915,7 +943,7 @@ mod tests {
use crate::adapters::{config_store, config_store::UiConfig};
use super::{
- TransferForm, dev_seed_verify_bypass_allowed, persist_burn_per_block_config,
+ TransferForm, dev_seed_verify_bypass_allowed, persist_burn_settings_config,
validate_transfer_form,
};
@@ -926,7 +954,7 @@ mod tests {
}
#[tokio::test]
- async fn burn_rate_config_persistence_updates_config_file() {
+ async fn burn_settings_config_persistence_updates_config_file() {
let dir = tempfile::tempdir().unwrap();
let config_path = dir.path().join("config.json");
let ui_config = Arc::new(Mutex::new(UiConfig {
@@ -936,12 +964,13 @@ mod tests {
let initial_config = ui_config.lock().await.clone();
config_store::save(&config_path, &initial_config).expect("initial config should save");
- persist_burn_per_block_config(&ui_config, &config_path, 50)
+ persist_burn_settings_config(&ui_config, &config_path, 50, 3)
.await
.unwrap();
let config = config_store::load_or_create(&config_path).unwrap();
assert_eq!(config.burn_per_block, 50);
+ assert_eq!(config.burn_fee, 3);
}
#[test]
diff --git a/src/app.rs b/src/app.rs
@@ -9,8 +9,8 @@ use serde::{Deserialize, Serialize};
use tokio::sync::Mutex;
use crate::domain::{
- Amount, Block, ChainSnapshot, ChainStatus, DEFAULT_TRANSACTION_FEE, Ledger, PreparedBlock,
- Transaction, VDF_TARGET_BLOCK_MS, Wallet, run_vdf,
+ Amount, Block, ChainSnapshot, ChainStatus, DEFAULT_TRANSACTION_FEE, LaunchProfile, Ledger,
+ PreparedBlock, Transaction, VDF_TARGET_BLOCK_MS, Wallet, run_vdf,
};
pub type SharedNode = Arc<Mutex<NodeCore>>;
@@ -22,6 +22,7 @@ pub const PROTOCOL_VERSION: u32 = 1;
pub const NETWORK_ID: &str = "luun-devnet-v0";
pub const BLOCK_REQUEST_LIMIT: usize = 128;
const IMPORT_REBROADCAST_LIMIT: usize = 128;
+const MICRO_LUUN: u64 = 1_000_000;
#[derive(Clone, Debug)]
pub struct NodeConfig {
@@ -29,6 +30,7 @@ pub struct NodeConfig {
pub genesis_allocations: BTreeMap<String, Amount>,
pub vdf_rounds: u32,
pub burn_per_block: Amount,
+ pub burn_fee: Amount,
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
@@ -100,6 +102,8 @@ pub struct NodeStatus {
pub struct LaunchProfileStatus {
pub profile_id: String,
pub profile_hash: String,
+ pub ticket_maturity_delay_heights: u64,
+ pub ticket_expiry_window_heights: u64,
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
@@ -107,6 +111,7 @@ pub struct MiningStatus {
pub automatic: bool,
pub burn_per_block: Amount,
pub automatic_burn_fee: Amount,
+ pub economics: MiningEconomicsStatus,
pub vdf_rounds: u32,
pub vdf_target_block_ms: u64,
pub current_leader: Option<String>,
@@ -115,6 +120,15 @@ pub struct MiningStatus {
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
+pub struct MiningEconomicsStatus {
+ pub slider_max: Amount,
+ pub last_payout: Amount,
+ pub estimated_active_burn: Amount,
+ pub estimated_other_burn_rate_microluun: u64,
+ pub break_even_burn_microluun: u64,
+}
+
+#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
pub struct AutoMineOutcome {
pub burned: Option<Transaction>,
pub block: Option<Block>,
@@ -133,6 +147,7 @@ pub struct NodeCore {
wallet: Wallet,
ledger: Ledger,
burn_per_block: Amount,
+ burn_fee: Amount,
last_auto_burn_height: Option<u64>,
outbox: Vec<GossipEnvelope>,
}
@@ -140,14 +155,29 @@ pub struct NodeCore {
impl NodeCore {
pub fn new(config: NodeConfig) -> Self {
let ledger = Ledger::new(config.genesis_allocations, config.vdf_rounds);
- Self::from_ledger(config.wallet, ledger, config.burn_per_block)
+ Self::from_ledger_with_burn_fee(
+ config.wallet,
+ ledger,
+ config.burn_per_block,
+ config.burn_fee,
+ )
}
pub fn from_ledger(wallet: Wallet, ledger: Ledger, burn_per_block: Amount) -> Self {
+ Self::from_ledger_with_burn_fee(wallet, ledger, burn_per_block, DEFAULT_TRANSACTION_FEE)
+ }
+
+ pub fn from_ledger_with_burn_fee(
+ wallet: Wallet,
+ ledger: Ledger,
+ burn_per_block: Amount,
+ burn_fee: Amount,
+ ) -> Self {
Self {
wallet,
ledger,
burn_per_block,
+ burn_fee,
last_auto_burn_height: None,
outbox: Vec::new(),
}
@@ -307,11 +337,14 @@ impl NodeCore {
launch_profile: LaunchProfileStatus {
profile_id: launch_profile.profile_id.clone(),
profile_hash: chain.launch_profile_hash.clone(),
+ ticket_maturity_delay_heights: launch_profile.ticket_maturity_delay_heights,
+ ticket_expiry_window_heights: launch_profile.ticket_expiry_window_heights,
},
mining: MiningStatus {
automatic: true,
burn_per_block: self.burn_per_block,
- automatic_burn_fee: automatic_burn_fee(self.burn_per_block),
+ automatic_burn_fee: self.burn_fee,
+ economics: self.mining_economics(),
vdf_rounds: self.ledger.vdf_rounds(),
vdf_target_block_ms: VDF_TARGET_BLOCK_MS,
current_leader,
@@ -322,9 +355,51 @@ impl NodeCore {
}
}
+ fn mining_economics(&self) -> MiningEconomicsStatus {
+ let launch_profile = self.ledger.launch_profile();
+ let chain = self.ledger.chain();
+ let window = launch_profile.ticket_expiry_window_heights.max(1);
+ let last_payout = chain
+ .last()
+ .map(|block| block.reward)
+ .unwrap_or_else(|| self.ledger.status().block_reward);
+ let estimated_active_burn = estimated_active_burn_for_next_block(chain, launch_profile);
+ let average_active_burn_rate_microluun = u128::from(estimated_active_burn)
+ .saturating_mul(u128::from(MICRO_LUUN))
+ / u128::from(window);
+ let configured_burn_microluun =
+ u128::from(self.burn_per_block).saturating_mul(u128::from(MICRO_LUUN));
+ let estimated_other_burn_rate_microluun = average_active_burn_rate_microluun
+ .saturating_sub(configured_burn_microluun)
+ .min(u128::from(u64::MAX)) as u64;
+ let break_even_burn_microluun = low_break_even_burn_microluun(
+ last_payout,
+ estimated_other_burn_rate_microluun,
+ self.burn_fee,
+ last_payout,
+ );
+
+ MiningEconomicsStatus {
+ slider_max: last_payout,
+ last_payout,
+ estimated_active_burn,
+ estimated_other_burn_rate_microluun,
+ break_even_burn_microluun,
+ }
+ }
+
pub fn set_burn_per_block(&mut self, amount: Amount) -> Result<Option<Transaction>> {
+ self.set_automatic_burn(amount, self.burn_fee)
+ }
+
+ pub fn set_automatic_burn(
+ &mut self,
+ amount: Amount,
+ fee: Amount,
+ ) -> Result<Option<Transaction>> {
let was_disabled = self.burn_per_block == 0;
self.burn_per_block = amount;
+ self.burn_fee = fee;
if was_disabled && amount > 0 {
self.last_auto_burn_height = None;
}
@@ -447,7 +522,7 @@ impl NodeCore {
return Ok(None);
}
- let fee = automatic_burn_fee(self.burn_per_block);
+ let fee = self.burn_fee;
let balance = self.ledger.balance_of(self.wallet.address());
let amount = if balance >= self.burn_per_block.saturating_add(fee) {
self.burn_per_block
@@ -582,10 +657,101 @@ impl NodeCore {
}
}
-fn automatic_burn_fee(_burn_per_block: Amount) -> Amount {
+fn estimated_active_burn_for_next_block(chain: &[Block], launch_profile: &LaunchProfile) -> Amount {
+ let Some(tip) = chain.last() else {
+ return 0;
+ };
+ let window = launch_profile.ticket_expiry_window_heights.max(1);
+ let target_height = tip.height.saturating_add(1);
+ if target_height < launch_profile.ticket_maturity_delay_heights {
+ return block_burned(tip).saturating_mul(window);
+ }
+
+ let last_eligible_burn_height =
+ target_height.saturating_sub(launch_profile.ticket_maturity_delay_heights);
+ let first_eligible_burn_height =
+ last_eligible_burn_height.saturating_sub(window.saturating_sub(1));
+ let mut matching_blocks = 0_u64;
+ let mut total = 0_u64;
+ for block in chain {
+ if first_eligible_burn_height <= block.height && block.height <= last_eligible_burn_height {
+ matching_blocks = matching_blocks.saturating_add(1);
+ total = total.saturating_add(block_burned(block));
+ }
+ }
+
+ if matching_blocks >= window {
+ total
+ } else {
+ block_burned(tip).saturating_mul(window)
+ }
+}
+
+fn block_burned(block: &Block) -> Amount {
+ block
+ .transactions
+ .iter()
+ .filter(|tx| tx.is_burn())
+ .fold(0, |total, tx| total.saturating_add(tx.amount()))
+}
+
+fn low_break_even_burn_microluun(
+ payout: Amount,
+ other_burn_rate_microluun: u64,
+ fee: Amount,
+ slider_max: Amount,
+) -> u64 {
+ if payout == 0 || slider_max == 0 {
+ return 0;
+ }
+ if fee == 0 {
+ return 0;
+ }
+
+ let max = slider_max.saturating_mul(MICRO_LUUN);
+ let steps = 1_000_u64;
+ let mut previous = 0_u64;
+ for step in 1..=steps {
+ let candidate = ((u128::from(max) * u128::from(step)) / u128::from(steps)) as u64;
+ if expected_burn_profit(candidate, other_burn_rate_microluun, payout, fee) >= 0.0 {
+ let mut low = previous;
+ let mut high = candidate;
+ for _ in 0..32 {
+ let middle = low + (high - low) / 2;
+ if expected_burn_profit(middle, other_burn_rate_microluun, payout, fee) >= 0.0 {
+ high = middle;
+ } else {
+ low = middle;
+ }
+ }
+ return high;
+ }
+ previous = candidate;
+ }
0
}
+fn expected_burn_profit(
+ burn_microluun: u64,
+ other_burn_rate_microluun: u64,
+ payout: Amount,
+ fee: Amount,
+) -> f64 {
+ if burn_microluun == 0 {
+ return -(fee as f64);
+ }
+
+ let burn = burn_microluun as f64 / MICRO_LUUN as f64;
+ let other_burn_rate = other_burn_rate_microluun as f64 / MICRO_LUUN as f64;
+ let total_burn_rate = burn + other_burn_rate;
+ let win_chance = if total_burn_rate > 0.0 {
+ burn / total_burn_rate
+ } else {
+ 1.0
+ };
+ payout as f64 * win_chance - burn - fee as f64
+}
+
#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
pub struct PeerBook {
peers: BTreeMap<String, PeerInfo>,
@@ -709,7 +875,22 @@ mod tests {
use crate::domain::Wallet;
- use super::{NodeConfig, NodeCore};
+ use super::{
+ MICRO_LUUN, NodeConfig, NodeCore, expected_burn_profit, low_break_even_burn_microluun,
+ };
+
+ #[test]
+ fn break_even_uses_low_root_for_weighted_burn_rate() {
+ let other_burn_rate = 10 * MICRO_LUUN;
+ let break_even = low_break_even_burn_microluun(102, other_burn_rate, 1, 102);
+
+ assert!(
+ break_even > 100_000 && break_even < 120_000,
+ "expected low break-even around 0.11 LUUN, got {break_even} microluun"
+ );
+ assert!(break_even < MICRO_LUUN);
+ assert!(expected_burn_profit(MICRO_LUUN, other_burn_rate, 102, 1) > 0.0);
+ }
#[test]
fn same_height_verified_import_does_not_reset_auto_burn_guard() {
@@ -721,6 +902,7 @@ mod tests {
genesis_allocations: allocations,
vdf_rounds: 10,
burn_per_block: 1,
+ burn_fee: 1,
});
let first = node.prepare_automatic_mining(1);
diff --git a/src/domain.rs b/src/domain.rs
@@ -1345,15 +1345,7 @@ impl Ledger {
}
fn selected_ticket_for_height(&self, height: u64) -> Option<BurnTicket> {
- self.tickets
- .iter()
- .filter(|ticket| ticket_is_eligible_for_height(ticket, height))
- .min_by(|left, right| {
- ticket_rank(self.tip(), height, left)
- .cmp(&ticket_rank(self.tip(), height, right))
- .then_with(|| left.id.cmp(&right.id))
- })
- .cloned()
+ select_weighted_ticket(self.tip(), height, &self.tickets)
}
fn tip(&self) -> &Block {
@@ -1363,11 +1355,44 @@ impl Ledger {
}
}
-fn ticket_rank(parent: &Block, target_height: u64, ticket: &BurnTicket) -> String {
- hex_hash(format!(
- "luun-ticket-rank:{}:{}:{}:{}:{}",
- target_height, parent.hash, parent.vdf_output, ticket.id, ticket.amount
- ))
+fn select_weighted_ticket(
+ parent: &Block,
+ target_height: u64,
+ tickets: &[BurnTicket],
+) -> Option<BurnTicket> {
+ let eligible = tickets
+ .iter()
+ .filter(|ticket| ticket_is_eligible_for_height(ticket, target_height))
+ .collect::<Vec<_>>();
+ let total_weight = eligible.iter().try_fold(0_u128, |total, ticket| {
+ total.checked_add(u128::from(ticket.amount))
+ })?;
+ if total_weight == 0 {
+ return None;
+ }
+
+ let draw = weighted_ticket_draw(parent, target_height, total_weight);
+ let mut cumulative = 0_u128;
+ for ticket in eligible {
+ cumulative = cumulative.checked_add(u128::from(ticket.amount))?;
+ if draw < cumulative {
+ return Some(ticket.clone());
+ }
+ }
+ None
+}
+
+fn weighted_ticket_draw(parent: &Block, target_height: u64, total_weight: u128) -> u128 {
+ let digest = Sha256::digest(
+ format!(
+ "luun-ticket-draw:{}:{}:{}",
+ target_height, parent.hash, parent.vdf_output
+ )
+ .as_bytes(),
+ );
+ let mut bytes = [0_u8; 16];
+ bytes.copy_from_slice(&digest[..16]);
+ u128::from_be_bytes(bytes) % total_weight
}
fn tickets_created_by_block(block: &Block, profile: &LaunchProfile) -> Result<Vec<BurnTicket>> {
diff --git a/src/main.rs b/src/main.rs
@@ -47,11 +47,13 @@ async fn main() -> Result<()> {
let ledger = initialize_ledger(&opts, wallet.address(), &chain_store).await?;
let has_chain = opts.has_chain() || persisted_chain_exists;
let initial_burn_per_block = initial_burn_per_block(&opts, &ui_config);
+ let initial_burn_fee = ui_config.burn_fee;
- let node: SharedNode = Arc::new(Mutex::new(NodeCore::from_ledger(
+ let node: SharedNode = Arc::new(Mutex::new(NodeCore::from_ledger_with_burn_fee(
wallet,
ledger,
initial_burn_per_block,
+ initial_burn_fee,
)));
let ui_config = Arc::new(Mutex::new(ui_config));
let mut peers = ui_config.lock().await.peers.clone();
@@ -69,8 +71,8 @@ async fn main() -> Result<()> {
println!("management UI: http://{}", opts.http_addr);
println!("p2p listener: {}", opts.p2p_addr);
println!(
- "automatic mining: VDF-driven, burning {} LUUN per block",
- initial_burn_per_block
+ "automatic mining: VDF-driven, burning {} LUUN per block with {} LUUN fee",
+ initial_burn_per_block, initial_burn_fee
);
let gossip =
@@ -619,6 +621,7 @@ mod tests {
let genesis = parse(&["--genesis"]).unwrap().unwrap();
let configured = UiConfig {
burn_per_block: 50,
+ burn_fee: 3,
..UiConfig::default()
};
assert_eq!(initial_burn_per_block(&genesis, &configured), 100);
@@ -628,6 +631,7 @@ mod tests {
fn non_genesis_modes_start_with_configured_burn_rate() {
let configured = UiConfig {
burn_per_block: 50,
+ burn_fee: 3,
..UiConfig::default()
};
diff --git a/tests/luun.rs b/tests/luun.rs
@@ -16,6 +16,7 @@ fn node(_network_key: &str, wallet: Wallet, allocations: BTreeMap<String, Amount
genesis_allocations: allocations,
vdf_rounds: 25,
burn_per_block: DEFAULT_BURN_PER_BLOCK,
+ burn_fee: 1,
})
}
@@ -101,6 +102,36 @@ fn genesis_burn_starts_chain_with_reward_and_first_leader() {
}
#[test]
+fn burn_amount_weights_leader_selection() {
+ let mut high_weight_leaders = 0;
+ for sample in 0..100 {
+ let low = Wallet::from_seed(&format!("weighted-low-{sample}"));
+ let high = Wallet::from_seed(&format!("weighted-high-{sample}"));
+ let mut allocations = BTreeMap::new();
+ allocations.insert(low.address().to_string(), 1_000);
+ allocations.insert(high.address().to_string(), 1_000);
+ let ledger = Ledger::new_with_genesis_burns(
+ allocations,
+ vec![
+ GenesisBurn::new(low.address(), 1),
+ GenesisBurn::new(high.address(), 99),
+ ],
+ 25,
+ )
+ .unwrap();
+
+ if ledger.expected_leader_for_next_block().as_deref() == Some(high.address()) {
+ high_weight_leaders += 1;
+ }
+ }
+
+ assert!(
+ high_weight_leaders >= 90,
+ "high burn ticket should win most weighted draws, won {high_weight_leaders}/100"
+ );
+}
+
+#[test]
fn starter_node_waits_for_a_burn_before_vdf_work() {
let alice = Wallet::from_seed("alice");
let mut node = starter_node(alice.clone());
@@ -487,6 +518,7 @@ fn automatic_mining_burns_configured_amount_once_per_height() {
genesis_allocations: allocations,
vdf_rounds: 10,
burn_per_block: 25,
+ burn_fee: 1,
});
let first = node.automatic_mine_once(1);
@@ -498,6 +530,7 @@ fn automatic_mining_burns_configured_amount_once_per_height() {
let second = node.automatic_mine_once(2);
assert!(second.burned.is_some());
assert_eq!(second.burned.as_ref().map(|tx| tx.amount()), Some(25));
+ assert_eq!(second.burned.as_ref().map(|tx| tx.fee()), Some(1));
}
#[test]
@@ -543,15 +576,27 @@ fn automatic_burn_status_shows_configured_fee() {
allocations.insert(alice.address().to_string(), 1_000);
let mut node = node("alice", alice, allocations);
- assert_eq!(node.status().mining.automatic_burn_fee, 0);
+ assert_eq!(node.status().mining.automatic_burn_fee, 1);
node.set_burn_per_block(1).unwrap();
assert_eq!(node.status().mining.burn_per_block, 1);
- assert_eq!(node.status().mining.automatic_burn_fee, 0);
+ assert_eq!(node.status().mining.automatic_burn_fee, 1);
- node.set_burn_per_block(50).unwrap();
+ node.set_automatic_burn(50, 3).unwrap();
assert_eq!(node.status().mining.burn_per_block, 50);
- assert_eq!(node.status().mining.automatic_burn_fee, 0);
+ assert_eq!(node.status().mining.automatic_burn_fee, 3);
+}
+
+#[test]
+fn automatic_mining_uses_configured_burn_fee() {
+ let alice = Wallet::from_seed("auto-fee-burn-alice");
+ let mut allocations = BTreeMap::new();
+ allocations.insert(alice.address().to_string(), 1_000);
+ let mut node = node("alice", alice, allocations);
+
+ let burned = node.set_automatic_burn(50, 3).unwrap().unwrap();
+ assert_eq!(burned.amount(), 50);
+ assert_eq!(burned.fee(), 3);
}
#[test]
@@ -1148,6 +1193,7 @@ fn mined_block_gossip_does_not_include_full_chain_snapshot() {
genesis_allocations: allocations.clone(),
vdf_rounds: 10,
burn_per_block: 1,
+ burn_fee: 1,
});
let plan = alice_node.prepare_automatic_mining(1);