iuna

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

commit 2f1d5baff6679c52324edca7246a8ea0f2bc2c79
parent b25639af59541215beb21c4065ce2233c2798b84
Author: Joris Hartog <jorishartog@hotmail.com>
Date:   Fri, 24 Jul 2026 21:55:05 +0200

Refine mining controls and PoW issuance

Diffstat:
Massets/luun-ui.js | 124+++++++++++++++++++++++++++++++++++++++++--------------------------------------
Msrc/adapters/config_store.rs | 44++++++++++++++++++++++++++++++++++++++++++++
Msrc/adapters/http.rs | 278+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----------------
Msrc/app.rs | 315++++++++++++++++++++++++++++++++++++++++---------------------------------------
Msrc/domain.rs | 165++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---
Msrc/main.rs | 85+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----------
Mtests/luun.rs | 23++++++++++++++++++++---
7 files changed, 745 insertions(+), 289 deletions(-)

diff --git a/assets/luun-ui.js b/assets/luun-ui.js @@ -26,6 +26,8 @@ window.luunApp = function luunApp() { burnAmountDraft: 0, burnFee: 1000000, burnFeeDraft: "1", + miningEnabled: false, + powMiningEnabled: false, burnAmountDirty: false, transferTo: "", transferAmount: null, @@ -36,6 +38,7 @@ window.luunApp = function luunApp() { flash: null, flashTimer: null, showWalletUtxos: false, + showPowDifficultyInfo: false, lastUpdated: null, pollHandle: null, newBlockHashes: new Set(), @@ -277,6 +280,8 @@ window.luunApp = function luunApp() { this.p2pMetrics = p2pMetrics; this.burnAmount = status.mining?.burn_per_block ?? this.burnAmount; 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; if (!this.burnAmountDirty) { this.burnAmountDraft = this.amountLabel(this.burnAmount); this.burnFeeDraft = this.amountLabel(this.burnFee); @@ -376,9 +381,18 @@ window.luunApp = function luunApp() { this.showWalletUtxos = false; }, + openPowDifficultyInfo() { + this.showPowDifficultyInfo = true; + }, + + closePowDifficultyInfo() { + this.showPowDifficultyInfo = false; + }, + closeModals() { this.closeTransactionModal(); this.closeWalletUtxosModal(); + this.closePowDifficultyInfo(); }, async loadOlderBlocks() { @@ -458,8 +472,10 @@ window.luunApp = function luunApp() { this.burnFeeDraft = this.amountLabel(fee); await this.postForm( "/api/settings/burn-per-block", - { amount, fee }, - `Burn rate set to ${this.amountLabel(amount)} LUUN per block with ${this.amountLabel(fee)} fee` + { enabled: this.miningEnabled, amount, fee }, + this.miningEnabled + ? `Mining on: ${this.amountLabel(amount)} LUUN per block with ${this.amountLabel(fee)} fee` + : `Mining settings saved while off` ); this.burnAmountDirty = false; this.burnAmount = amount; @@ -469,73 +485,47 @@ window.luunApp = function luunApp() { } }, - async minePowReward() { + async setMiningEnabled(enabled) { + const previous = this.miningEnabled; try { + const amount = this.parseLuunAmount(this.burnAmountDraft); + const fee = this.parseLuunAmount(this.burnFeeDraft); + if (enabled && amount === 0) { + this.miningEnabled = false; + throw new Error("Set LUUN per block before turning mining on"); + } + this.miningEnabled = enabled; await this.postForm( - "/api/mine", - {}, - `Queued PoW mine action for ${this.amountLabel(this.status.chain?.mine_reward ?? 0)} LUUN` + "/api/settings/burn-per-block", + { enabled, amount, fee }, + enabled ? "Mining turned on" : "Mining turned off" ); + this.burnAmountDirty = false; + this.burnAmount = amount; + this.burnFee = fee; } catch (error) { + this.miningEnabled = previous; this.showFlash(error.message, "error"); } }, - automaticBurnFeeDraft() { - return this.parseLuunAmount(this.burnFeeDraft); - }, - - miningEconomics() { - return this.status.mining?.economics || {}; - }, - - burnSliderMax() { - return this.amountNumber(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 ?? 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() { - return Math.max( - 0, - Math.trunc(Number(this.miningEconomics().break_even_burn_microluun) || 0) - ); - }, - - breakEvenPercent() { - const max = Math.max(0, Math.trunc(Number(this.miningEconomics().slider_max ?? this.latestBlockReward()))); - return max > 0 ? Math.min(100, Math.max(0, (this.breakEvenBurn() / max) * 100)) : 0; - }, - - breakEvenStyle() { - return `left: ${this.breakEvenPercent()}%`; + async setPowMiningEnabled(enabled) { + const previous = this.powMiningEnabled; + try { + this.powMiningEnabled = enabled; + await this.postForm( + "/api/settings/pow-mining", + { enabled }, + enabled ? "PoW mining turned on" : "PoW mining turned off" + ); + } catch (error) { + this.powMiningEnabled = previous; + this.showFlash(error.message, "error"); + } }, - 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.amountLabel(marker)} LUUN, using last payout ${this.amountLabel(payout)}, estimated active burns ${this.amountLabel(burned)}, fee ${this.amountLabel(fee)}, and ${window} eligible blocks.`; + automaticBurnFeeDraft() { + return this.parseLuunAmount(this.burnFeeDraft); }, amountLabel(value) { @@ -637,6 +627,22 @@ window.luunApp = function luunApp() { return tx.amount ?? tx.outputs?.[0]?.amount ?? 0; }, + isMineTx(tx) { + return tx?.kind === "mine"; + }, + + txDifficultyBits(tx) { + return tx?.difficulty_bits ?? tx?.difficultyBits ?? null; + }, + + txProofBits(tx) { + return tx?.proof_bits ?? tx?.proofBits ?? null; + }, + + txProofHash(tx) { + return tx?.proof_hash ?? tx?.proofHash ?? tx?.signature ?? null; + }, + txInputs(tx) { return Array.isArray(tx.inputs) ? tx.inputs : []; }, diff --git a/src/adapters/config_store.rs b/src/adapters/config_store.rs @@ -16,6 +16,8 @@ pub const DEFAULT_BURN_FEE: Amount = DEFAULT_TRANSACTION_FEE; #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] pub struct UiConfig { pub setup_complete: bool, + pub mining_enabled: bool, + pub pow_mining_enabled: bool, pub burn_per_block: Amount, pub burn_fee: Amount, pub peers: Vec<String>, @@ -25,6 +27,8 @@ impl Default for UiConfig { fn default() -> Self { Self { setup_complete: false, + mining_enabled: false, + pow_mining_enabled: false, burn_per_block: 0, burn_fee: DEFAULT_BURN_FEE, peers: Vec::new(), @@ -39,6 +43,10 @@ struct ConfigFile { amount_unit: Option<String>, setup_complete: bool, #[serde(default)] + mining_enabled: Option<bool>, + #[serde(default)] + pow_mining_enabled: bool, + #[serde(default)] burn_per_block: Amount, #[serde(default = "default_burn_fee")] burn_fee: Amount, @@ -70,6 +78,8 @@ pub fn save(path: &Path, config: &UiConfig) -> Result<()> { version: CONFIG_FILE_VERSION, amount_unit: Some(AMOUNT_UNIT_MICROLUUN.to_string()), setup_complete: config.setup_complete, + mining_enabled: Some(config.mining_enabled), + pow_mining_enabled: config.pow_mining_enabled, burn_per_block: config.burn_per_block, burn_fee: config.burn_fee, peers: config.peers.clone(), @@ -105,6 +115,8 @@ fn load(path: &Path) -> Result<UiConfig> { Ok(UiConfig { setup_complete: stored.setup_complete, + mining_enabled: stored.mining_enabled.unwrap_or(stored.burn_per_block > 0), + 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), peers: stored.peers, @@ -142,6 +154,8 @@ mod tests { assert!(stored.contains("\"version\": 1")); assert!(stored.contains("\"amount_unit\": \"microluun\"")); assert!(stored.contains("\"setup_complete\": false")); + assert!(stored.contains("\"mining_enabled\": false")); + assert!(stored.contains("\"pow_mining_enabled\": false")); assert!(stored.contains("\"burn_per_block\": 0")); assert!(stored.contains("\"burn_fee\": 1000000")); assert!(stored.contains("\"peers\": []")); @@ -156,6 +170,8 @@ mod tests { &path, &UiConfig { setup_complete: true, + mining_enabled: true, + pow_mining_enabled: true, burn_per_block: 50 * MICRO_LUUN, burn_fee: 3 * MICRO_LUUN, peers: vec!["127.0.0.1:9444".to_string()], @@ -165,6 +181,8 @@ mod tests { let config = load_or_create(&path).unwrap(); assert!(config.setup_complete); + assert!(config.mining_enabled); + 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.peers, vec!["127.0.0.1:9444"]); @@ -188,8 +206,34 @@ mod tests { let config = load_or_create(&path).unwrap(); assert!(config.setup_complete); + assert!(!config.mining_enabled); + assert!(!config.pow_mining_enabled); 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"]); } + + #[test] + fn loads_old_config_with_burn_rate_as_mining_enabled() { + let dir = tempdir().unwrap(); + let path = dir.path().join("config.json"); + fs::write( + &path, + r#"{ + "version": 1, + "setup_complete": true, + "burn_per_block": 2, + "burn_fee": 1, + "peers": [] +} +"#, + ) + .unwrap(); + + let config = load_or_create(&path).unwrap(); + + assert!(config.mining_enabled); + assert_eq!(config.burn_per_block, 2 * MICRO_LUUN); + assert_eq!(config.burn_fee, MICRO_LUUN); + } } diff --git a/src/adapters/http.rs b/src/adapters/http.rs @@ -42,11 +42,17 @@ struct HttpState { #[derive(Debug, Deserialize)] struct BurnSettingsForm { + enabled: Option<bool>, amount: Amount, fee: Amount, } #[derive(Debug, Deserialize)] +struct PowMiningForm { + enabled: bool, +} + +#[derive(Debug, Deserialize)] struct TransferForm { to: String, amount: Amount, @@ -107,6 +113,9 @@ struct WalletTransactionRow { block_height: Option<u64>, block_miner: Option<String>, direction: &'static str, + difficulty_bits: Option<u32>, + proof_bits: Option<u32>, + proof_hash: Option<String>, } #[derive(Clone, Debug, Eq, PartialEq, Serialize)] @@ -142,6 +151,9 @@ struct UiTransaction { outputs: Vec<TxOutput>, change: Vec<TxOutput>, signature: String, + difficulty_bits: Option<u32>, + proof_bits: Option<u32>, + proof_hash: Option<String>, } #[derive(Clone, Debug, Eq, PartialEq, Serialize)] @@ -189,7 +201,7 @@ pub async fn serve( "/api/settings/burn-per-block", post(api_burn_per_block_form), ) - .route("/api/mine", post(api_mine_form)) + .route("/api/settings/pow-mining", post(api_pow_mining_form)) .route("/api/transfer", post(api_transfer_form)) .route("/settings/burn-per-block", post(burn_per_block_form)) .route("/transfer", post(transfer_form)) @@ -347,12 +359,16 @@ async fn api_burn_per_block_form( State(state): State<HttpState>, Form(form): Form<BurnSettingsForm>, ) -> Json<ActionResponse> { - let result = set_burn_settings(&state, form.amount, form.fee).await; + let enabled = form.enabled.unwrap_or(form.amount > 0); + let result = set_burn_settings(&state, enabled, form.amount, form.fee).await; action_json(result) } -async fn api_mine_form(State(state): State<HttpState>) -> Json<ActionResponse> { - let result = mine_pow_reward(&state).await; +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; action_json(result) } @@ -360,7 +376,8 @@ async fn burn_per_block_form( State(state): State<HttpState>, Form(form): Form<BurnSettingsForm>, ) -> Response { - match set_burn_settings(&state, form.amount, form.fee).await { + let enabled = form.enabled.unwrap_or(form.amount > 0); + match set_burn_settings(&state, enabled, form.amount, form.fee).await { Ok(_) => Redirect::to("/").into_response(), Err(error) => api_error(error).into_response(), } @@ -396,17 +413,29 @@ async fn peer_form(State(state): State<HttpState>, Form(form): Form<PeerForm>) - } } -async fn set_burn_settings(state: &HttpState, amount: Amount, fee: Amount) -> Result<()> { +async fn set_burn_settings( + state: &HttpState, + enabled: bool, + amount: Amount, + fee: Amount, +) -> Result<()> { let result = { let mut node = state.node.lock().await; - let result = node.set_automatic_burn(amount, fee); + let result = node.set_automatic_burn_settings(enabled, amount, fee); let outbox = node.drain_outbox(); (result, outbox) }; match result.0 { Ok(_) => { - persist_burn_settings_config(&state.ui_config, &state.config_path, amount, fee).await?; + persist_burn_settings_config( + &state.ui_config, + &state.config_path, + enabled, + amount, + fee, + ) + .await?; state.gossip.broadcast(result.1).await } Err(error) => Err(error), @@ -416,15 +445,35 @@ async fn set_burn_settings(state: &HttpState, amount: Amount, fee: Amount) -> Re async fn persist_burn_settings_config( ui_config: &Arc<Mutex<UiConfig>>, config_path: &Path, + enabled: bool, amount: Amount, fee: Amount, ) -> Result<()> { let mut config = ui_config.lock().await; + config.mining_enabled = enabled; config.burn_per_block = amount; config.burn_fee = fee; config_store::save(config_path, &config) } +async fn set_pow_mining(state: &HttpState, enabled: bool) -> Result<()> { + { + let mut node = state.node.lock().await; + node.set_pow_mining_enabled(enabled); + } + persist_pow_mining_config(&state.ui_config, &state.config_path, enabled).await +} + +async fn persist_pow_mining_config( + ui_config: &Arc<Mutex<UiConfig>>, + config_path: &Path, + enabled: bool, +) -> Result<()> { + let mut config = ui_config.lock().await; + config.pow_mining_enabled = enabled; + config_store::save(config_path, &config) +} + async fn add_peer(state: &HttpState, peer: String) -> Result<()> { let addresses = { let mut peers = state.peers.lock().await; @@ -518,9 +567,15 @@ fn wallet_transaction_row( } else { "sent" }, + difficulty_bits: None, + proof_bits: None, + proof_hash: None, }), Transaction::Mine { - output, signature, .. + output, + difficulty_bits, + signature, + .. } if output.address == wallet => Some(WalletTransactionRow { kind: "mine", from: "pow".to_string(), @@ -535,6 +590,9 @@ fn wallet_transaction_row( block_height, block_miner, direction: "received", + difficulty_bits: Some(*difficulty_bits), + proof_bits: Some(proof_bits(signature)), + proof_hash: Some(signature.clone()), }), _ => None, } @@ -592,6 +650,9 @@ fn ui_transaction( outputs: outputs.clone(), change: Vec::new(), signature: signature.clone(), + difficulty_bits: None, + proof_bits: None, + proof_hash: None, }, Transaction::Burn { inputs, @@ -609,9 +670,15 @@ fn ui_transaction( outputs: Vec::new(), change: change.clone(), signature: signature.clone(), + difficulty_bits: None, + proof_bits: None, + proof_hash: None, }, Transaction::Mine { - output, signature, .. + output, + difficulty_bits, + signature, + .. } => UiTransaction { kind: "mine", from: "pow".to_string(), @@ -622,6 +689,9 @@ fn ui_transaction( outputs: vec![output.clone()], change: Vec::new(), signature: signature.clone(), + difficulty_bits: Some(*difficulty_bits), + proof_bits: Some(proof_bits(signature)), + proof_hash: Some(signature.clone()), }, } } @@ -645,6 +715,31 @@ fn ui_inputs( .collect() } +fn proof_bits(hex_hash: &str) -> u32 { + let mut bits = 0_u32; + for byte in hex_hash.as_bytes() { + let Some(nibble) = hex_nibble(*byte) else { + break; + }; + if nibble == 0 { + bits += 4; + continue; + } + bits += nibble.leading_zeros() - 4; + break; + } + bits +} + +fn hex_nibble(byte: u8) -> Option<u8> { + match byte { + b'0'..=b'9' => Some(byte - b'0'), + b'a'..=b'f' => Some(byte - b'a' + 10), + b'A'..=b'F' => Some(byte - b'A' + 10), + _ => None, + } +} + fn known_output_index( genesis_allocations: &BTreeMap<String, Amount>, chain: &[Block], @@ -800,20 +895,6 @@ async fn transfer(state: &HttpState, form: TransferForm) -> Result<()> { } } -async fn mine_pow_reward(state: &HttpState) -> Result<()> { - let result = { - let mut node = state.node.lock().await; - let result = node.mine_pow_reward(); - let outbox = node.drain_outbox(); - (result, outbox) - }; - - match result.0 { - Ok(_) => state.gossip.broadcast(result.1).await, - Err(error) => Err(error), - } -} - fn validate_transfer_form(form: TransferForm) -> Result<(String, Amount, Amount, Vec<OutPoint>)> { let to = form.to.trim(); if to.is_empty() { @@ -926,7 +1007,7 @@ const INDEX_HTML: &str = r#"<!doctype html> code { overflow-wrap: anywhere; color: #c7f5ea; } .table-wrap { overflow-x: auto; } .muted { color: #8d989f; } - .flash { border-radius: 6px; padding: 10px 12px; margin: 12px 0; border: 1px solid; font-weight: 700; } + .flash { position: fixed; top: 18px; right: 18px; z-index: 80; width: min(420px, calc(100vw - 36px)); border-radius: 6px; padding: 10px 12px; border: 1px solid; font-weight: 700; box-shadow: 0 18px 48px rgba(0, 0, 0, .38); } .flash.success { color: #d5f55f; background: #1c2516; border-color: #566d25; } .flash.error { color: #ffb1a8; background: #2a1717; border-color: #713434; } .ok { color: #d5f55f; } @@ -977,16 +1058,33 @@ const INDEX_HTML: &str = r#"<!doctype html> .wallet-balance-line:hover, .wallet-balance-line:focus-visible { border-color: #d5f55f; outline: none; } .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; } + .panel-description { max-width: 760px; margin: -4px 0 12px; color: #9eb3bc; font-size: 13px; line-height: 1.45; } + .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; } - .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; } - .mine-action-row { display: flex; flex-wrap: wrap; justify-content: space-between; gap: 10px; align-items: center; } - .mine-action-meta { color: #9eb3bc; font-size: 13px; } + .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-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; } + .mine-stat-value.money { color: #d5f55f; } + .info-button { display: inline-grid; place-items: center; width: 18px; height: 18px; padding: 0; border-radius: 999px; border-color: #3a4248; background: #181b1f; color: #9eb3bc; font-size: 11px; line-height: 1; } + .info-button:hover, .info-button:focus-visible { border-color: #d5f55f; color: #d5f55f; outline: none; } + .info-copy { display: grid; gap: 10px; color: #c3cbd0; line-height: 1.45; } + .info-copy p { margin: 0; } + .info-facts { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 8px; } + .info-fact { border: 1px solid #2f363c; border-radius: 8px; padding: 10px; background: #111316; } + .info-fact .label { color: #879198; font-size: 10px; font-weight: 850; text-transform: uppercase; } + .info-fact .value { margin-top: 5px; color: #d5f55f; font-weight: 850; } + .mining-head { align-items: center; } + .toggle-switch { display: inline-flex; grid-template-columns: none; align-items: center; gap: 9px; color: #9fa8ad; font-size: 12px; font-weight: 850; cursor: pointer; user-select: none; } + .toggle-switch input { position: absolute; opacity: 0; pointer-events: none; } + .toggle-track { position: relative; width: 46px; height: 26px; border: 1px solid #3a4248; border-radius: 999px; background: #101215; transition: background .16s ease, border-color .16s ease; } + .toggle-thumb { position: absolute; top: 3px; left: 3px; width: 18px; height: 18px; border-radius: 999px; background: #879198; transition: transform .16s ease, background .16s ease; } + .toggle-switch.active { color: #d5f55f; } + .toggle-switch.active .toggle-track { border-color: #d5f55f; background: #263219; } + .toggle-switch.active .toggle-thumb { transform: translateX(20px); background: #d5f55f; } + .toggle-switch:focus-within .toggle-track { outline: 2px solid #d5f55f; outline-offset: 2px; } + .toggle-text { min-width: 22px; text-align: right; } .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; } @@ -1057,9 +1155,8 @@ const INDEX_HTML: &str = r#"<!doctype html> .tx-modal-empty { border: 1px dashed #3a4248; border-radius: 8px; padding: 10px; color: #8d989f; } .utxo-list { display: grid; gap: 8px; } .wallet-utxo-row { display: grid; gap: 6px; border: 1px solid #2f363c; border-radius: 8px; padding: 10px; background: #111316; } - @media (max-width: 760px) { .utxo-flow { grid-template-columns: 1fr; } .utxo-arrow { min-height: 28px; transform: rotate(90deg); } .tx-modal-head { align-items: stretch; } } + @media (max-width: 760px) { .utxo-flow, .mine-action-row, .mine-stats { grid-template-columns: 1fr; } .utxo-arrow { min-height: 28px; transform: rotate(90deg); } .tx-modal-head { align-items: stretch; } } @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; } @@ -1076,7 +1173,7 @@ const INDEX_HTML: &str = r#"<!doctype html> .block-card { flex-basis: 108px; } } </style> - <script defer src="/assets/luun-ui.js?v=43"></script> + <script defer src="/assets/luun-ui.js?v=45"></script> <script defer src="/assets/alpine.min.js"></script> </head> <body x-data="luunApp()" x-init="init()" @keydown.window.escape="closeModals()" x-cloak> @@ -1182,6 +1279,8 @@ const INDEX_HTML: &str = r#"<!doctype html> <div class="tx-field"><span class="tx-label">Status</span><span class="tx-value text" x-text="txTitle(tx)"></span></div> <div class="tx-field"><span class="tx-label">From</span><code class="tx-value hash" x-text="short(tx.from)"></code></div> <div class="tx-field" x-show="tx.to"><span class="tx-label">To</span><code class="tx-value hash" x-text="short(tx.to)"></code></div> + <div class="tx-field" x-show="isMineTx(tx)"><span class="tx-label">Proof Bits</span><span class="tx-value number"><span x-text="txProofBits(tx) ?? '-'"></span> / <span x-text="txDifficultyBits(tx) ?? '-'"></span></span></div> + <div class="tx-field" x-show="isMineTx(tx)"><span class="tx-label">Proof Hash</span><code class="tx-value hash" x-text="short(txProofHash(tx))"></code></div> <div class="tx-field"><span class="tx-label">Signature</span><code class="tx-value hash" x-text="short(tx.signature)"></code></div> </div> </div> @@ -1207,29 +1306,46 @@ const INDEX_HTML: &str = r#"<!doctype html> </div> </div> <div class="panel"> - <h3>Mining</h3> + <div class="panel-head mining-head"> + <h3>Burn</h3> + <label class="toggle-switch" :class="{ active: miningEnabled }"> + <input type="checkbox" :checked="miningEnabled" @change="setMiningEnabled($event.target.checked)"> + <span class="toggle-track" aria-hidden="true"><span class="toggle-thumb"></span></span> + <span class="toggle-text" x-text="miningEnabled ? 'On' : 'Off'"></span> + </label> + </div> + <div class="panel-description">Burn LUUN to compete for block leadership. Winning burns produce PoB/VDF blocks and earn the transaction fees in those blocks.</div> <form class="mining-form" @submit.prevent="saveBurn"> <div class="burn-fields"> - <label>LUUN per block<input x-model="burnAmountDraft" @input="burnAmountDirty = true" type="number" min="0" :max="burnSliderMax()" step="0.000001"></label> + <label>LUUN per block<input x-model="burnAmountDraft" @input="burnAmountDirty = true" type="number" min="0" step="0.000001"></label> <label>Fee<input x-model="burnFeeDraft" @input="burnAmountDirty = true" type="number" min="0" step="0.000001"></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="burnAmountDraft" @input="burnAmountDirty = true" type="range" min="0" :max="burnSliderMax()" step="0.000001"> - <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 class="panel"> - <h3>PoW issuance</h3> + <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-action-meta">Mine action reward: LUUN <span x-text="amountLabel(status.chain?.mine_reward ?? 0)"></span> / difficulty <span x-text="status.launch_profile?.mine_difficulty_bits ?? '-'"></span> bits</div> - <button class="primary" type="button" @click="minePowReward">Mine coin</button> + <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> + </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> </div> @@ -1342,6 +1458,8 @@ const INDEX_HTML: &str = r#"<!doctype html> <div class="tx-field"><span class="tx-label">Fee</span><span class="tx-value money">LUUN <span x-text="amountLabel(tx.fee ?? 0)"></span></span></div> <div class="tx-field"><span class="tx-label">From</span><code class="tx-value hash" x-text="short(txFrom(tx))"></code></div> <div class="tx-field" x-show="txTo(tx)"><span class="tx-label">To</span><code class="tx-value hash" x-text="short(txTo(tx))"></code></div> + <div class="tx-field" x-show="isMineTx(tx)"><span class="tx-label">Proof Bits</span><span class="tx-value number"><span x-text="txProofBits(tx) ?? '-'"></span> / <span x-text="txDifficultyBits(tx) ?? '-'"></span></span></div> + <div class="tx-field" x-show="isMineTx(tx)"><span class="tx-label">Proof Hash</span><code class="tx-value hash" x-text="short(txProofHash(tx))"></code></div> <div class="tx-field"><span class="tx-label">Signature</span><code class="tx-value hash" x-text="short(tx.signature)"></code></div> </div> </template> @@ -1362,6 +1480,8 @@ const INDEX_HTML: &str = r#"<!doctype html> <div class="tx-field"><span class="tx-label">Fee</span><span class="tx-value money">LUUN <span x-text="amountLabel(tx.fee ?? 0)"></span></span></div> <div class="tx-field"><span class="tx-label">From</span><code class="tx-value hash" x-text="short(txFrom(tx))"></code></div> <div class="tx-field" x-show="txTo(tx)"><span class="tx-label">To</span><code class="tx-value hash" x-text="short(txTo(tx))"></code></div> + <div class="tx-field" x-show="isMineTx(tx)"><span class="tx-label">Proof Bits</span><span class="tx-value number"><span x-text="txProofBits(tx) ?? '-'"></span> / <span x-text="txDifficultyBits(tx) ?? '-'"></span></span></div> + <div class="tx-field" x-show="isMineTx(tx)"><span class="tx-label">Proof Hash</span><code class="tx-value hash" x-text="short(txProofHash(tx))"></code></div> <div class="tx-field"><span class="tx-label">Signature</span><code class="tx-value hash" x-text="short(tx.signature)"></code></div> </div> </template> @@ -1392,6 +1512,25 @@ const INDEX_HTML: &str = r#"<!doctype html> </div> </section> </div> + <div class="setup-overlay transaction-overlay" x-show="showPowDifficultyInfo" x-transition.opacity @click.self="closePowDifficultyInfo()" role="dialog" aria-modal="true" aria-labelledby="pow-difficulty-title"> + <section class="tx-modal"> + <div class="tx-modal-head"> + <div class="tx-modal-title"> + <h2 id="pow-difficulty-title">PoW Difficulty</h2> + </div> + <button type="button" @click="closePowDifficultyInfo">Close</button> + </div> + <div class="info-copy"> + <p>Difficulty is adjusted to target about one mine action per block.</p> + <div class="info-facts"> + <div class="info-fact"><div class="label">Window</div><div class="value">10 blocks</div></div> + <div class="info-fact"><div class="label">Target</div><div class="value">10 mine actions</div></div> + <div class="info-fact"><div class="label">Max step</div><div class="value">2 bits</div></div> + </div> + <p>If a window includes more mine actions than the target, difficulty rises. If it includes fewer, difficulty falls. The initial difficulty is 12 bits.</p> + </div> + </section> + </div> <div class="setup-overlay transaction-overlay" x-show="selectedTransaction" x-transition.opacity @click.self="closeTransactionModal()" role="dialog" aria-modal="true" aria-labelledby="tx-modal-title"> <section class="tx-modal"> <div class="tx-modal-head"> @@ -1408,6 +1547,9 @@ const INDEX_HTML: &str = r#"<!doctype html> <div class="tx-field"><span class="tx-label">Fee</span><span class="tx-value money">LUUN <span x-text="amountLabel(selectedTransaction?.tx?.fee ?? 0)"></span></span></div> <div class="tx-field"><span class="tx-label">From</span><code class="tx-value hash" x-text="txFrom(selectedTransaction?.tx || {})"></code></div> <div class="tx-field" x-show="txTo(selectedTransaction?.tx || {})"><span class="tx-label">To</span><code class="tx-value hash" x-text="txTo(selectedTransaction?.tx || {})"></code></div> + <div class="tx-field" x-show="isMineTx(selectedTransaction?.tx)"><span class="tx-label">Difficulty</span><span class="tx-value number" x-text="txDifficultyBits(selectedTransaction?.tx) ?? '-'"></span></div> + <div class="tx-field" x-show="isMineTx(selectedTransaction?.tx)"><span class="tx-label">Proof Bits</span><span class="tx-value number" x-text="txProofBits(selectedTransaction?.tx) ?? '-'"></span></div> + <div class="tx-field" x-show="isMineTx(selectedTransaction?.tx)"><span class="tx-label">Proof Hash</span><code class="tx-value hash" x-text="txProofHash(selectedTransaction?.tx) || '-'"></code></div> </div> <div class="utxo-flow"> <div class="utxo-column"> @@ -1562,7 +1704,7 @@ mod tests { use super::{ TransferForm, dev_seed_verify_bypass_allowed, persist_burn_settings_config, - validate_transfer_form, wallet_transaction_rows, + persist_pow_mining_config, validate_transfer_form, wallet_transaction_rows, }; #[test] @@ -1636,15 +1778,41 @@ mod tests { let initial_config = ui_config.lock().await.clone(); config_store::save(&config_path, &initial_config).expect("initial config should save"); - persist_burn_settings_config(&ui_config, &config_path, 50 * MICRO_LUUN, 3 * MICRO_LUUN) - .await - .unwrap(); + persist_burn_settings_config( + &ui_config, + &config_path, + true, + 50 * MICRO_LUUN, + 3 * MICRO_LUUN, + ) + .await + .unwrap(); let config = config_store::load_or_create(&config_path).unwrap(); + assert!(config.mining_enabled); assert_eq!(config.burn_per_block, 50 * MICRO_LUUN); assert_eq!(config.burn_fee, 3 * MICRO_LUUN); } + #[tokio::test] + async fn pow_mining_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 { + setup_complete: true, + ..UiConfig::default() + })); + 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) + .await + .unwrap(); + let config = config_store::load_or_create(&config_path).unwrap(); + + assert!(config.pow_mining_enabled); + } + #[test] fn transfer_form_requires_recipient_amount_and_fee() { let error = validate_transfer_form(TransferForm { diff --git a/src/app.rs b/src/app.rs @@ -4,13 +4,13 @@ use std::{ time::{SystemTime, UNIX_EPOCH}, }; -use anyhow::Result; +use anyhow::{Context, Result}; use serde::{Deserialize, Serialize}; use tokio::sync::Mutex; use crate::domain::{ - Amount, Block, ChainSnapshot, ChainStatus, DEFAULT_TRANSACTION_FEE, LaunchProfile, Ledger, - OutPoint, PreparedBlock, Transaction, VDF_TARGET_BLOCK_MS, Wallet, run_vdf, + Amount, Block, ChainSnapshot, ChainStatus, DEFAULT_TRANSACTION_FEE, Ledger, OutPoint, + PreparedBlock, Transaction, VDF_TARGET_BLOCK_MS, Wallet, run_vdf, }; pub type SharedNode = Arc<Mutex<NodeCore>>; @@ -119,9 +119,9 @@ pub struct LaunchProfileStatus { #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] pub struct MiningStatus { pub automatic: bool, + pub pow_mining_enabled: 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>, @@ -130,16 +130,8 @@ 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 pow_mined: Option<Transaction>, pub burned: Option<Transaction>, pub block: Option<Block>, pub skipped_reason: Option<String>, @@ -147,6 +139,7 @@ pub struct AutoMineOutcome { #[derive(Clone, Debug)] pub struct AutoMinePlan { + pub pow_mined: Option<Transaction>, pub burned: Option<Transaction>, pub work: Option<PreparedBlock>, pub skipped_reason: Option<String>, @@ -156,9 +149,12 @@ pub struct AutoMinePlan { pub struct NodeCore { wallet: Wallet, ledger: Ledger, + automatic_mining_enabled: bool, + pow_mining_enabled: bool, burn_per_block: Amount, burn_fee: Amount, last_auto_burn_height: Option<u64>, + last_auto_pow_mine_anchor: Option<String>, outbox: Vec<GossipEnvelope>, } @@ -183,12 +179,31 @@ impl NodeCore { burn_per_block: Amount, burn_fee: Amount, ) -> Self { + Self::from_ledger_with_burn_fee_and_enabled( + wallet, + ledger, + burn_per_block > 0, + burn_per_block, + burn_fee, + ) + } + + pub fn from_ledger_with_burn_fee_and_enabled( + wallet: Wallet, + ledger: Ledger, + automatic_mining_enabled: bool, + burn_per_block: Amount, + burn_fee: Amount, + ) -> Self { Self { wallet, ledger, + automatic_mining_enabled, + pow_mining_enabled: false, burn_per_block, burn_fee, last_auto_burn_height: None, + last_auto_pow_mine_anchor: None, outbox: Vec::new(), } } @@ -200,6 +215,7 @@ impl NodeCore { pub fn replace_wallet(&mut self, wallet: Wallet) { self.wallet = wallet; self.last_auto_burn_height = None; + self.last_auto_pow_mine_anchor = None; } pub fn ledger(&self) -> &Ledger { @@ -344,10 +360,10 @@ impl NodeCore { mine_difficulty_bits: launch_profile.mine_difficulty_bits, }, mining: MiningStatus { - automatic: true, + automatic: self.automatic_mining_enabled, + pow_mining_enabled: self.pow_mining_enabled, burn_per_block: 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, @@ -358,34 +374,6 @@ 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(0); - let estimated_active_burn = estimated_active_burn_for_next_block(chain, launch_profile); - let average_active_burn_rate_microluun = - u128::from(estimated_active_burn) / u128::from(window); - let configured_burn_microluun = u128::from(self.burn_per_block); - 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) } @@ -395,15 +383,32 @@ impl NodeCore { amount: Amount, fee: Amount, ) -> Result<Option<Transaction>> { - let was_disabled = self.burn_per_block == 0; + self.set_automatic_burn_settings(amount > 0, amount, fee) + } + + pub fn set_automatic_burn_settings( + &mut self, + enabled: bool, + amount: Amount, + fee: Amount, + ) -> Result<Option<Transaction>> { + let was_disabled = !self.automatic_mining_enabled || self.burn_per_block == 0; + self.automatic_mining_enabled = enabled; self.burn_per_block = amount; self.burn_fee = fee; - if was_disabled && amount > 0 { + if was_disabled && enabled && amount > 0 { self.last_auto_burn_height = None; } self.prepare_automatic_burn() } + pub fn set_pow_mining_enabled(&mut self, enabled: bool) { + self.pow_mining_enabled = enabled; + if !enabled { + self.last_auto_pow_mine_anchor = None; + } + } + pub fn burn(&mut self, amount: Amount) -> Result<Transaction> { self.burn_with_fee(amount, 0) } @@ -472,6 +477,7 @@ impl NodeCore { pub fn automatic_mine_once(&mut self, timestamp_ms: u64) -> AutoMineOutcome { let plan = self.prepare_automatic_mining(timestamp_ms); let mut outcome = AutoMineOutcome { + pow_mined: plan.pow_mined, burned: plan.burned, block: None, skipped_reason: plan.skipped_reason, @@ -496,11 +502,31 @@ impl NodeCore { pub fn prepare_automatic_mining(&mut self, timestamp_ms: u64) -> AutoMinePlan { let mut plan = AutoMinePlan { + pow_mined: None, burned: None, work: None, skipped_reason: None, }; + let pow_error = match self.prepare_automatic_pow_mine() { + Ok(tx) => { + plan.pow_mined = tx; + None + } + Err(error) => Some(format!("automatic PoW mining failed: {error:#}")), + }; + + if !self.automatic_mining_enabled { + plan.skipped_reason = + Some(pow_error.unwrap_or_else(|| "automatic mining is off".to_string())); + return plan; + } + + if let Some(error) = pow_error { + plan.skipped_reason = Some(error); + return plan; + } + match self.prepare_automatic_burn() { Ok(tx) => plan.burned = tx, Err(error) => { @@ -535,8 +561,48 @@ impl NodeCore { plan } + fn prepare_automatic_pow_mine(&mut self) -> Result<Option<Transaction>> { + if !self.pow_mining_enabled { + return Ok(None); + } + let anchor = self + .ledger + .chain() + .last() + .map(|block| block.hash.clone()) + .context("ledger has no anchor block")?; + if self.last_auto_pow_mine_anchor.as_deref() == Some(anchor.as_str()) + || self.wallet_has_mine_for_anchor(&anchor) + { + return Ok(None); + } + let tx = self.ledger.build_mine(self.wallet.address())?; + if self.ledger.submit_transaction(tx.clone())? { + self.last_auto_pow_mine_anchor = Some(anchor); + self.outbox.push(GossipEnvelope::Transaction(tx.clone())); + return Ok(Some(tx)); + } + Ok(None) + } + + fn wallet_has_mine_for_anchor(&self, anchor: &str) -> bool { + self.ledger.pending().iter().any(|tx| { + matches!( + tx, + Transaction::Mine { + output, + anchor: tx_anchor, + .. + } if tx_anchor == anchor && output.address == self.wallet.address() + ) + }) + } + fn prepare_automatic_burn(&mut self) -> Result<Option<Transaction>> { let current_height = self.ledger.status().height; + if !self.automatic_mining_enabled { + return Ok(None); + } if self.burn_per_block == 0 { self.last_auto_burn_height = Some(current_height); return Ok(None); @@ -644,6 +710,7 @@ impl NodeCore { let imported = self.ledger.extend_from_snapshot(snapshot)?; if imported { self.last_auto_burn_height = None; + self.last_auto_pow_mine_anchor = None; self.enqueue_imported_blocks(previous_height); } Ok(()) @@ -660,6 +727,7 @@ impl NodeCore { self.ledger = ledger; self.last_auto_burn_height = None; + self.last_auto_pow_mine_anchor = None; self.enqueue_imported_blocks(previous_height); Ok(true) } @@ -681,101 +749,6 @@ impl NodeCore { } } -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; - 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; - let other_burn_rate = other_burn_rate_microluun 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>, @@ -897,24 +870,9 @@ pub fn now_ms() -> u64 { mod tests { use std::collections::BTreeMap; - use crate::domain::{MICRO_LUUN, Wallet}; + use crate::domain::{Transaction, Wallet}; - use super::{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 payout = 102 * MICRO_LUUN; - let fee = MICRO_LUUN; - let break_even = low_break_even_burn_microluun(payout, other_burn_rate, fee, payout); - - 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, payout, fee) > 0.0); - } + use super::{NodeConfig, NodeCore}; #[test] fn same_height_verified_import_does_not_reset_auto_burn_guard() { @@ -940,6 +898,49 @@ mod tests { let second = node.prepare_automatic_mining(2); assert!(second.burned.is_none()); } + + #[test] + fn automatic_pow_mining_queues_one_mine_action_per_anchor() { + let wallet = Wallet::from_seed("automatic-pow-mining-wallet"); + let mut node = NodeCore::new(NodeConfig { + wallet: wallet.clone(), + genesis_allocations: BTreeMap::new(), + vdf_rounds: 10, + burn_per_block: 0, + burn_fee: 0, + }); + + let disabled = node.prepare_automatic_mining(1); + assert!(disabled.pow_mined.is_none()); + assert_eq!( + disabled.skipped_reason.as_deref(), + Some("automatic mining is off") + ); + + node.set_pow_mining_enabled(true); + let first = node.prepare_automatic_mining(2); + let first_mine = first.pow_mined.as_ref().expect("PoW should be queued"); + let Transaction::Mine { + anchor, + output, + difficulty_bits, + .. + } = first_mine + else { + panic!("expected mine transaction"); + }; + assert_eq!(anchor, &node.chain().last().unwrap().hash); + assert_eq!(output.address, wallet.address()); + assert_eq!( + *difficulty_bits, + node.ledger().current_mine_difficulty_bits() + ); + assert_eq!(node.ledger().pending().len(), 1); + + let second = node.prepare_automatic_mining(3); + assert!(second.pow_mined.is_none()); + assert_eq!(node.ledger().pending().len(), 1); + } } #[derive(Debug, Default)] diff --git a/src/domain.rs b/src/domain.rs @@ -13,6 +13,12 @@ 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; pub const MINE_DIFFICULTY_BITS: u32 = 12; +const MINE_RETARGET_WINDOW_BLOCKS: u64 = 10; +const MINE_TARGET_ACTIONS_PER_BLOCK: u64 = 1; +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; const MAX_PENDING_TRANSACTIONS: usize = 10_000; const MAX_BLOCK_TRANSACTIONS: usize = 1_000; const DEFAULT_TICKET_MATURITY_DELAY: u64 = 3; @@ -711,6 +717,7 @@ pub struct ChainStatus { pub next_leader: Option<String>, pub launch_profile_hash: String, pub mine_reward: Amount, + pub current_mine_difficulty_bits: u32, pub balances: BTreeMap<String, Amount>, pub pending_transactions: usize, } @@ -1147,6 +1154,7 @@ impl Ledger { next_leader: self.expected_leader_for_next_block(), launch_profile_hash: self.launch_profile.hash(), mine_reward: self.mine_reward, + current_mine_difficulty_bits: self.current_mine_difficulty_bits(), balances: balances_from_utxos(&self.utxos), pending_transactions: self.pending.len(), } @@ -1226,6 +1234,10 @@ impl Ledger { &self.launch_profile } + pub fn current_mine_difficulty_bits(&self) -> u32 { + self.mine_difficulty_bits_for_anchor_height(self.tip().height) + } + pub fn balance_of(&self, address: &str) -> Amount { self.utxos .values() @@ -1367,7 +1379,7 @@ impl Ledger { amount: self.mine_reward, }; let anchor = self.tip().hash.clone(); - let difficulty_bits = self.launch_profile.mine_difficulty_bits; + let difficulty_bits = self.current_mine_difficulty_bits(); for nonce in 0..u64::MAX { let signature = mine_signature(&output, &anchor, nonce, difficulty_bits); if !hash_meets_difficulty(&signature, difficulty_bits) { @@ -1751,11 +1763,19 @@ impl Ledger { if output.amount != self.mine_reward { bail!("mine transaction reward is invalid"); } - if *difficulty_bits != self.launch_profile.mine_difficulty_bits { - bail!("mine transaction difficulty is invalid"); + let anchor_block = self + .chain + .iter() + .find(|block| block.hash == *anchor) + .context("mine transaction anchor is not on this chain")?; + let anchor_age = self.tip().height.saturating_sub(anchor_block.height); + if anchor_age > MINE_MAX_ANCHOR_AGE_BLOCKS { + bail!("mine transaction anchor is too old"); } - if !self.has_block(anchor) { - bail!("mine transaction anchor is not on this chain"); + let required_difficulty = + self.mine_difficulty_bits_for_anchor_height(anchor_block.height); + if *difficulty_bits != required_difficulty { + bail!("mine transaction difficulty is invalid"); } } Ok(()) @@ -1773,6 +1793,23 @@ impl Ledger { select_weighted_ticket(self.tip(), height, &self.tickets) } + fn mine_difficulty_bits_for_anchor_height(&self, anchor_height: u64) -> u32 { + let mut difficulty = self.launch_profile.mine_difficulty_bits; + let mut window_end = MINE_RETARGET_WINDOW_BLOCKS; + while window_end <= anchor_height { + let window_start = window_end + 1 - MINE_RETARGET_WINDOW_BLOCKS; + let mine_actions = self + .chain + .iter() + .filter(|block| window_start <= block.height && block.height <= window_end) + .map(mine_action_count) + .sum::<u64>(); + difficulty = retarget_mine_difficulty_bits(difficulty, mine_actions); + window_end = window_end.saturating_add(MINE_RETARGET_WINDOW_BLOCKS); + } + difficulty + } + fn tip(&self) -> &Block { self.chain .last() @@ -1952,6 +1989,55 @@ fn ticket_is_eligible_for_height(ticket: &BurnTicket, height: u64) -> bool { ticket.eligible_from_height <= height && height <= ticket.eligible_until_height } +fn mine_action_count(block: &Block) -> u64 { + block + .transactions + .iter() + .filter(|transaction| matches!(transaction, Transaction::Mine { .. })) + .count() as u64 +} + +fn retarget_mine_difficulty_bits(current: u32, mine_actions: u64) -> u32 { + let target = MINE_RETARGET_WINDOW_BLOCKS.saturating_mul(MINE_TARGET_ACTIONS_PER_BLOCK); + if target == 0 || mine_actions == target { + return current.clamp(MINE_MIN_DIFFICULTY_BITS, MINE_MAX_DIFFICULTY_BITS); + } + + let step = if mine_actions > target { + floor_log2_ratio(mine_actions, target).min(MINE_MAX_RETARGET_STEP_BITS) + } else if mine_actions == 0 { + MINE_MAX_RETARGET_STEP_BITS + } else { + floor_log2_ratio(target, mine_actions).min(MINE_MAX_RETARGET_STEP_BITS) + }; + + if step == 0 { + return current.clamp(MINE_MIN_DIFFICULTY_BITS, MINE_MAX_DIFFICULTY_BITS); + } + if mine_actions > target { + current + .saturating_add(step) + .clamp(MINE_MIN_DIFFICULTY_BITS, MINE_MAX_DIFFICULTY_BITS) + } else { + current + .saturating_sub(step) + .clamp(MINE_MIN_DIFFICULTY_BITS, MINE_MAX_DIFFICULTY_BITS) + } +} + +fn floor_log2_ratio(numerator: u64, denominator: u64) -> u32 { + if denominator == 0 || numerator <= denominator { + return 0; + } + let mut step = 0_u32; + let mut threshold = denominator; + while threshold <= numerator / 2 { + threshold = threshold.saturating_mul(2); + step = step.saturating_add(1); + } + step +} + fn ensure_block_has_burn(transactions: &[Transaction]) -> Result<()> { if !transactions.iter().any(Transaction::is_burn) { bail!("block must include at least one burn transaction"); @@ -2559,6 +2645,23 @@ mod tests { balances_from_utxos(&ledger.utxos_after_valid_pending().unwrap()) } + fn ledger_with_allocation(wallet: &Wallet, amount: Amount) -> Ledger { + let mut genesis = BTreeMap::new(); + genesis.insert(wallet.address().to_string(), amount); + Ledger::new(genesis, 1) + } + + fn mine_burn_block_with_mines(ledger: &mut Ledger, wallet: &Wallet, mine_actions: usize) { + let burn = ledger.build_burn(wallet, MICRO_LUUN, 0).unwrap(); + ledger.submit_transaction(burn).unwrap(); + for _ in 0..mine_actions { + let mine = ledger.build_mine(wallet.address()).unwrap(); + ledger.submit_transaction(mine).unwrap(); + } + let block = ledger.mine_next_block(wallet, ledger.height() + 1).unwrap(); + ledger.apply_block(block).unwrap(); + } + #[test] fn wallet_utxos_only_include_outputs_owned_by_address() { let alice = Wallet::from_seed("wallet-utxos-alice"); @@ -2833,4 +2936,56 @@ mod tests { "unselected future tickets should remain pending" ); } + + #[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); + + for _ in 0..MINE_RETARGET_WINDOW_BLOCKS { + mine_burn_block_with_mines(&mut ledger, &alice, 2); + } + + assert_eq!( + ledger.current_mine_difficulty_bits(), + MINE_DIFFICULTY_BITS + 1 + ); + let mine = ledger.build_mine(alice.address()).unwrap(); + let Transaction::Mine { + difficulty_bits, .. + } = mine + else { + panic!("expected mine action"); + }; + assert_eq!(difficulty_bits, MINE_DIFFICULTY_BITS + 1); + } + + #[test] + fn mine_difficulty_decreases_when_issuance_is_below_target_window() { + let alice = Wallet::from_seed("mine-difficulty-down-alice"); + let mut ledger = ledger_with_allocation(&alice, 100 * MICRO_LUUN); + + for _ in 0..MINE_RETARGET_WINDOW_BLOCKS { + mine_burn_block_with_mines(&mut ledger, &alice, 0); + } + + assert_eq!( + ledger.current_mine_difficulty_bits(), + MINE_DIFFICULTY_BITS - MINE_MAX_RETARGET_STEP_BITS + ); + } + + #[test] + fn mine_actions_expire_when_anchor_is_too_old() { + let alice = Wallet::from_seed("mine-anchor-expiry-alice"); + let mut ledger = ledger_with_allocation(&alice, 100 * MICRO_LUUN); + let stale_mine = ledger.build_mine(alice.address()).unwrap(); + + for _ in 0..=MINE_MAX_ANCHOR_AGE_BLOCKS { + mine_burn_block_with_mines(&mut ledger, &alice, 0); + } + + let error = ledger.submit_transaction(stale_mine).unwrap_err(); + assert!(format!("{error:#}").contains("mine transaction anchor is too old")); + } } diff --git a/src/main.rs b/src/main.rs @@ -11,12 +11,13 @@ use anyhow::{Context, Result, bail}; use luun::{ adapters::{chain_store::SqliteChainStore, config_store, http, p2p, wallet_store}, app::{NodeCore, PeerBook, SharedNode, SharedPeerBook, now_ms}, - domain::{Amount, BLOCK_REWARD, ChainSnapshot, GenesisBurn, Ledger, MICRO_LUUN, run_vdf}, + domain::{Amount, ChainSnapshot, GenesisBurn, Ledger, MICRO_LUUN, run_vdf}, }; use tokio::sync::Mutex; const GENESIS_BOOTSTRAP_BURN_AMOUNT: Amount = MICRO_LUUN; -const GENESIS_INITIAL_BURN_PER_BLOCK: Amount = BLOCK_REWARD; +const GENESIS_INITIAL_BURN_PER_BLOCK: Amount = GENESIS_BOOTSTRAP_BURN_AMOUNT; +const GENESIS_INITIAL_BURN_FEE: Amount = 0; const VDF_MEASUREMENT_INITIAL_ROUNDS: u32 = 1_000_000; const VDF_MEASUREMENT_MAX_ROUNDS: u32 = 100_000_000; const VDF_MEASUREMENT_MIN_ELAPSED: Duration = Duration::from_millis(150); @@ -42,19 +43,26 @@ async fn main() -> Result<()> { let mut ui_config = config_store::load_or_create(&config_path)?; if opts.chain_mode == ChainMode::Genesis { ui_config.setup_complete = false; + ui_config.mining_enabled = true; + ui_config.pow_mining_enabled = false; + ui_config.burn_per_block = GENESIS_INITIAL_BURN_PER_BLOCK; + ui_config.burn_fee = GENESIS_INITIAL_BURN_FEE; config_store::save(&config_path, &ui_config)?; } 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 initial_burn_fee = initial_burn_fee(&opts, &ui_config); - let node: SharedNode = Arc::new(Mutex::new(NodeCore::from_ledger_with_burn_fee( + let mut node_core = NodeCore::from_ledger_with_burn_fee_and_enabled( wallet, ledger, + ui_config.mining_enabled, initial_burn_per_block, initial_burn_fee, - ))); + ); + node_core.set_pow_mining_enabled(ui_config.pow_mining_enabled); + 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(); peers.extend(opts.peers); @@ -299,6 +307,13 @@ fn initial_burn_per_block(opts: &CliOptions, ui_config: &config_store::UiConfig) } } +fn initial_burn_fee(opts: &CliOptions, ui_config: &config_store::UiConfig) -> Amount { + match opts.chain_mode { + ChainMode::Genesis => GENESIS_INITIAL_BURN_FEE, + ChainMode::Setup | ChainMode::Join => ui_config.burn_fee, + } +} + fn print_help() { println!("{}", help_text()); } @@ -547,14 +562,15 @@ mod tests { use luun::{ adapters::{chain_store::SqliteChainStore, config_store::UiConfig}, app::{DEFAULT_BURN_PER_BLOCK, NodeCore}, - domain::{BLOCK_REWARD, GenesisBurn, Ledger, Wallet}, + domain::{BLOCK_REWARD, GenesisBurn, Ledger, MICRO_LUUN, Wallet}, }; use rusqlite::Connection; use tempfile::tempdir; use tokio::sync::Mutex; use super::{ - ChainMode, CliOptions, extrapolate_vdf_rounds, help_text, initial_burn_per_block, + ChainMode, CliOptions, GENESIS_INITIAL_BURN_FEE, GENESIS_INITIAL_BURN_PER_BLOCK, + extrapolate_vdf_rounds, help_text, initial_burn_fee, initial_burn_per_block, initialize_ledger, measure_vdf_rounds, persist_chain_snapshot, run_chain_persistence_with_interval, validate_wallet_for_mode, }; @@ -631,18 +647,65 @@ mod tests { } #[test] - fn genesis_mode_starts_with_full_reward_burn_rate() { + fn genesis_mode_starts_with_bootstrap_burn_rate_and_zero_fee() { 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), BLOCK_REWARD); + assert_eq!( + initial_burn_per_block(&genesis, &configured), + GENESIS_INITIAL_BURN_PER_BLOCK + ); + assert_eq!( + initial_burn_fee(&genesis, &configured), + GENESIS_INITIAL_BURN_FEE + ); + } + + #[test] + fn genesis_default_auto_mining_keeps_burning_after_first_block() { + let wallet = Wallet::from_seed("genesis-auto-burn-wallet"); + let mut genesis = BTreeMap::new(); + genesis.insert(wallet.address().to_string(), MICRO_LUUN); + let ledger = Ledger::new_with_genesis_burns( + genesis, + vec![GenesisBurn::new(wallet.address(), MICRO_LUUN)], + 1, + ) + .unwrap(); + let mut node = NodeCore::from_ledger_with_burn_fee( + wallet.clone(), + ledger, + GENESIS_INITIAL_BURN_PER_BLOCK, + GENESIS_INITIAL_BURN_FEE, + ); + + let first = node.automatic_mine_once(1_000); + let second = node.automatic_mine_once(2_000); + + assert_eq!( + first.burned.as_ref().map(|tx| tx.amount()), + Some(MICRO_LUUN) + ); + assert!(first.block.is_some(), "{first:?}"); + assert_eq!( + second.burned.as_ref().map(|tx| tx.amount()), + Some(MICRO_LUUN) + ); + assert!(second.block.is_some(), "{second:?}"); + assert!( + second.skipped_reason.as_deref().is_none_or(|reason| { + !reason.contains("block must include at least one burn transaction") + }), + "{second:?}" + ); + assert!(node.ledger().balance_of(wallet.address()) >= BLOCK_REWARD - 2 * MICRO_LUUN); } #[test] - fn non_genesis_modes_start_with_configured_burn_rate() { + fn non_genesis_modes_start_with_configured_burn_rate_and_fee() { let configured = UiConfig { burn_per_block: 50, burn_fee: 3, @@ -651,9 +714,11 @@ mod tests { let setup = parse(&[]).unwrap().unwrap(); assert_eq!(initial_burn_per_block(&setup, &configured), 50); + assert_eq!(initial_burn_fee(&setup, &configured), 3); let join = parse(&["--join", "127.0.0.1:9444"]).unwrap().unwrap(); assert_eq!(initial_burn_per_block(&join, &configured), 50); + assert_eq!(initial_burn_fee(&join, &configured), 3); assert_eq!( initial_burn_per_block(&setup, &UiConfig::default()), diff --git a/tests/luun.rs b/tests/luun.rs @@ -110,7 +110,13 @@ fn starter_node(wallet: Wallet) -> NodeCore { let ledger = Ledger::new_with_genesis_burns(genesis, vec![GenesisBurn::new(wallet.address(), 1)], 25) .unwrap(); - NodeCore::from_ledger(wallet, ledger, DEFAULT_BURN_PER_BLOCK) + NodeCore::from_ledger_with_burn_fee_and_enabled( + wallet, + ledger, + true, + DEFAULT_BURN_PER_BLOCK, + MICRO_LUUN, + ) } #[test] @@ -593,7 +599,7 @@ fn default_automatic_mining_does_not_burn() { outcome .skipped_reason .as_deref() - .is_some_and(|reason| reason.contains("at least one burn")) + .is_some_and(|reason| reason.contains("automatic mining is off")) ); assert_eq!(node.ledger().balance_of(alice.address()), 1_000); } @@ -607,9 +613,11 @@ fn burn_per_block_can_be_set_to_zero() { let burned = node.set_burn_per_block(25).unwrap(); assert!(burned.is_some()); + assert!(node.status().mining.automatic); assert_eq!(node.status().mining.burn_per_block, 25); let burned = node.set_burn_per_block(0).unwrap(); assert!(burned.is_none()); + assert!(!node.status().mining.automatic); assert_eq!(node.status().mining.burn_per_block, 0); } @@ -739,7 +747,13 @@ fn waiting_wallet_gossips_pending_burn_to_selected_leader() { Some(bob.address()) ); let mut alice_node = NodeCore::from_ledger(alice.clone(), ledger.clone(), 1); - let mut bob_node = NodeCore::from_ledger(bob.clone(), ledger, DEFAULT_BURN_PER_BLOCK); + let mut bob_node = NodeCore::from_ledger_with_burn_fee_and_enabled( + bob.clone(), + ledger, + true, + DEFAULT_BURN_PER_BLOCK, + MICRO_LUUN, + ); let alice_outcome = alice_node.automatic_mine_once(1); assert!(alice_outcome.burned.is_some()); @@ -1593,6 +1607,9 @@ fn friend_node_can_join_snapshot_from_started_chain() { let mut alice_genesis = BTreeMap::new(); alice_genesis.insert(alice.address().to_string(), 1_000); let mut alice_node = node("alice", alice.clone(), alice_genesis); + alice_node + .set_automatic_burn_settings(true, DEFAULT_BURN_PER_BLOCK, MICRO_LUUN) + .unwrap(); alice_node.burn(1).unwrap(); alice_node.automatic_mine_once(1);