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:
| M | assets/luun-ui.js | | | 124 | +++++++++++++++++++++++++++++++++++++++++-------------------------------------- |
| M | src/adapters/config_store.rs | | | 44 | ++++++++++++++++++++++++++++++++++++++++++++ |
| M | src/adapters/http.rs | | | 278 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------------- |
| M | src/app.rs | | | 315 | ++++++++++++++++++++++++++++++++++++++++--------------------------------------- |
| M | src/domain.rs | | | 165 | ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--- |
| M | src/main.rs | | | 85 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------- |
| M | tests/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);