iuna

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

commit c5fb08d38133f9cb1e74de581287fb78e9da6f47
parent 900d37b4a5ca3aa3942a794e34900e5b856ebe3f
Author: Joris Hartog <jorishartog@hotmail.com>
Date:   Fri, 24 Jul 2026 13:18:27 +0200

Add PoW mine actions for issuance

Diffstat:
Massets/luun-ui.js | 23++++++++++++++++++++++-
Msrc/adapters/http.rs | 64+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Msrc/app.rs | 15+++++++++++----
Msrc/domain.rs | 199+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----------
Mtests/luun.rs | 65+++++++++++++++++++++++++++++++++++++++++------------------------
5 files changed, 312 insertions(+), 54 deletions(-)

diff --git a/assets/luun-ui.js b/assets/luun-ui.js @@ -469,6 +469,18 @@ window.luunApp = function luunApp() { } }, + async minePowReward() { + try { + await this.postForm( + "/api/mine", + {}, + `Queued PoW mine action for ${this.amountLabel(this.status.chain?.mine_reward ?? 0)} LUUN` + ); + } catch (error) { + this.showFlash(error.message, "error"); + } + }, + automaticBurnFeeDraft() { return this.parseLuunAmount(this.burnFeeDraft); }, @@ -487,7 +499,7 @@ window.luunApp = function luunApp() { latestBlockReward() { const latest = this.latestBlock(); - return Math.max(0, Math.trunc(Number(latest?.reward ?? this.status.chain?.block_reward ?? 0))); + return Math.max(0, Math.trunc(Number(latest?.reward ?? 0))); }, ticketWindow() { @@ -745,6 +757,10 @@ window.luunApp = function luunApp() { return block.transactions.filter((tx) => tx.kind === "transfer").length; }, + blockMineCount(block) { + return block.transactions.filter((tx) => tx.kind === "mine").length; + }, + burnCountLabel(block) { const count = this.blockBurnCount(block); return `${count} burn${count === 1 ? "" : "s"}`; @@ -755,6 +771,11 @@ window.luunApp = function luunApp() { return `${count} transfer${count === 1 ? "" : "s"}`; }, + mineCountLabel(block) { + const count = this.blockMineCount(block); + return `${count} mine${count === 1 ? "" : "s"}`; + }, + blockMinerLabel(block) { const miner = this.short(block.miner); return block.miner === this.status.wallet_address ? `${miner} (me)` : miner; diff --git a/src/adapters/http.rs b/src/adapters/http.rs @@ -189,6 +189,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/transfer", post(api_transfer_form)) .route("/settings/burn-per-block", post(burn_per_block_form)) .route("/transfer", post(transfer_form)) @@ -350,6 +351,11 @@ async fn api_burn_per_block_form( action_json(result) } +async fn api_mine_form(State(state): State<HttpState>) -> Json<ActionResponse> { + let result = mine_pow_reward(&state).await; + action_json(result) +} + async fn burn_per_block_form( State(state): State<HttpState>, Form(form): Form<BurnSettingsForm>, @@ -513,6 +519,23 @@ fn wallet_transaction_row( "sent" }, }), + Transaction::Mine { + output, signature, .. + } if output.address == wallet => Some(WalletTransactionRow { + kind: "mine", + from: "pow".to_string(), + to: Some(output.address.clone()), + amount: output.amount, + fee: 0, + inputs: Vec::new(), + outputs: vec![output.clone()], + change: Vec::new(), + signature: signature.clone(), + status, + block_height, + block_miner, + direction: "received", + }), _ => None, } } @@ -587,6 +610,19 @@ fn ui_transaction( change: change.clone(), signature: signature.clone(), }, + Transaction::Mine { + output, signature, .. + } => UiTransaction { + kind: "mine", + from: "pow".to_string(), + to: Some(output.address.clone()), + amount: output.amount, + fee: 0, + inputs: Vec::new(), + outputs: vec![output.clone()], + change: Vec::new(), + signature: signature.clone(), + }, } } @@ -654,6 +690,7 @@ fn index_transaction_outputs( let created_outputs = match transaction { Transaction::Transfer { outputs, .. } => outputs, Transaction::Burn { change, .. } => change, + Transaction::Mine { output, .. } => std::slice::from_ref(output), }; for (index, output) in created_outputs.iter().enumerate() { outputs.insert( @@ -763,6 +800,20 @@ 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() { @@ -934,6 +985,8 @@ const INDEX_HTML: &str = r#"<!doctype html> .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; } .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; } @@ -983,6 +1036,7 @@ const INDEX_HTML: &str = r#"<!doctype html> .pill { display: inline-flex; align-items: center; border-radius: 999px; padding: 2px 8px; font-size: 12px; font-weight: 800; background: #2b3136; color: #d6dee2; } .pill.burn { background: #332918; color: #ffd070; } .pill.transfer { background: #17312a; color: #8de9cd; } + .pill.mine { background: #172a34; color: #8bdcff; } .mempool-strip { display: flex; gap: 8px; overflow-x: auto; padding-bottom: 4px; } .mempool-item { flex: 0 0 220px; } .tx-modal { width: min(940px, 100%); max-height: calc(100vh - 44px); overflow: auto; border: 1px solid #3b4448; border-radius: 8px; padding: 16px; background: #181b1f; box-shadow: 0 24px 80px rgba(0, 0, 0, .46); } @@ -1140,7 +1194,7 @@ const INDEX_HTML: &str = r#"<!doctype html> <section x-show="tab === 'mining'"> <div class="page-title"> - <div class="muted">Automatic VDF-paced block production</div> + <div class="muted">PoB/VDF block production with PoW issuance actions</div> </div> <div class="mining-grid"> <div class="panel"> @@ -1171,6 +1225,13 @@ const INDEX_HTML: &str = r#"<!doctype html> </div> </form> </div> + <div class="panel"> + <h3>PoW issuance</h3> + <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> + </div> </div> </section> @@ -1242,6 +1303,7 @@ const INDEX_HTML: &str = r#"<!doctype html> <div class="block-meta"> <span x-text="burnCountLabel(block)"></span> <span x-text="transferCountLabel(block)"></span> + <span x-text="mineCountLabel(block)"></span> </div> <div class="block-miner" x-text="blockMinerLabel(block)"></div> </button> diff --git a/src/app.rs b/src/app.rs @@ -113,6 +113,7 @@ pub struct LaunchProfileStatus { pub profile_hash: String, pub ticket_maturity_delay_heights: u64, pub ticket_expiry_window_heights: u64, + pub mine_difficulty_bits: u32, } #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] @@ -340,6 +341,7 @@ impl NodeCore { profile_hash: chain.launch_profile_hash.clone(), ticket_maturity_delay_heights: launch_profile.ticket_maturity_delay_heights, ticket_expiry_window_heights: launch_profile.ticket_expiry_window_heights, + mine_difficulty_bits: launch_profile.mine_difficulty_bits, }, mining: MiningStatus { automatic: true, @@ -360,10 +362,7 @@ impl NodeCore { let launch_profile = self.ledger.launch_profile(); let chain = self.ledger.chain(); let window = launch_profile.ticket_expiry_window_heights.max(1); - let last_payout = chain - .last() - .map(|block| block.reward) - .unwrap_or_else(|| self.ledger.status().block_reward); + let 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); @@ -450,6 +449,14 @@ impl NodeCore { Ok(tx) } + pub fn mine_pow_reward(&mut self) -> Result<Transaction> { + let tx = self.ledger.build_mine(self.wallet.address())?; + if self.ledger.submit_transaction(tx.clone())? { + self.outbox.push(GossipEnvelope::Transaction(tx.clone())); + } + Ok(tx) + } + pub fn receive_transaction(&mut self, tx: Transaction) -> Result<bool> { let accepted = self.ledger.submit_transaction(tx.clone())?; if accepted { diff --git a/src/domain.rs b/src/domain.rs @@ -8,9 +8,11 @@ use sha2::{Digest, Sha256}; pub type Amount = u64; pub const MICRO_LUUN: Amount = 1_000_000; pub const BLOCK_REWARD: Amount = 100 * MICRO_LUUN; +pub const MINE_REWARD: Amount = 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; +pub const MINE_DIFFICULTY_BITS: u32 = 12; const MAX_PENDING_TRANSACTIONS: usize = 10_000; const MAX_BLOCK_TRANSACTIONS: usize = 1_000; const DEFAULT_TICKET_MATURITY_DELAY: u64 = 3; @@ -98,6 +100,13 @@ pub enum Transaction { fee: Amount, signature: String, }, + Mine { + output: TxOutput, + anchor: String, + nonce: u64, + difficulty_bits: u32, + signature: String, + }, } impl Transaction { @@ -155,6 +164,7 @@ impl Transaction { .first() .map(|input| input.owner.as_str()) .unwrap_or(""), + Self::Mine { output, .. } => output.address.as_str(), } } @@ -162,6 +172,7 @@ impl Transaction { match self { Self::Transfer { outputs, .. } => outputs.first().map(|output| output.address.as_str()), Self::Burn { .. } => None, + Self::Mine { output, .. } => Some(output.address.as_str()), } } @@ -171,16 +182,21 @@ impl Transaction { outputs.first().map(|output| output.amount).unwrap_or(0) } Self::Burn { amount, .. } => *amount, + Self::Mine { output, .. } => output.amount, } } pub fn fee(&self) -> Amount { match self { Self::Transfer { fee, .. } | Self::Burn { fee, .. } => *fee, + Self::Mine { .. } => 0, } } pub fn total_debit(&self) -> Result<Amount> { + if matches!(self, Self::Mine { .. }) { + return Ok(0); + } self.amount() .checked_add(self.fee()) .context("transaction amount plus fee overflows") @@ -189,6 +205,7 @@ impl Transaction { pub fn signature(&self) -> &str { match self { Self::Transfer { signature, .. } | Self::Burn { signature, .. } => signature, + Self::Mine { signature, .. } => signature, } } @@ -226,10 +243,34 @@ impl Transaction { fee: *fee, } .canonical(), + Self::Mine { + output, + anchor, + nonce, + difficulty_bits, + .. + } => mine_payload(output, anchor, *nonce, *difficulty_bits), } } fn verify_signature(&self) -> Result<()> { + if let Self::Mine { + output, + anchor, + nonce, + difficulty_bits, + signature, + } = self + { + let expected = mine_signature(output, anchor, *nonce, *difficulty_bits); + if *signature != expected { + bail!("mine transaction proof hash is invalid"); + } + if !hash_meets_difficulty(signature, *difficulty_bits) { + bail!("mine transaction proof does not meet difficulty"); + } + return Ok(()); + } if self.signature().starts_with("luun-genesis-burn:") || self.inputs_are_genesis_signed() { return Ok(()); } @@ -256,6 +297,7 @@ impl Transaction { fn inputs(&self) -> &[TxInput] { match self { Self::Transfer { inputs, .. } | Self::Burn { inputs, .. } => inputs, + Self::Mine { .. } => &[], } } @@ -263,6 +305,7 @@ impl Transaction { match self { Self::Transfer { outputs, .. } => outputs, Self::Burn { change, .. } => change, + Self::Mine { output, .. } => std::slice::from_ref(output), } } @@ -399,6 +442,41 @@ fn canonical_outputs(outputs: &[TxOutput]) -> String { .join("|") } +fn mine_payload(output: &TxOutput, anchor: &str, nonce: u64, difficulty_bits: u32) -> String { + format!( + "luun-mine:{}:{}:{}:{}", + output.address, output.amount, anchor, nonce + ) + &format!(":{difficulty_bits}") +} + +fn mine_signature(output: &TxOutput, anchor: &str, nonce: u64, difficulty_bits: u32) -> String { + hex_hash(mine_payload(output, anchor, nonce, difficulty_bits)) +} + +fn hash_meets_difficulty(hash: &str, difficulty_bits: u32) -> bool { + let full_zero_nibbles = (difficulty_bits / 4) as usize; + let remaining_bits = difficulty_bits % 4; + if hash.len() < full_zero_nibbles + usize::from(remaining_bits > 0) { + return false; + } + if !hash.as_bytes()[..full_zero_nibbles] + .iter() + .all(|byte| *byte == b'0') + { + return false; + } + if remaining_bits == 0 { + return true; + } + let Some(next) = hash.as_bytes().get(full_zero_nibbles).copied() else { + return false; + }; + let Some(value) = (next as char).to_digit(16) else { + return false; + }; + value < (1 << (4 - remaining_bits)) +} + fn pending_spent_outpoints(pending: &[Transaction]) -> BTreeSet<OutPoint> { pending .iter() @@ -632,7 +710,7 @@ pub struct ChainStatus { pub tip_hash: String, pub next_leader: Option<String>, pub launch_profile_hash: String, - pub block_reward: Amount, + pub mine_reward: Amount, pub balances: BTreeMap<String, Amount>, pub pending_transactions: usize, } @@ -651,6 +729,8 @@ pub struct LaunchProfile { pub ticket_maturity_delay_heights: u64, #[serde(default = "default_ticket_expiry_window_heights")] pub ticket_expiry_window_heights: u64, + #[serde(default = "default_mine_difficulty_bits")] + pub mine_difficulty_bits: u32, pub max_pending_transactions: usize, pub max_block_transactions: usize, #[serde(default = "default_max_block_bytes")] @@ -660,9 +740,10 @@ pub struct LaunchProfile { impl Default for LaunchProfile { fn default() -> Self { Self { - profile_id: "luun-devnet-v4".to_string(), + profile_id: "luun-devnet-v5".to_string(), ticket_maturity_delay_heights: DEFAULT_TICKET_MATURITY_DELAY, ticket_expiry_window_heights: DEFAULT_TICKET_EXPIRY_WINDOW, + mine_difficulty_bits: MINE_DIFFICULTY_BITS, max_pending_transactions: MAX_PENDING_TRANSACTIONS, max_block_transactions: MAX_BLOCK_TRANSACTIONS, max_block_bytes: MAX_BLOCK_BYTES, @@ -678,13 +759,18 @@ fn default_ticket_expiry_window_heights() -> u64 { DEFAULT_TICKET_EXPIRY_WINDOW } +fn default_mine_difficulty_bits() -> u32 { + MINE_DIFFICULTY_BITS +} + impl LaunchProfile { pub fn hash(&self) -> String { hex_hash(format!( - "luun-launch-profile:{}:{}:{}:{}:{}:{}", + "luun-launch-profile:{}:{}:{}:{}:{}:{}:{}", self.profile_id, self.ticket_maturity_delay_heights, self.ticket_expiry_window_heights, + self.mine_difficulty_bits, self.max_pending_transactions, self.max_block_transactions, self.max_block_bytes @@ -768,7 +854,7 @@ pub struct Ledger { utxos: BTreeMap<OutPoint, TxOutput>, tickets: Vec<BurnTicket>, pending: Vec<Transaction>, - block_reward: Amount, + mine_reward: Amount, initial_vdf_rounds: u32, vdf_rounds: u32, launch_profile: LaunchProfile, @@ -813,7 +899,7 @@ impl Ledger { utxos, tickets, pending: Vec::new(), - block_reward: BLOCK_REWARD, + mine_reward: MINE_REWARD, initial_vdf_rounds: vdf_rounds, vdf_rounds, launch_profile, @@ -851,7 +937,7 @@ impl Ledger { utxos, tickets: Vec::new(), pending: Vec::new(), - block_reward: BLOCK_REWARD, + mine_reward: MINE_REWARD, initial_vdf_rounds: vdf_rounds, vdf_rounds, launch_profile, @@ -1060,7 +1146,7 @@ impl Ledger { tip_hash: self.tip().hash.clone(), next_leader: self.expected_leader_for_next_block(), launch_profile_hash: self.launch_profile.hash(), - block_reward: self.block_reward, + mine_reward: self.mine_reward, balances: balances_from_utxos(&self.utxos), pending_transactions: self.pending.len(), } @@ -1274,16 +1360,42 @@ impl Ledger { Ok(transaction) } + pub fn build_mine(&self, recipient: impl Into<String>) -> Result<Transaction> { + let recipient = recipient.into(); + let output = TxOutput { + address: recipient, + amount: self.mine_reward, + }; + let anchor = self.tip().hash.clone(); + let difficulty_bits = self.launch_profile.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) { + continue; + } + let transaction = Transaction::Mine { + output: output.clone(), + anchor: anchor.clone(), + nonce, + difficulty_bits, + signature, + }; + if self.has_transaction(transaction.signature()) { + continue; + } + self.validate_new_transaction(&transaction)?; + return Ok(transaction); + } + bail!("could not find valid mine proof"); + } + pub fn submit_transaction(&mut self, transaction: Transaction) -> Result<bool> { - if self - .pending - .iter() - .any(|tx| tx.signature() == transaction.signature()) - { + if self.has_transaction(transaction.signature()) { return Ok(false); } transaction.verify_signature()?; + self.validate_transaction_terms(&transaction)?; if transaction_inputs_spent_by(&transaction, &self.pending) { return Ok(false); @@ -1334,7 +1446,7 @@ impl Ledger { prev_hash, timestamp_ms, miner: miner.to_string(), - reward: reward_with_fees(self.block_reward, &transactions)?, + reward: fee_reward(&transactions)?, vdf_rounds: self.vdf_rounds, vdf_seed, leader_ticket, @@ -1374,9 +1486,10 @@ impl Ledger { if !signatures.insert(tx.signature()) { bail!("duplicate transaction in block"); } + self.validate_transaction_terms(tx)?; apply_transaction(tx, &mut utxos)?; } - if block.reward != reward_with_fees(self.block_reward, &block.transactions)? { + if block.reward != fee_reward(&block.transactions)? { bail!("block reward is invalid"); } let mut tickets = self.tickets.clone(); @@ -1429,7 +1542,7 @@ impl Ledger { if block.compute_hash() != block.hash { bail!("block hash is invalid"); } - if block.reward != reward_with_fees(self.block_reward, &block.transactions)? { + if block.reward != fee_reward(&block.transactions)? { bail!("block reward is invalid"); } if block.vdf_rounds != self.vdf_rounds { @@ -1506,7 +1619,9 @@ impl Ledger { let mut still_pending = Vec::new(); for tx in remaining { - if apply_transaction(tx, &mut utxos).is_ok() { + if self.validate_transaction_terms(tx).is_ok() + && apply_transaction(tx, &mut utxos).is_ok() + { valid.push(tx.clone()); progressed = true; } else { @@ -1620,10 +1735,32 @@ impl Ledger { } fn validate_new_transaction(&self, transaction: &Transaction) -> Result<()> { + self.validate_transaction_terms(transaction)?; let mut utxos = self.utxos_after_valid_pending()?; apply_transaction(transaction, &mut utxos) } + fn validate_transaction_terms(&self, transaction: &Transaction) -> Result<()> { + if let Transaction::Mine { + output, + anchor, + difficulty_bits, + .. + } = transaction + { + 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"); + } + if !self.has_block(anchor) { + bail!("mine transaction anchor is not on this chain"); + } + } + Ok(()) + } + fn utxos_after_valid_pending(&self) -> Result<BTreeMap<OutPoint, TxOutput>> { let mut utxos = self.utxos.clone(); for pending in self.valid_pending_transactions() { @@ -1936,6 +2073,17 @@ fn apply_transaction( utxos: &mut BTreeMap<OutPoint, TxOutput>, ) -> Result<()> { transaction.verify_signature()?; + if let Transaction::Mine { output, .. } = transaction { + ensure_outputs_do_not_overflow(utxos, std::slice::from_ref(output))?; + utxos.insert( + OutPoint { + txid: transaction.signature().to_string(), + index: 0, + }, + output.clone(), + ); + return Ok(()); + } ensure_single_input_owner(transaction)?; let input_total = spend_inputs(transaction, utxos)?; let output_total = transaction @@ -1951,7 +2099,7 @@ fn apply_transaction( .context("transaction outputs plus fee overflow")? .checked_add(match transaction { Transaction::Burn { amount, .. } => *amount, - Transaction::Transfer { .. } => 0, + Transaction::Transfer { .. } | Transaction::Mine { .. } => 0, }) .context("transaction outputs plus burn overflow")?; if input_total != required { @@ -1970,11 +2118,9 @@ fn apply_transaction( Ok(()) } -fn reward_with_fees(base_reward: Amount, transactions: &[Transaction]) -> Result<Amount> { - transactions.iter().try_fold(base_reward, |total, tx| { - total - .checked_add(tx.fee()) - .context("block reward plus fees overflows") +fn fee_reward(transactions: &[Transaction]) -> Result<Amount> { + transactions.iter().try_fold(0_u64, |total, tx| { + total.checked_add(tx.fee()).context("block fees overflow") }) } @@ -2012,6 +2158,9 @@ fn transaction_has_missing_inputs( } fn ensure_single_input_owner(transaction: &Transaction) -> Result<()> { + if matches!(transaction, Transaction::Mine { .. }) { + return Ok(()); + } let Some(first) = transaction.inputs().first() else { bail!("transaction has no inputs"); }; @@ -2094,7 +2243,9 @@ fn utxos_after_genesis( for transaction in &genesis.transactions { match transaction { Transaction::Burn { .. } => apply_transaction(transaction, &mut utxos)?, - Transaction::Transfer { .. } => bail!("genesis only supports burn transactions"), + Transaction::Transfer { .. } | Transaction::Mine { .. } => { + bail!("genesis only supports burn transactions") + } } } credit_reward_output(&mut utxos, genesis)?; @@ -2178,7 +2329,7 @@ fn genesis_miner( .iter() .filter_map(|transaction| match transaction { Transaction::Burn { inputs, .. } => inputs.first().map(|input| input.owner.as_str()), - Transaction::Transfer { .. } => None, + Transaction::Transfer { .. } | Transaction::Mine { .. } => None, }) .find(|from| genesis_allocations.contains_key(*from)) .or_else(|| genesis_allocations.keys().next().map(String::as_str)) diff --git a/tests/luun.rs b/tests/luun.rs @@ -4,7 +4,7 @@ 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, MICRO_LUUN, + Amount, BLOCK_REWARD, GenesisBurn, Ledger, MAX_BLOCK_BYTES, MICRO_LUUN, MINE_REWARD, VDF_TARGET_BLOCK_MS, Wallet, run_vdf, verify_vdf, }, }; @@ -242,7 +242,7 @@ fn transfer_and_burn_update_balances_when_block_is_applied() { let block = ledger.mine_next_block(&alice, 1).unwrap(); ledger.apply_block(block).unwrap(); - assert_eq!(ledger.balance_of(alice.address()), luun(950)); + assert_eq!(ledger.balance_of(alice.address()), luun(850)); assert_eq!(ledger.balance_of(bob.address()), luun(225)); } @@ -284,22 +284,44 @@ fn block_with_forged_transaction_is_rejected() { } #[test] -fn block_reward_is_fixed_at_one_hundred_luun() { +fn mine_action_introduces_one_luun_and_block_author_gets_fees_only() { let alice = Wallet::from_seed("alice"); let mut allocations = BTreeMap::new(); - allocations.insert(alice.address().to_string(), luun(1_000)); + allocations.insert(alice.address().to_string(), 2 * MICRO_LUUN); let mut ledger = Ledger::new(allocations, 10); + let mine = ledger.build_mine(alice.address()).unwrap(); + assert_eq!(mine.amount(), MINE_REWARD); + ledger.submit_transaction(mine.clone()).unwrap(); submit_burn(&mut ledger, &alice, MICRO_LUUN); let block = ledger.mine_next_block(&alice, 1).unwrap(); - assert_eq!(block.reward, BLOCK_REWARD); + assert_eq!(block.reward, 0); ledger.apply_block(block).unwrap(); - assert_eq!(ledger.balance_of(alice.address()), luun(1_099)); + assert_eq!(ledger.balance_of(alice.address()), 2 * MICRO_LUUN); + assert!(!ledger.submit_transaction(mine).unwrap()); + assert_eq!(ledger.balance_of(alice.address()), 2 * MICRO_LUUN); } #[test] -fn burn_larger_than_block_reward_is_paid_from_existing_utxos() { +fn forged_mine_action_cannot_introduce_luun() { + let alice = Wallet::from_seed("forged-mine-alice"); + let mut allocations = BTreeMap::new(); + allocations.insert(alice.address().to_string(), MICRO_LUUN); + let mut ledger = Ledger::new(allocations, 10); + + let mut forged = ledger.build_mine(alice.address()).unwrap(); + if let luun::domain::Transaction::Mine { nonce, .. } = &mut forged { + *nonce += 1; + } + + let error = ledger.submit_transaction(forged).unwrap_err(); + assert!(format!("{error:#}").contains("mine transaction proof hash is invalid")); + assert_eq!(ledger.balance_of(alice.address()), MICRO_LUUN); +} + +#[test] +fn burn_larger_than_mine_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 + luun(50)); @@ -311,10 +333,10 @@ fn burn_larger_than_block_reward_is_paid_from_existing_utxos() { let block = ledger.mine_next_block(&alice, 1).unwrap(); assert_eq!(block.transactions[0].amount(), burn_amount); - assert_eq!(block.reward, BLOCK_REWARD); + assert_eq!(block.reward, 0); ledger.apply_block(block).unwrap(); - assert_eq!(ledger.balance_of(alice.address()), BLOCK_REWARD + luun(25)); + assert_eq!(ledger.balance_of(alice.address()), luun(25)); } #[test] @@ -350,10 +372,10 @@ fn transaction_fees_are_paid_to_the_block_miner() { ledger.submit_transaction(tx).unwrap(); let block = ledger.mine_next_block(&alice, 1).unwrap(); - assert_eq!(block.reward, BLOCK_REWARD + luun(7)); + assert_eq!(block.reward, luun(7)); ledger.apply_block(block).unwrap(); - assert_eq!(ledger.balance_of(alice.address()), luun(216)); + assert_eq!(ledger.balance_of(alice.address()), luun(116)); assert_eq!(ledger.balance_of(bob.address()), luun(183)); } @@ -407,7 +429,7 @@ fn oversized_blocks_are_rejected() { let mut block = ledger.mine_next_block(&alice, 1).unwrap(); let oversized_transfer = transfer_fee_tx(&ledger, &bob, "x".repeat(MAX_BLOCK_BYTES), 1, 3); block.transactions.push(oversized_transfer); - block.reward = BLOCK_REWARD + 3; + block.reward = 3; block.hash = block.compute_hash(); let error = ledger.apply_block(block).unwrap_err(); @@ -465,16 +487,13 @@ fn transfer_that_would_overflow_recipient_balance_is_rejected() { } #[test] -fn block_reward_that_would_overflow_miner_balance_is_rejected() { +fn mine_reward_that_would_overflow_recipient_balance_is_rejected() { let alice = Wallet::from_seed("alice"); let mut allocations = BTreeMap::new(); allocations.insert(alice.address().to_string(), Amount::MAX); - let mut ledger = Ledger::new(allocations, 10); - submit_burn(&mut ledger, &alice, 1); - let block = ledger.mine_next_block(&alice, 1).unwrap(); - - let error = ledger.apply_block(block).unwrap_err(); + let ledger = Ledger::new(allocations, 10); + let error = ledger.build_mine(alice.address()).unwrap_err(); assert!(format!("{error:#}").contains("balance overflow")); } @@ -550,7 +569,7 @@ fn automatic_mining_burns_configured_amount_once_per_height() { 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()), luun(1_075)); + assert_eq!(node.ledger().balance_of(alice.address()), luun(975)); let second = node.automatic_mine_once(2); assert!(second.burned.is_some()); @@ -645,10 +664,7 @@ fn automatic_mining_caps_burn_to_spendable_balance_after_fee() { ); 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 + MICRO_LUUN - ); + assert_eq!(node.ledger().balance_of(alice.address()), MICRO_LUUN); } #[test] @@ -1679,7 +1695,8 @@ fn fork_choice_preflight_rejects_invalid_fork_before_vrf_scoring() { if let Some(transaction) = invalid_snapshot.blocks[6].transactions.first_mut() { match transaction { luun::domain::Transaction::Burn { signature, .. } - | luun::domain::Transaction::Transfer { signature, .. } => signature.push_str("00"), + | luun::domain::Transaction::Transfer { signature, .. } + | luun::domain::Transaction::Mine { signature, .. } => signature.push_str("00"), } }