commit eb06c2e50ee9d4d85cdb1014047a0b650d5fee6d
parent 0fb58688671abdbb19dc5fbcce53071089ce9d5d
Author: Joris Hartog <jorishartog@hotmail.com>
Date: Fri, 24 Jul 2026 11:07:39 +0200
Use microluun amount units
Diffstat:
7 files changed, 187 insertions(+), 127 deletions(-)
diff --git a/assets/luun-ui.js b/assets/luun-ui.js
@@ -24,12 +24,12 @@ window.luunApp = function luunApp() {
setupFeedback: null,
burnAmount: 0,
burnAmountDraft: 0,
- burnFee: 1,
- burnFeeDraft: 1,
+ burnFee: 1000000,
+ burnFeeDraft: "1",
burnAmountDirty: false,
transferTo: "",
transferAmount: null,
- transferFee: 1,
+ transferFee: "1",
peerAddress: "",
flash: null,
flashTimer: null,
@@ -276,8 +276,8 @@ window.luunApp = function luunApp() {
this.burnAmount = status.mining?.burn_per_block ?? this.burnAmount;
this.burnFee = status.mining?.automatic_burn_fee ?? this.burnFee;
if (!this.burnAmountDirty) {
- this.burnAmountDraft = this.burnAmount;
- this.burnFeeDraft = this.burnFee;
+ this.burnAmountDraft = this.amountLabel(this.burnAmount);
+ this.burnFeeDraft = this.amountLabel(this.burnFee);
}
this.lastUpdated = new Date();
} catch (error) {
@@ -438,14 +438,14 @@ window.luunApp = function luunApp() {
async saveBurn() {
try {
- const amount = Math.max(0, Math.trunc(Number(this.burnAmountDraft) || 0));
- const fee = Math.max(0, Math.trunc(Number(this.burnFeeDraft) || 0));
- this.burnAmountDraft = amount;
- this.burnFeeDraft = fee;
+ const amount = this.parseLuunAmount(this.burnAmountDraft);
+ const fee = this.parseLuunAmount(this.burnFeeDraft);
+ this.burnAmountDraft = this.amountLabel(amount);
+ this.burnFeeDraft = this.amountLabel(fee);
await this.postForm(
"/api/settings/burn-per-block",
{ amount, fee },
- `Burn rate set to ${amount} LUUN per block with ${fee} fee`
+ `Burn rate set to ${this.amountLabel(amount)} LUUN per block with ${this.amountLabel(fee)} fee`
);
this.burnAmountDirty = false;
this.burnAmount = amount;
@@ -456,7 +456,7 @@ window.luunApp = function luunApp() {
},
automaticBurnFeeDraft() {
- return Math.max(0, Math.trunc(Number(this.burnFeeDraft) || 0));
+ return this.parseLuunAmount(this.burnFeeDraft);
},
miningEconomics() {
@@ -464,7 +464,7 @@ window.luunApp = function luunApp() {
},
burnSliderMax() {
- return Math.max(0, Math.trunc(Number(this.miningEconomics().slider_max ?? this.latestBlockReward())));
+ return this.amountNumber(this.miningEconomics().slider_max ?? this.latestBlockReward());
},
latestBlock() {
@@ -488,15 +488,14 @@ window.luunApp = function luunApp() {
},
breakEvenBurn() {
- const microluun = Math.max(
+ return Math.max(
0,
Math.trunc(Number(this.miningEconomics().break_even_burn_microluun) || 0)
);
- return microluun / 1000000;
},
breakEvenPercent() {
- const max = this.burnSliderMax();
+ 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;
},
@@ -510,25 +509,42 @@ window.luunApp = function luunApp() {
const burned = this.estimatedActiveBurnTotal();
const fee = this.status.mining?.automatic_burn_fee ?? this.automaticBurnFeeDraft();
const window = this.ticketWindow();
- return `Marker: break-even near ${this.formatBurn(marker)} LUUN, using last payout ${payout}, estimated active burns ${this.formatBurn(burned)}, fee ${fee}, and ${window} eligible blocks.`;
+ 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.`;
+ },
+
+ amountLabel(value) {
+ const microluun = Math.max(0, Math.trunc(Number(value) || 0));
+ const whole = Math.floor(microluun / 1000000);
+ const fractional = String(microluun % 1000000).padStart(6, "0").replace(/0+$/, "");
+ return fractional ? `${whole}.${fractional}` : `${whole}`;
+ },
+
+ amountNumber(value) {
+ return Number(this.amountLabel(value));
},
- formatBurn(value) {
- const amount = Math.max(0, Number(value) || 0);
- return amount >= 10 ? amount.toFixed(1) : amount.toFixed(2);
+ parseLuunAmount(value) {
+ const text = String(value ?? "").trim();
+ if (!text) return 0;
+ const match = text.match(/^(\d+)(?:\.(\d{0,6})\d*)?$/);
+ if (!match) return 0;
+ const whole = Number(match[1] || 0);
+ const fractional = Number((match[2] || "").padEnd(6, "0"));
+ return Math.max(0, Math.trunc(whole * 1000000 + fractional));
},
async sendTransfer() {
try {
- const amount = this.transferAmount;
- const fee = this.transferFee;
+ const amount = this.parseLuunAmount(this.transferAmount);
+ const fee = this.parseLuunAmount(this.transferFee);
const recipient = this.short(this.transferTo);
await this.postForm(
"/api/transfer",
{ to: this.transferTo, amount, fee },
- `Queued transfer of ${amount} LUUN to ${recipient} with ${fee} fee`
+ `Queued transfer of ${this.amountLabel(amount)} LUUN to ${recipient} with ${this.amountLabel(fee)} fee`
);
this.transferTo = "";
+ this.transferAmount = null;
} catch (error) {
this.showFlash(error.message, "error");
}
@@ -646,7 +662,7 @@ window.luunApp = function luunApp() {
},
txInputAmountLabel(input) {
- return input.amount === null || input.amount === undefined ? "-" : `LUUN ${input.amount}`;
+ return input.amount === null || input.amount === undefined ? "-" : `LUUN ${this.amountLabel(input.amount)}`;
},
txFeeRecipient(tx) {
diff --git a/src/adapters/config_store.rs b/src/adapters/config_store.rs
@@ -7,10 +7,11 @@ use std::{
use anyhow::{Context, Result, bail};
use serde::{Deserialize, Serialize};
-use crate::domain::Amount;
+use crate::domain::{Amount, DEFAULT_TRANSACTION_FEE, MICRO_LUUN};
const CONFIG_FILE_VERSION: u32 = 1;
-pub const DEFAULT_BURN_FEE: Amount = 1;
+const AMOUNT_UNIT_MICROLUUN: &str = "microluun";
+pub const DEFAULT_BURN_FEE: Amount = DEFAULT_TRANSACTION_FEE;
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
pub struct UiConfig {
@@ -34,6 +35,8 @@ impl Default for UiConfig {
#[derive(Debug, Deserialize, Serialize)]
struct ConfigFile {
version: u32,
+ #[serde(default)]
+ amount_unit: Option<String>,
setup_complete: bool,
#[serde(default)]
burn_per_block: Amount,
@@ -44,7 +47,7 @@ struct ConfigFile {
}
fn default_burn_fee() -> Amount {
- DEFAULT_BURN_FEE
+ 1
}
pub fn load_or_create(path: &Path) -> Result<UiConfig> {
@@ -65,6 +68,7 @@ pub fn save(path: &Path, config: &UiConfig) -> Result<()> {
let stored = ConfigFile {
version: CONFIG_FILE_VERSION,
+ amount_unit: Some(AMOUNT_UNIT_MICROLUUN.to_string()),
setup_complete: config.setup_complete,
burn_per_block: config.burn_per_block,
burn_fee: config.burn_fee,
@@ -93,10 +97,16 @@ fn load(path: &Path) -> Result<UiConfig> {
);
}
+ let scale = if stored.amount_unit.as_deref() == Some(AMOUNT_UNIT_MICROLUUN) {
+ 1
+ } else {
+ MICRO_LUUN
+ };
+
Ok(UiConfig {
setup_complete: stored.setup_complete,
- burn_per_block: stored.burn_per_block,
- burn_fee: stored.burn_fee,
+ burn_per_block: stored.burn_per_block.saturating_mul(scale),
+ burn_fee: stored.burn_fee.saturating_mul(scale),
peers: stored.peers,
})
}
@@ -116,6 +126,8 @@ mod tests {
use tempfile::tempdir;
+ use crate::domain::MICRO_LUUN;
+
use super::{DEFAULT_BURN_FEE, UiConfig, load_or_create, save};
#[test]
@@ -128,9 +140,10 @@ mod tests {
assert!(!config.setup_complete);
let stored = fs::read_to_string(path).unwrap();
assert!(stored.contains("\"version\": 1"));
+ assert!(stored.contains("\"amount_unit\": \"microluun\""));
assert!(stored.contains("\"setup_complete\": false"));
assert!(stored.contains("\"burn_per_block\": 0"));
- assert!(stored.contains("\"burn_fee\": 1"));
+ assert!(stored.contains("\"burn_fee\": 1000000"));
assert!(stored.contains("\"peers\": []"));
}
@@ -143,8 +156,8 @@ mod tests {
&path,
&UiConfig {
setup_complete: true,
- burn_per_block: 50,
- burn_fee: 3,
+ burn_per_block: 50 * MICRO_LUUN,
+ burn_fee: 3 * MICRO_LUUN,
peers: vec!["127.0.0.1:9444".to_string()],
},
)
@@ -152,8 +165,8 @@ mod tests {
let config = load_or_create(&path).unwrap();
assert!(config.setup_complete);
- assert_eq!(config.burn_per_block, 50);
- assert_eq!(config.burn_fee, 3);
+ 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"]);
}
diff --git a/src/adapters/http.rs b/src/adapters/http.rs
@@ -982,7 +982,7 @@ const INDEX_HTML: &str = r#"<!doctype html>
.block-card { flex-basis: 108px; }
}
</style>
- <script defer src="/assets/luun-ui.js?v=42"></script>
+ <script defer src="/assets/luun-ui.js?v=43"></script>
<script defer src="/assets/alpine.min.js"></script>
</head>
<body x-data="luunApp()" x-init="init()" @keydown.window.escape="closeModals()" x-cloak>
@@ -1023,7 +1023,7 @@ const INDEX_HTML: &str = r#"<!doctype html>
<div class="page-title">
<button class="wallet-balance-line" type="button" @click="openWalletUtxosModal" title="Show wallet UTXOs">
<span class="tx-label">Balance</span>
- <span class="tx-value money">LUUN <span x-text="status.wallet_balance ?? '-'"></span></span>
+ <span class="tx-value money">LUUN <span x-text="amountLabel(status.wallet_balance)"></span></span>
</button>
</div>
<div class="wallet-grid">
@@ -1032,8 +1032,8 @@ const INDEX_HTML: &str = r#"<!doctype html>
<h3>Send</h3>
<form @submit.prevent="sendTransfer">
<label>Recipient<input x-model="transferTo" autocomplete="off" required></label>
- <label>Amount<input x-model.number="transferAmount" type="number" min="1" required></label>
- <label>Fee<input x-model.number="transferFee" type="number" min="0" required></label>
+ <label>Amount<input x-model="transferAmount" type="number" min="0.000001" step="0.000001" required></label>
+ <label>Fee<input x-model="transferFee" type="number" min="0" step="0.000001" required></label>
<button class="primary" type="submit">Send</button>
</form>
</div>
@@ -1057,8 +1057,8 @@ const INDEX_HTML: &str = r#"<!doctype html>
<div class="wallet-tx-row" :class="{ pending: tx.status === 'pending' }" role="button" tabindex="0" @click="openTransactionModal(tx, { source: 'Wallet' })" @keydown.enter.prevent="openTransactionModal(tx, { source: 'Wallet' })" @keydown.space.prevent="openTransactionModal(tx, { source: 'Wallet' })">
<span class="pill" :class="tx.kind" x-text="tx.direction"></span>
<div class="wallet-tx-main">
- <div class="tx-field"><span class="tx-label">Amount</span><span class="tx-value money">LUUN <span x-text="tx.amount"></span></span></div>
- <div class="tx-field"><span class="tx-label">Fee</span><span class="tx-value money">LUUN <span x-text="tx.fee ?? 0"></span></span></div>
+ <div class="tx-field"><span class="tx-label">Amount</span><span class="tx-value money">LUUN <span x-text="amountLabel(tx.amount)"></span></span></div>
+ <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">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>
@@ -1090,14 +1090,14 @@ const INDEX_HTML: &str = r#"<!doctype html>
<h3>Mining</h3>
<form class="mining-form" @submit.prevent="saveBurn">
<div class="burn-fields">
- <label>LUUN per block<input x-model.number="burnAmountDraft" @input="burnAmountDirty = true" type="number" min="0" :max="burnSliderMax()"></label>
- <label>Fee<input x-model.number="burnFeeDraft" @input="burnAmountDirty = true" type="number" min="0"></label>
+ <label>LUUN per block<input x-model="burnAmountDraft" @input="burnAmountDirty = true" type="number" min="0" :max="burnSliderMax()" 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.number="burnAmountDraft" @input="burnAmountDirty = true" type="range" min="0" :max="burnSliderMax()" step="1">
+ <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>
@@ -1199,10 +1199,10 @@ const INDEX_HTML: &str = r#"<!doctype html>
<div class="detail-kv"><div class="key">Hash</div><code x-text="selectedBlock.hash"></code></div>
<div class="detail-kv"><div class="key">Previous</div><code x-text="short(selectedBlock.prev_hash)"></code></div>
<div class="detail-kv"><div class="key">Miner</div><code x-text="short(selectedBlock.miner)"></code></div>
- <div class="detail-kv"><div class="key">Reward</div><div x-text="selectedBlock.reward"></div></div>
+ <div class="detail-kv"><div class="key">Reward</div><div>LUUN <span x-text="amountLabel(selectedBlock.reward)"></span></div></div>
<div class="detail-kv"><div class="key">Burns</div><div x-text="blockBurnCount(selectedBlock)"></div></div>
<div class="detail-kv"><div class="key">Transfers</div><div x-text="blockTransferCount(selectedBlock)"></div></div>
- <div class="detail-kv"><div class="key">Total Burned</div><div x-text="blockBurned(selectedBlock)"></div></div>
+ <div class="detail-kv"><div class="key">Total Burned</div><div>LUUN <span x-text="amountLabel(blockBurned(selectedBlock))"></span></div></div>
<div class="detail-kv"><div class="key">VDF</div><div><span x-text="selectedBlock.vdf_rounds"></span> rounds</div></div>
</div>
<div class="tx-list">
@@ -1210,8 +1210,8 @@ const INDEX_HTML: &str = r#"<!doctype html>
<template x-for="tx in selectedBlock.transactions" :key="tx.signature">
<div class="tx-card" role="button" tabindex="0" @click="openTransactionModal(tx, { source: 'Block', blockHeight: selectedBlock.height, blockMiner: selectedBlock.miner })" @keydown.enter.prevent="openTransactionModal(tx, { source: 'Block', blockHeight: selectedBlock.height, blockMiner: selectedBlock.miner })" @keydown.space.prevent="openTransactionModal(tx, { source: 'Block', blockHeight: selectedBlock.height, blockMiner: selectedBlock.miner })">
<span class="pill" :class="tx.kind" x-text="tx.kind"></span>
- <div class="tx-field"><span class="tx-label">Amount</span><span class="tx-value money">LUUN <span x-text="txAmount(tx)"></span></span></div>
- <div class="tx-field"><span class="tx-label">Fee</span><span class="tx-value money">LUUN <span x-text="tx.fee ?? 0"></span></span></div>
+ <div class="tx-field"><span class="tx-label">Amount</span><span class="tx-value money">LUUN <span x-text="amountLabel(txAmount(tx))"></span></span></div>
+ <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"><span class="tx-label">Signature</span><code class="tx-value hash" x-text="short(tx.signature)"></code></div>
@@ -1230,8 +1230,8 @@ const INDEX_HTML: &str = r#"<!doctype html>
<template x-for="tx in mempool" :key="tx.signature">
<div class="mempool-item" role="button" tabindex="0" @click="openTransactionModal(tx, { source: 'Mempool' })" @keydown.enter.prevent="openTransactionModal(tx, { source: 'Mempool' })" @keydown.space.prevent="openTransactionModal(tx, { source: 'Mempool' })">
<span class="pill" :class="tx.kind" x-text="tx.kind"></span>
- <div class="tx-field"><span class="tx-label">Amount</span><span class="tx-value money">LUUN <span x-text="txAmount(tx)"></span></span></div>
- <div class="tx-field"><span class="tx-label">Fee</span><span class="tx-value money">LUUN <span x-text="tx.fee ?? 0"></span></span></div>
+ <div class="tx-field"><span class="tx-label">Amount</span><span class="tx-value money">LUUN <span x-text="amountLabel(txAmount(tx))"></span></span></div>
+ <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"><span class="tx-label">Signature</span><code class="tx-value hash" x-text="short(tx.signature)"></code></div>
@@ -1248,14 +1248,14 @@ const INDEX_HTML: &str = r#"<!doctype html>
<div class="tx-modal-head">
<div class="tx-modal-title">
<h2 id="wallet-utxos-title">Wallet UTXOs</h2>
- <div class="tx-field"><span class="tx-label">Total</span><span class="tx-value money">LUUN <span x-text="status.wallet_balance ?? '-'"></span></span></div>
+ <div class="tx-field"><span class="tx-label">Total</span><span class="tx-value money">LUUN <span x-text="amountLabel(status.wallet_balance)"></span></span></div>
</div>
<button type="button" @click="closeWalletUtxosModal">Close</button>
</div>
<div class="utxo-list">
<template x-for="utxo in walletUtxos" :key="`${utxo.outpoint.txid}:${utxo.outpoint.index}`">
<div class="wallet-utxo-row">
- <div class="utxo-node-label"><span>UTXO</span><span class="utxo-node-amount">LUUN <span x-text="utxo.amount"></span></span></div>
+ <div class="utxo-node-label"><span>UTXO</span><span class="utxo-node-amount">LUUN <span x-text="amountLabel(utxo.amount)"></span></span></div>
<div class="tx-field"><span class="tx-label">Outpoint</span><code class="tx-value hash" x-text="txInputOutpoint({ outpoint: utxo.outpoint })"></code></div>
<div class="tx-field"><span class="tx-label">Address</span><code class="tx-value hash" x-text="utxo.address"></code></div>
</div>
@@ -1276,8 +1276,8 @@ const INDEX_HTML: &str = r#"<!doctype html>
</div>
<div class="tx-modal-summary">
<div class="tx-field"><span class="tx-label">Source</span><span class="tx-value text" x-text="selectedTransactionLabel()"></span></div>
- <div class="tx-field"><span class="tx-label">Amount</span><span class="tx-value money">LUUN <span x-text="txAmount(selectedTransaction?.tx || {})"></span></span></div>
- <div class="tx-field"><span class="tx-label">Fee</span><span class="tx-value money">LUUN <span x-text="selectedTransaction?.tx?.fee ?? 0"></span></span></div>
+ <div class="tx-field"><span class="tx-label">Amount</span><span class="tx-value money">LUUN <span x-text="amountLabel(txAmount(selectedTransaction?.tx || {}))"></span></span></div>
+ <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>
@@ -1301,7 +1301,7 @@ const INDEX_HTML: &str = r#"<!doctype html>
<template x-for="(output, index) in txVisualOutputs(selectedTransaction?.tx || {})" :key="txOutputKey(output, index)">
<div class="utxo-node" :class="{ burned: output.kind === 'burned', fee: output.kind === 'fee' }">
<div class="utxo-node-label"><span x-text="output.label"></span><span x-text="output.kind"></span></div>
- <div class="utxo-node-amount">LUUN <span x-text="output.amount"></span></div>
+ <div class="utxo-node-amount">LUUN <span x-text="amountLabel(output.amount)"></span></div>
<template x-if="output.address">
<div class="tx-field"><span class="tx-label">To</span><code class="tx-value hash" x-text="output.address"></code></div>
</template>
@@ -1429,7 +1429,7 @@ mod tests {
use crate::{
adapters::{config_store, config_store::UiConfig},
- domain::{Block, Transaction, Wallet},
+ domain::{Block, MICRO_LUUN, Transaction, Wallet},
};
use super::{
@@ -1508,13 +1508,13 @@ mod tests {
let initial_config = ui_config.lock().await.clone();
config_store::save(&config_path, &initial_config).expect("initial config should save");
- persist_burn_settings_config(&ui_config, &config_path, 50, 3)
+ persist_burn_settings_config(&ui_config, &config_path, 50 * MICRO_LUUN, 3 * MICRO_LUUN)
.await
.unwrap();
let config = config_store::load_or_create(&config_path).unwrap();
- assert_eq!(config.burn_per_block, 50);
- assert_eq!(config.burn_fee, 3);
+ assert_eq!(config.burn_per_block, 50 * MICRO_LUUN);
+ assert_eq!(config.burn_fee, 3 * MICRO_LUUN);
}
#[test]
diff --git a/src/app.rs b/src/app.rs
@@ -19,10 +19,9 @@ pub type SharedPeerBook = Arc<Mutex<PeerBook>>;
pub const DEFAULT_BURN_PER_BLOCK: Amount = 0;
pub const DEFAULT_VDF_ROUNDS: u32 = 67_000_000;
pub const PROTOCOL_VERSION: u32 = 1;
-pub const NETWORK_ID: &str = "luun-devnet-v1";
+pub const NETWORK_ID: &str = "luun-devnet-v2";
pub const BLOCK_REQUEST_LIMIT: usize = 128;
const IMPORT_REBROADCAST_LIMIT: usize = 128;
-const MICRO_LUUN: u64 = 1_000_000;
#[derive(Clone, Debug)]
pub struct NodeConfig {
@@ -366,11 +365,9 @@ impl NodeCore {
.map(|block| block.reward)
.unwrap_or_else(|| self.ledger.status().block_reward);
let estimated_active_burn = estimated_active_burn_for_next_block(chain, launch_profile);
- let average_active_burn_rate_microluun = u128::from(estimated_active_burn)
- .saturating_mul(u128::from(MICRO_LUUN))
- / u128::from(window);
- let configured_burn_microluun =
- u128::from(self.burn_per_block).saturating_mul(u128::from(MICRO_LUUN));
+ let 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;
@@ -712,7 +709,7 @@ fn low_break_even_burn_microluun(
return 0;
}
- let max = slider_max.saturating_mul(MICRO_LUUN);
+ let max = slider_max;
let steps = 1_000_u64;
let mut previous = 0_u64;
for step in 1..=steps {
@@ -745,8 +742,8 @@ fn expected_burn_profit(
return -(fee as f64);
}
- let burn = burn_microluun as f64 / MICRO_LUUN as f64;
- let other_burn_rate = other_burn_rate_microluun as f64 / MICRO_LUUN as f64;
+ let 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
@@ -877,23 +874,23 @@ pub fn now_ms() -> u64 {
mod tests {
use std::collections::BTreeMap;
- use crate::domain::Wallet;
+ use crate::domain::{MICRO_LUUN, Wallet};
- use super::{
- MICRO_LUUN, NodeConfig, NodeCore, expected_burn_profit, low_break_even_burn_microluun,
- };
+ 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 break_even = low_break_even_burn_microluun(102, other_burn_rate, 1, 102);
+ 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, 102, 1) > 0.0);
+ assert!(expected_burn_profit(MICRO_LUUN, other_burn_rate, payout, fee) > 0.0);
}
#[test]
diff --git a/src/domain.rs b/src/domain.rs
@@ -6,8 +6,9 @@ use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
pub type Amount = u64;
-pub const BLOCK_REWARD: Amount = 100;
-pub const DEFAULT_TRANSACTION_FEE: Amount = 1;
+pub const MICRO_LUUN: Amount = 1_000_000;
+pub const BLOCK_REWARD: Amount = 100 * MICRO_LUUN;
+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;
const MAX_PENDING_TRANSACTIONS: usize = 10_000;
diff --git a/src/main.rs b/src/main.rs
@@ -11,12 +11,12 @@ 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, ChainSnapshot, GenesisBurn, Ledger, run_vdf},
+ domain::{Amount, BLOCK_REWARD, ChainSnapshot, GenesisBurn, Ledger, MICRO_LUUN, run_vdf},
};
use tokio::sync::Mutex;
-const GENESIS_BOOTSTRAP_BURN_AMOUNT: u64 = 1;
-const GENESIS_INITIAL_BURN_PER_BLOCK: Amount = 100;
+const GENESIS_BOOTSTRAP_BURN_AMOUNT: Amount = MICRO_LUUN;
+const GENESIS_INITIAL_BURN_PER_BLOCK: Amount = BLOCK_REWARD;
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);
@@ -72,7 +72,8 @@ async fn main() -> Result<()> {
println!("p2p listener: {}", opts.p2p_addr);
println!(
"automatic mining: VDF-driven, burning {} LUUN per block with {} LUUN fee",
- initial_burn_per_block, initial_burn_fee
+ format_luun(initial_burn_per_block),
+ format_luun(initial_burn_fee)
);
let gossip =
@@ -112,6 +113,20 @@ async fn main() -> Result<()> {
.await
}
+fn format_luun(amount: Amount) -> String {
+ let whole = amount / MICRO_LUUN;
+ let fractional = amount % MICRO_LUUN;
+ if fractional == 0 {
+ whole.to_string()
+ } else {
+ let mut fractional = format!("{fractional:06}");
+ while fractional.ends_with('0') {
+ fractional.pop();
+ }
+ format!("{whole}.{fractional}")
+ }
+}
+
async fn initialize_ledger(
opts: &CliOptions,
wallet_address: &str,
@@ -532,7 +547,7 @@ mod tests {
use luun::{
adapters::{chain_store::SqliteChainStore, config_store::UiConfig},
app::{DEFAULT_BURN_PER_BLOCK, NodeCore},
- domain::{GenesisBurn, Ledger, Wallet},
+ domain::{BLOCK_REWARD, GenesisBurn, Ledger, Wallet},
};
use rusqlite::Connection;
use tempfile::tempdir;
@@ -623,7 +638,7 @@ mod tests {
burn_fee: 3,
..UiConfig::default()
};
- assert_eq!(initial_burn_per_block(&genesis, &configured), 100);
+ assert_eq!(initial_burn_per_block(&genesis, &configured), BLOCK_REWARD);
}
#[test]
diff --git a/tests/luun.rs b/tests/luun.rs
@@ -4,12 +4,16 @@ use luun::{
adapters::chain_store::SqliteChainStore,
app::{DEFAULT_BURN_PER_BLOCK, InMemoryNetwork, NodeConfig, NodeCore, PeerBook, PeerDirection},
domain::{
- Amount, BLOCK_REWARD, GenesisBurn, Ledger, MAX_BLOCK_BYTES, VDF_TARGET_BLOCK_MS, Wallet,
- run_vdf, verify_vdf,
+ Amount, BLOCK_REWARD, GenesisBurn, Ledger, MAX_BLOCK_BYTES, MICRO_LUUN,
+ VDF_TARGET_BLOCK_MS, Wallet, run_vdf, verify_vdf,
},
};
use tempfile::tempdir;
+fn luun(amount: Amount) -> Amount {
+ amount * MICRO_LUUN
+}
+
fn node(_network_key: &str, wallet: Wallet, allocations: BTreeMap<String, Amount>) -> NodeCore {
NodeCore::new(NodeConfig {
wallet,
@@ -227,19 +231,19 @@ fn transfer_and_burn_update_balances_when_block_is_applied() {
let alice = Wallet::from_seed("alice");
let bob = Wallet::from_seed("bob");
let mut allocations = BTreeMap::new();
- allocations.insert(alice.address().to_string(), 1_000);
- allocations.insert(bob.address().to_string(), 100);
+ allocations.insert(alice.address().to_string(), luun(1_000));
+ allocations.insert(bob.address().to_string(), luun(100));
let mut ledger = Ledger::new(allocations, 10);
- let transfer = transfer_tx(&ledger, &alice, bob.address(), 125);
+ let transfer = transfer_tx(&ledger, &alice, bob.address(), luun(125));
ledger.submit_transaction(transfer).unwrap();
- let burn = burn_tx(&ledger, &alice, 25);
+ let burn = burn_tx(&ledger, &alice, luun(25));
ledger.submit_transaction(burn).unwrap();
let block = ledger.mine_next_block(&alice, 1).unwrap();
ledger.apply_block(block).unwrap();
- assert_eq!(ledger.balance_of(alice.address()), 950);
- assert_eq!(ledger.balance_of(bob.address()), 225);
+ assert_eq!(ledger.balance_of(alice.address()), luun(950));
+ assert_eq!(ledger.balance_of(bob.address()), luun(225));
}
#[test]
@@ -283,25 +287,25 @@ fn block_with_forged_transaction_is_rejected() {
fn block_reward_is_fixed_at_one_hundred_luun() {
let alice = Wallet::from_seed("alice");
let mut allocations = BTreeMap::new();
- allocations.insert(alice.address().to_string(), 1_000);
+ allocations.insert(alice.address().to_string(), luun(1_000));
let mut ledger = Ledger::new(allocations, 10);
- submit_burn(&mut ledger, &alice, 1);
+ submit_burn(&mut ledger, &alice, MICRO_LUUN);
let block = ledger.mine_next_block(&alice, 1).unwrap();
assert_eq!(block.reward, BLOCK_REWARD);
ledger.apply_block(block).unwrap();
- assert_eq!(ledger.balance_of(alice.address()), 1_099);
+ assert_eq!(ledger.balance_of(alice.address()), luun(1_099));
}
#[test]
fn burn_larger_than_block_reward_is_paid_from_existing_utxos() {
let alice = Wallet::from_seed("large-burn-existing-utxos-alice");
let mut allocations = BTreeMap::new();
- allocations.insert(alice.address().to_string(), BLOCK_REWARD + 50);
+ allocations.insert(alice.address().to_string(), BLOCK_REWARD + luun(50));
let mut ledger = Ledger::new(allocations, 10);
- let burn_amount = BLOCK_REWARD + 25;
+ let burn_amount = BLOCK_REWARD + luun(25);
let burn = ledger.build_burn(&alice, burn_amount, 0).unwrap();
ledger.submit_transaction(burn).unwrap();
@@ -310,7 +314,7 @@ fn burn_larger_than_block_reward_is_paid_from_existing_utxos() {
assert_eq!(block.reward, BLOCK_REWARD);
ledger.apply_block(block).unwrap();
- assert_eq!(ledger.balance_of(alice.address()), BLOCK_REWARD + 25);
+ assert_eq!(ledger.balance_of(alice.address()), BLOCK_REWARD + luun(25));
}
#[test]
@@ -320,7 +324,9 @@ fn burn_larger_than_existing_balance_cannot_use_next_block_reward() {
allocations.insert(alice.address().to_string(), BLOCK_REWARD);
let ledger = Ledger::new(allocations, 10);
- let error = ledger.build_burn(&alice, BLOCK_REWARD + 1, 0).unwrap_err();
+ let error = ledger
+ .build_burn(&alice, BLOCK_REWARD + MICRO_LUUN, 0)
+ .unwrap_err();
assert!(format!("{error:#}").contains("insufficient funds"));
}
@@ -330,22 +336,25 @@ fn transaction_fees_are_paid_to_the_block_miner() {
let alice = Wallet::from_seed("fee-miner-alice");
let bob = Wallet::from_seed("fee-payer-bob");
let mut allocations = BTreeMap::new();
- allocations.insert(alice.address().to_string(), 1);
- allocations.insert(bob.address().to_string(), 200);
+ allocations.insert(alice.address().to_string(), MICRO_LUUN);
+ allocations.insert(bob.address().to_string(), luun(200));
- let mut ledger =
- Ledger::new_with_genesis_burns(allocations, vec![GenesisBurn::new(alice.address(), 1)], 10)
- .unwrap();
- submit_burn(&mut ledger, &alice, 1);
- let tx = transfer_fee_tx(&ledger, &bob, alice.address(), 10, 7);
+ let mut ledger = Ledger::new_with_genesis_burns(
+ allocations,
+ vec![GenesisBurn::new(alice.address(), MICRO_LUUN)],
+ 10,
+ )
+ .unwrap();
+ submit_burn(&mut ledger, &alice, MICRO_LUUN);
+ let tx = transfer_fee_tx(&ledger, &bob, alice.address(), luun(10), luun(7));
ledger.submit_transaction(tx).unwrap();
let block = ledger.mine_next_block(&alice, 1).unwrap();
- assert_eq!(block.reward, BLOCK_REWARD + 7);
+ assert_eq!(block.reward, BLOCK_REWARD + luun(7));
ledger.apply_block(block).unwrap();
- assert_eq!(ledger.balance_of(alice.address()), 216);
- assert_eq!(ledger.balance_of(bob.address()), 183);
+ assert_eq!(ledger.balance_of(alice.address()), luun(216));
+ assert_eq!(ledger.balance_of(bob.address()), luun(183));
}
#[test]
@@ -527,26 +536,26 @@ fn block_hash_is_bound_to_block_contents() {
fn automatic_mining_burns_configured_amount_once_per_height() {
let alice = Wallet::from_seed("alice");
let mut allocations = BTreeMap::new();
- allocations.insert(alice.address().to_string(), 1_000);
+ allocations.insert(alice.address().to_string(), luun(1_000));
let mut node = NodeCore::new(NodeConfig {
wallet: alice.clone(),
genesis_allocations: allocations,
vdf_rounds: 10,
- burn_per_block: 25,
- burn_fee: 1,
+ burn_per_block: luun(25),
+ burn_fee: MICRO_LUUN,
});
let first = node.automatic_mine_once(1);
assert!(first.burned.is_some());
assert!(first.block.is_some());
assert_eq!(node.ledger().chain().len(), 2);
- assert_eq!(node.ledger().balance_of(alice.address()), 1_075);
+ assert_eq!(node.ledger().balance_of(alice.address()), luun(1_075));
let second = node.automatic_mine_once(2);
assert!(second.burned.is_some());
- assert_eq!(second.burned.as_ref().map(|tx| tx.amount()), Some(25));
- assert_eq!(second.burned.as_ref().map(|tx| tx.fee()), Some(1));
+ assert_eq!(second.burned.as_ref().map(|tx| tx.amount()), Some(luun(25)));
+ assert_eq!(second.burned.as_ref().map(|tx| tx.fee()), Some(MICRO_LUUN));
}
#[test]
@@ -624,16 +633,22 @@ fn automatic_mining_caps_burn_to_spendable_balance_after_fee() {
wallet: alice.clone(),
genesis_allocations: allocations,
vdf_rounds: 10,
- burn_per_block: BLOCK_REWARD + 50,
- burn_fee: 1,
+ burn_per_block: BLOCK_REWARD + luun(50),
+ burn_fee: MICRO_LUUN,
});
let outcome = node.automatic_mine_once(1);
- assert_eq!(outcome.burned.as_ref().map(|tx| tx.amount()), Some(99));
- assert_eq!(outcome.burned.as_ref().map(|tx| tx.fee()), Some(1));
+ assert_eq!(
+ outcome.burned.as_ref().map(|tx| tx.amount()),
+ Some(BLOCK_REWARD - MICRO_LUUN)
+ );
+ assert_eq!(outcome.burned.as_ref().map(|tx| tx.fee()), Some(MICRO_LUUN));
assert!(outcome.block.is_some());
- assert_eq!(node.ledger().balance_of(alice.address()), BLOCK_REWARD + 1);
+ assert_eq!(
+ node.ledger().balance_of(alice.address()),
+ BLOCK_REWARD + MICRO_LUUN
+ );
}
#[test]
@@ -697,8 +712,8 @@ fn waiting_wallet_gossips_pending_burn_to_selected_leader() {
let alice = Wallet::from_seed("deadlock-alice");
let bob = Wallet::from_seed("deadlock-bob");
let mut allocations = BTreeMap::new();
- allocations.insert(alice.address().to_string(), 1_000);
- allocations.insert(bob.address().to_string(), 1_000);
+ allocations.insert(alice.address().to_string(), luun(1_000));
+ allocations.insert(bob.address().to_string(), luun(1_000));
let ledger =
Ledger::new_with_genesis_burns(allocations, vec![GenesisBurn::new(bob.address(), 1)], 25)
.unwrap();
@@ -932,10 +947,13 @@ fn joined_nodes_import_transfer_block_and_every_wallet_mines() {
let bob = Wallet::from_seed("flow-bob");
let carol = Wallet::from_seed("flow-carol");
let mut genesis = BTreeMap::new();
- genesis.insert(alice.address().to_string(), 100);
- let alice_ledger =
- Ledger::new_with_genesis_burns(genesis, vec![GenesisBurn::new(alice.address(), 1)], 5)
- .unwrap();
+ genesis.insert(alice.address().to_string(), luun(100));
+ let alice_ledger = Ledger::new_with_genesis_burns(
+ genesis,
+ vec![GenesisBurn::new(alice.address(), MICRO_LUUN)],
+ 5,
+ )
+ .unwrap();
let mut network = InMemoryNetwork::default();
network.insert(
@@ -975,7 +993,7 @@ fn joined_nodes_import_transfer_block_and_every_wallet_mines() {
network
.node_mut("a")
.unwrap()
- .transfer(bob.address(), 30)
+ .transfer_with_fee(bob.address(), luun(30), 0)
.unwrap();
network.node_mut("a").unwrap().burn(1).unwrap();
network.deliver_until_idle().unwrap();
@@ -984,7 +1002,7 @@ fn joined_nodes_import_transfer_block_and_every_wallet_mines() {
block5
.transactions
.iter()
- .any(|tx| tx.to() == Some(bob.address()) && tx.amount() == 30)
+ .any(|tx| tx.to() == Some(bob.address()) && tx.amount() == luun(30))
);
mined_by.push(block5.miner.clone());
let block5_outbox = network.node_mut("a").unwrap().drain_outbox();
@@ -1012,7 +1030,7 @@ fn joined_nodes_import_transfer_block_and_every_wallet_mines() {
);
assert_eq!(
network.node(id).unwrap().ledger().balance_of(bob.address()),
- 30,
+ luun(30),
"{id} did not apply A -> B transfer"
);
}
@@ -1038,7 +1056,7 @@ fn joined_nodes_import_transfer_block_and_every_wallet_mines() {
network
.node_mut("b")
.unwrap()
- .transfer(carol.address(), 10)
+ .transfer_with_fee(carol.address(), luun(10), 0)
.unwrap();
network.node_mut("b").unwrap().burn(1).unwrap();
network.deliver_until_idle().unwrap();