iuna

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

commit eb06c2e50ee9d4d85cdb1014047a0b650d5fee6d
parent 0fb58688671abdbb19dc5fbcce53071089ce9d5d
Author: Joris Hartog <jorishartog@hotmail.com>
Date:   Fri, 24 Jul 2026 11:07:39 +0200

Use microluun amount units

Diffstat:
Massets/luun-ui.js | 62+++++++++++++++++++++++++++++++++++++++-----------------------
Msrc/adapters/config_store.rs | 33+++++++++++++++++++++++----------
Msrc/adapters/http.rs | 48++++++++++++++++++++++++------------------------
Msrc/app.rs | 29+++++++++++++----------------
Msrc/domain.rs | 5+++--
Msrc/main.rs | 27+++++++++++++++++++++------
Mtests/luun.rs | 110++++++++++++++++++++++++++++++++++++++++++++++---------------------------------
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();