iuna

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

commit 7b71da40b832ab2afd448c7ce95d05ff9bd9663e
parent b66607eb843c1c76e4f90fd09dcadbc4a50ea01c
Author: Joris Hartog <jorishartog@hotmail.com>
Date:   Thu, 23 Jul 2026 22:37:05 +0200

Migrate ledger transactions to UTXO model

Diffstat:
Massets/luun-ui.js | 14+++++++++++++-
Msrc/adapters/chain_store.rs | 2+-
Msrc/adapters/http.rs | 70+++++++++++++++++++++++++++++++++++++---------------------------------
Msrc/adapters/p2p.rs | 4++--
Msrc/app.rs | 13+++----------
Msrc/domain.rs | 877++++++++++++++++++++++++++++++++++++++++++++++++++++---------------------------
Msrc/main.rs | 5++---
Mtests/luun.rs | 193++++++++++++++++++++++++++++++++-----------------------------------------------
8 files changed, 719 insertions(+), 459 deletions(-)

diff --git a/assets/luun-ui.js b/assets/luun-ui.js @@ -548,10 +548,22 @@ window.luunApp = function luunApp() { return `${value.slice(0, 8)}...${value.slice(-8)}`; }, + txFrom(tx) { + return tx.from ?? tx.inputs?.[0]?.owner ?? ""; + }, + + txTo(tx) { + return tx.to ?? tx.outputs?.[0]?.address ?? null; + }, + + txAmount(tx) { + return tx.amount ?? tx.outputs?.[0]?.amount ?? 0; + }, + blockBurned(block) { return block.transactions .filter((tx) => tx.kind === "burn") - .reduce((sum, tx) => sum + tx.amount, 0); + .reduce((sum, tx) => sum + this.txAmount(tx), 0); }, blockBurnCount(block) { diff --git a/src/adapters/chain_store.rs b/src/adapters/chain_store.rs @@ -161,7 +161,7 @@ mod tests { .unwrap(); store.save(&ledger.snapshot()).unwrap(); - let burn = wallet.burn(1, ledger.next_nonce(wallet.address())); + let burn = ledger.build_burn(&wallet, 1, 0).unwrap(); ledger.submit_transaction(burn).unwrap(); let block = ledger.mine_next_block(&wallet, 1_000).unwrap(); ledger.apply_locally_mined_block(block).unwrap(); diff --git a/src/adapters/http.rs b/src/adapters/http.rs @@ -96,7 +96,6 @@ struct WalletTransactionRow { to: Option<String>, amount: Amount, fee: Amount, - nonce: u64, signature: String, status: &'static str, block_height: Option<u64>, @@ -384,25 +383,25 @@ fn wallet_transaction_row( block_height: Option<u64>, ) -> Option<WalletTransactionRow> { match tx { - Transaction::Transfer { - from, - to, - amount, - fee, - nonce, - signature, - } if from == wallet || to == wallet => Some(WalletTransactionRow { - kind: "transfer", - from: from.clone(), - to: Some(to.clone()), - amount: *amount, - fee: *fee, - nonce: *nonce, - signature: signature.clone(), - status, - block_height, - direction: if to == wallet { "received" } else { "sent" }, - }), + Transaction::Transfer { fee, signature, .. } + if tx.sender() == wallet || tx.to() == Some(wallet) => + { + Some(WalletTransactionRow { + kind: "transfer", + from: tx.sender().to_string(), + to: tx.to().map(str::to_string), + amount: tx.amount(), + fee: *fee, + signature: signature.clone(), + status, + block_height, + direction: if tx.to() == Some(wallet) { + "received" + } else { + "sent" + }, + }) + } _ => None, } } @@ -910,11 +909,10 @@ const INDEX_HTML: &str = r#"<!doctype html> <template x-for="tx in selectedBlock.transactions" :key="tx.signature"> <div class="tx-card"> <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="tx.amount"></span></span></div> + <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">From</span><code class="tx-value hash" x-text="short(tx.from)"></code></div> - <div class="tx-field" x-show="tx.to"><span class="tx-label">To</span><code class="tx-value hash" x-text="short(tx.to)"></code></div> - <div class="tx-field"><span class="tx-label">Nonce</span><span class="tx-value number" x-text="tx.nonce"></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> </div> </template> @@ -931,11 +929,10 @@ const INDEX_HTML: &str = r#"<!doctype html> <template x-for="tx in mempool" :key="tx.signature"> <div class="mempool-item"> <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="tx.amount"></span></span></div> + <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">From</span><code class="tx-value hash" x-text="short(tx.from)"></code></div> - <div class="tx-field" x-show="tx.to"><span class="tx-label">To</span><code class="tx-value hash" x-text="short(tx.to)"></code></div> - <div class="tx-field"><span class="tx-label">Nonce</span><span class="tx-value number" x-text="tx.nonce"></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> </div> </template> @@ -1053,7 +1050,7 @@ const INDEX_HTML: &str = r#"<!doctype html> #[cfg(test)] mod tests { - use std::sync::Arc; + use std::{collections::BTreeMap, sync::Arc}; use tokio::sync::Mutex; @@ -1078,12 +1075,19 @@ mod tests { let alice = Wallet::from_seed("wallet-history-alice"); let bob = Wallet::from_seed("wallet-history-bob"); let carol = Wallet::from_seed("wallet-history-carol"); - let old_received = bob.transfer(alice.address(), 31, 1); - let pending_burn = alice.burn_with_fee(2, 1, 2); + let mut allocations = BTreeMap::new(); + allocations.insert(alice.address().to_string(), 100); + allocations.insert(bob.address().to_string(), 100); + allocations.insert(carol.address().to_string(), 100); + let ledger = crate::domain::Ledger::new(allocations, 1); + let old_received = ledger.build_transfer(&bob, alice.address(), 31, 0).unwrap(); + let pending_burn = ledger.build_burn(&alice, 2, 1).unwrap(); + let carol_transfer = ledger.build_transfer(&carol, bob.address(), 5, 0).unwrap(); + let carol_burn = ledger.build_burn(&carol, 1, 0).unwrap(); let chain = vec![ - fake_block(30, vec![carol.transfer(bob.address(), 5, 1)]), + fake_block(30, vec![carol_transfer]), fake_block(31, vec![old_received.clone()]), - fake_block(32, vec![carol.burn(1, 2)]), + fake_block(32, vec![carol_burn]), ]; let rows = wallet_transaction_rows(alice.address(), vec![pending_burn.clone()], &chain); diff --git a/src/adapters/p2p.rs b/src/adapters/p2p.rs @@ -1717,9 +1717,9 @@ mod tests { async fn inventory_requests_only_missing_objects() { let alice = Wallet::from_seed("missing-inv-alice"); let bob = Wallet::from_seed("missing-inv-bob"); - let allocations = allocations(&[alice.clone(), bob.clone()], 1_000); + let allocations = allocations(&[alice.clone(), bob], 1_000); let mut local = node("local", alice.clone(), allocations.clone()); - let mut remote = node("remote", bob, allocations); + let mut remote = node("remote", alice, allocations); let tx = local.burn(1).unwrap(); let block = local.mine_one_at(1).unwrap(); diff --git a/src/app.rs b/src/app.rs @@ -19,7 +19,7 @@ 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-v0"; +pub const NETWORK_ID: &str = "luun-devnet-v1"; pub const BLOCK_REQUEST_LIMIT: usize = 128; const IMPORT_REBROADCAST_LIMIT: usize = 128; const MICRO_LUUN: u64 = 1_000_000; @@ -411,9 +411,7 @@ impl NodeCore { } pub fn burn_with_fee(&mut self, amount: Amount, fee: Amount) -> Result<Transaction> { - let tx = - self.wallet - .burn_with_fee(amount, fee, self.ledger.next_nonce(self.wallet.address())); + let tx = self.ledger.build_burn(&self.wallet, amount, fee)?; if self.ledger.submit_transaction(tx.clone())? { self.outbox.push(GossipEnvelope::Transaction(tx.clone())); } @@ -430,12 +428,7 @@ impl NodeCore { amount: Amount, fee: Amount, ) -> Result<Transaction> { - let tx = self.wallet.transfer_with_fee( - to, - amount, - fee, - self.ledger.next_nonce(self.wallet.address()), - ); + let tx = self.ledger.build_transfer(&self.wallet, to, amount, fee)?; if self.ledger.submit_transaction(tx.clone())? { self.outbox.push(GossipEnvelope::Transaction(tx.clone())); } diff --git a/src/domain.rs b/src/domain.rs @@ -43,41 +43,6 @@ impl Wallet { &self.address } - pub fn burn(&self, amount: Amount, nonce: u64) -> Transaction { - self.burn_with_fee(amount, 0, nonce) - } - - pub fn burn_with_fee(&self, amount: Amount, fee: Amount, nonce: u64) -> Transaction { - let unsigned = UnsignedTransaction::Burn { - from: self.address.clone(), - amount, - fee, - nonce, - }; - unsigned.sign(self) - } - - pub fn transfer(&self, to: impl Into<String>, amount: Amount, nonce: u64) -> Transaction { - self.transfer_with_fee(to, amount, 0, nonce) - } - - pub fn transfer_with_fee( - &self, - to: impl Into<String>, - amount: Amount, - fee: Amount, - nonce: u64, - ) -> Transaction { - let unsigned = UnsignedTransaction::Transfer { - from: self.address.clone(), - to: to.into(), - amount, - fee, - nonce, - }; - unsigned.sign(self) - } - fn sign_payload(&self, payload: &str) -> String { let seed = decode_hex_array::<32>(&self.secret).expect("wallet secret is valid hex"); let signing_key = SigningKey::from_bytes(&seed); @@ -95,106 +60,41 @@ impl Wallet { } } -#[derive(Clone, Debug, Eq, PartialEq)] -pub enum UnsignedTransaction { - Transfer { - from: String, - to: String, - amount: Amount, - fee: Amount, - nonce: u64, - }, - Burn { - from: String, - amount: Amount, - fee: Amount, - nonce: u64, - }, +#[derive(Clone, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)] +pub struct OutPoint { + pub txid: String, + pub index: u32, } -impl UnsignedTransaction { - fn sign(self, wallet: &Wallet) -> Transaction { - let signature = wallet.sign_payload(&self.canonical()); - match self { - Self::Transfer { - from, - to, - amount, - fee, - nonce, - } => Transaction::Transfer { - from, - to, - amount, - fee, - nonce, - signature, - }, - Self::Burn { - from, - amount, - fee, - nonce, - } => Transaction::Burn { - from, - amount, - fee, - nonce, - signature, - }, - } - } +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub struct TxInput { + pub outpoint: OutPoint, + pub owner: String, + pub signature: String, +} - fn canonical(&self) -> String { - match self { - Self::Transfer { - from, - to, - amount, - fee, - nonce, - } if *fee == 0 => format!("transfer:{from}:{to}:{amount}:{nonce}"), - Self::Transfer { - from, - to, - amount, - fee, - nonce, - } => format!("transfer:{from}:{to}:{amount}:{fee}:{nonce}"), - Self::Burn { - from, - amount, - fee, - nonce, - } if *fee == 0 => format!("burn:{from}:{amount}:{nonce}"), - Self::Burn { - from, - amount, - fee, - nonce, - } => format!("burn:{from}:{amount}:{fee}:{nonce}"), - } - } +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub struct TxOutput { + pub address: String, + pub amount: Amount, } #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] #[serde(tag = "kind", rename_all = "snake_case")] pub enum Transaction { Transfer { - from: String, - to: String, - amount: Amount, + inputs: Vec<TxInput>, + outputs: Vec<TxOutput>, #[serde(default)] fee: Amount, - nonce: u64, signature: String, }, Burn { - from: String, + inputs: Vec<TxInput>, + change: Vec<TxOutput>, amount: Amount, #[serde(default)] fee: Amount, - nonce: u64, signature: String, }, } @@ -202,31 +102,74 @@ pub enum Transaction { impl Transaction { pub fn genesis_burn(from: impl Into<String>, amount: Amount) -> Self { let from = from.into(); - let signature = hex_hash(format!("luun-genesis-burn:{from}:{amount}")); + Self::genesis_burn_with_change(from, amount, Vec::new()) + } + + fn genesis_burn_with_allocation( + from: impl Into<String>, + amount: Amount, + allocation: Amount, + ) -> Result<Self> { + if amount > allocation { + bail!("genesis burn exceeds allocation"); + } + let from = from.into(); + let change_amount = allocation - amount; + let change = if change_amount > 0 { + vec![TxOutput { + address: from.clone(), + amount: change_amount, + }] + } else { + Vec::new() + }; + Ok(Self::genesis_burn_with_change(from, amount, change)) + } + + fn genesis_burn_with_change(from: String, amount: Amount, change: Vec<TxOutput>) -> Self { + let input = TxInput { + outpoint: genesis_allocation_outpoint(&from), + owner: from.clone(), + signature: "genesis".to_string(), + }; + let unsigned = UnsignedUtxoTransaction::Burn { + inputs: vec![input.without_signature()], + change: change.clone(), + amount, + fee: 0, + }; + let signature = hex_hash(format!("luun-genesis-burn:{}", unsigned.canonical())); Self::Burn { - from, + inputs: vec![input], + change, amount, fee: 0, - nonce: 0, signature, } } pub fn sender(&self) -> &str { match self { - Self::Transfer { from, .. } | Self::Burn { from, .. } => from, + Self::Transfer { inputs, .. } | Self::Burn { inputs, .. } => inputs + .first() + .map(|input| input.owner.as_str()) + .unwrap_or(""), } } - pub fn nonce(&self) -> u64 { + pub fn to(&self) -> Option<&str> { match self { - Self::Transfer { nonce, .. } | Self::Burn { nonce, .. } => *nonce, + Self::Transfer { outputs, .. } => outputs.first().map(|output| output.address.as_str()), + Self::Burn { .. } => None, } } pub fn amount(&self) -> Amount { match self { - Self::Transfer { amount, .. } | Self::Burn { amount, .. } => *amount, + Self::Transfer { outputs, .. } => { + outputs.first().map(|output| output.amount).unwrap_or(0) + } + Self::Burn { amount, .. } => *amount, } } @@ -259,41 +202,46 @@ impl Transaction { fn signing_payload(&self) -> String { match self { Self::Transfer { - from, - to, - amount, + inputs, + outputs, fee, - nonce, .. - } if *fee == 0 => format!("transfer:{from}:{to}:{amount}:{nonce}"), - Self::Transfer { - from, - to, - amount, - fee, - nonce, - .. - } => format!("transfer:{from}:{to}:{amount}:{fee}:{nonce}"), - Self::Burn { - from, - amount, - fee, - nonce, - .. - } if *fee == 0 => format!("burn:{from}:{amount}:{nonce}"), + } => UnsignedUtxoTransaction::Transfer { + inputs: unsigned_inputs(inputs), + outputs: outputs.clone(), + fee: *fee, + } + .canonical(), Self::Burn { - from, + inputs, + change, amount, fee, - nonce, .. - } => format!("burn:{from}:{amount}:{fee}:{nonce}"), + } => UnsignedUtxoTransaction::Burn { + inputs: unsigned_inputs(inputs), + change: change.clone(), + amount: *amount, + fee: *fee, + } + .canonical(), } } fn verify_signature(&self) -> Result<()> { - let public_key = decode_hex_array::<32>(self.sender()) - .with_context(|| format!("invalid public key for {}", self.sender()))?; + if self.signature().starts_with("luun-genesis-burn:") || self.inputs_are_genesis_signed() { + return Ok(()); + } + if !self + .inputs() + .iter() + .all(|input| input.signature == self.signature()) + { + bail!("transaction input signature does not match transaction signature"); + } + let sender = self.sender(); + let public_key = decode_hex_array::<32>(sender) + .with_context(|| format!("invalid public key for {sender}"))?; let signature = decode_hex_array::<64>(self.signature()).context("invalid signature hex")?; let verifying_key = @@ -303,6 +251,176 @@ impl Transaction { .verify(self.signing_payload().as_bytes(), &signature) .context("transaction signature is invalid") } + + fn inputs(&self) -> &[TxInput] { + match self { + Self::Transfer { inputs, .. } | Self::Burn { inputs, .. } => inputs, + } + } + + fn outputs(&self) -> &[TxOutput] { + match self { + Self::Transfer { outputs, .. } => outputs, + Self::Burn { change, .. } => change, + } + } + + fn inputs_are_genesis_signed(&self) -> bool { + self.inputs() + .iter() + .all(|input| input.signature == "genesis") + } +} + +impl TxInput { + fn without_signature(&self) -> UnsignedTxInput { + UnsignedTxInput { + outpoint: self.outpoint.clone(), + owner: self.owner.clone(), + } + } +} + +impl OutPoint { + fn id(&self) -> String { + format!("{}:{}", self.txid, self.index) + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +struct UnsignedTxInput { + outpoint: OutPoint, + owner: String, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +enum UnsignedUtxoTransaction { + Transfer { + inputs: Vec<UnsignedTxInput>, + outputs: Vec<TxOutput>, + fee: Amount, + }, + Burn { + inputs: Vec<UnsignedTxInput>, + change: Vec<TxOutput>, + amount: Amount, + fee: Amount, + }, +} + +impl UnsignedUtxoTransaction { + fn sign(self, wallet: &Wallet) -> Transaction { + let signature = wallet.sign_payload(&self.canonical()); + let signed_inputs = self + .inputs() + .iter() + .map(|input| TxInput { + outpoint: input.outpoint.clone(), + owner: input.owner.clone(), + signature: signature.clone(), + }) + .collect::<Vec<_>>(); + match self { + Self::Transfer { outputs, fee, .. } => Transaction::Transfer { + inputs: signed_inputs, + outputs, + fee, + signature, + }, + Self::Burn { + change, + amount, + fee, + .. + } => Transaction::Burn { + inputs: signed_inputs, + change, + amount, + fee, + signature, + }, + } + } + + fn inputs(&self) -> &[UnsignedTxInput] { + match self { + Self::Transfer { inputs, .. } | Self::Burn { inputs, .. } => inputs, + } + } + + fn canonical(&self) -> String { + match self { + Self::Transfer { + inputs, + outputs, + fee, + } => format!( + "utxo-transfer:{}:{}:{fee}", + canonical_inputs(inputs), + canonical_outputs(outputs) + ), + Self::Burn { + inputs, + change, + amount, + fee, + } => format!( + "utxo-burn:{}:{}:{amount}:{fee}", + canonical_inputs(inputs), + canonical_outputs(change) + ), + } + } +} + +fn unsigned_inputs(inputs: &[TxInput]) -> Vec<UnsignedTxInput> { + inputs.iter().map(TxInput::without_signature).collect() +} + +fn canonical_inputs(inputs: &[UnsignedTxInput]) -> String { + inputs + .iter() + .map(|input| { + format!( + "{}:{}:{}", + input.outpoint.txid, input.outpoint.index, input.owner + ) + }) + .collect::<Vec<_>>() + .join("|") +} + +fn canonical_outputs(outputs: &[TxOutput]) -> String { + outputs + .iter() + .map(|output| format!("{}:{}", output.address, output.amount)) + .collect::<Vec<_>>() + .join("|") +} + +fn pending_spent_outpoints(pending: &[Transaction]) -> BTreeSet<OutPoint> { + pending + .iter() + .flat_map(|tx| tx.inputs().iter().map(|input| input.outpoint.clone())) + .collect() +} + +fn transaction_inputs_spent_by(transaction: &Transaction, pending: &[Transaction]) -> bool { + let spent = pending_spent_outpoints(pending); + transaction + .inputs() + .iter() + .any(|input| spent.contains(&input.outpoint)) +} + +fn transaction_inputs_available( + transaction: &Transaction, + utxos: &BTreeMap<OutPoint, TxOutput>, +) -> bool { + transaction + .inputs() + .iter() + .all(|input| utxos.contains_key(&input.outpoint)) } #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] @@ -541,7 +659,7 @@ pub struct LaunchProfile { impl Default for LaunchProfile { fn default() -> Self { Self { - profile_id: "luun-devnet-v3".to_string(), + profile_id: "luun-devnet-v4".to_string(), ticket_maturity_delay_heights: DEFAULT_TICKET_MATURITY_DELAY, ticket_expiry_window_heights: DEFAULT_TICKET_EXPIRY_WINDOW, max_pending_transactions: MAX_PENDING_TRANSACTIONS, @@ -646,8 +764,7 @@ enum TransactionKind { pub struct Ledger { chain: Vec<Block>, genesis_allocations: BTreeMap<String, Amount>, - balances: BTreeMap<String, Amount>, - nonces: BTreeMap<String, u64>, + utxos: BTreeMap<OutPoint, TxOutput>, tickets: Vec<BurnTicket>, pending: Vec<Transaction>, block_reward: Amount, @@ -669,8 +786,14 @@ impl Ledger { ) -> Result<Self> { let transactions = genesis_burns .into_iter() - .map(|burn| Transaction::genesis_burn(burn.from, burn.amount)) - .collect(); + .map(|burn| { + let allocation = genesis_allocations + .get(&burn.from) + .copied() + .unwrap_or_default(); + Transaction::genesis_burn_with_allocation(burn.from, burn.amount, allocation) + }) + .collect::<Result<Vec<_>>>()?; Self::new_with_genesis_transactions(genesis_allocations, transactions, vdf_rounds) } @@ -680,14 +803,13 @@ impl Ledger { vdf_rounds: u32, ) -> Result<Self> { let launch_profile = LaunchProfile::default(); - let balances = balances_after_genesis(&genesis_allocations, &genesis_transactions)?; let genesis = build_genesis_block(&genesis_allocations, genesis_transactions); + let utxos = utxos_after_genesis(&genesis_allocations, &genesis)?; let tickets = genesis_tickets(&genesis_allocations, &genesis, &launch_profile)?; Ok(Self { chain: vec![genesis], genesis_allocations: genesis_allocations.clone(), - balances, - nonces: BTreeMap::new(), + utxos, tickets, pending: Vec::new(), block_reward: BLOCK_REWARD, @@ -715,18 +837,17 @@ impl Ledger { let genesis = blocks[0].clone(); validate_genesis_block(&genesis)?; - let balances = balances_after_genesis(&genesis_allocations, &genesis.transactions)?; let expected_genesis = build_genesis_block(&genesis_allocations, genesis.transactions.clone()); if genesis != expected_genesis { bail!("chain snapshot genesis does not match its allocations and transactions"); } + let utxos = utxos_after_genesis(&genesis_allocations, &genesis)?; let mut ledger = Self { chain: vec![genesis], genesis_allocations, - balances, - nonces: BTreeMap::new(), + utxos, tickets: Vec::new(), pending: Vec::new(), block_reward: BLOCK_REWARD, @@ -939,7 +1060,7 @@ impl Ledger { next_leader: self.expected_leader_for_next_block(), launch_profile_hash: self.launch_profile.hash(), block_reward: self.block_reward, - balances: self.balances.clone(), + balances: balances_from_utxos(&self.utxos), pending_transactions: self.pending.len(), } } @@ -1019,25 +1140,85 @@ impl Ledger { } pub fn balance_of(&self, address: &str) -> Amount { - self.balances.get(address).copied().unwrap_or(0) + self.utxos + .values() + .filter(|output| output.address == address) + .map(|output| output.amount) + .sum() } pub fn next_nonce(&self, address: &str) -> u64 { - let base = self.nonces.get(address).copied().unwrap_or(0); - let Some(mut next) = base.checked_add(1) else { - return u64::MAX; + self.utxos + .keys() + .chain( + self.pending + .iter() + .flat_map(|tx| tx.inputs().iter().map(|input| &input.outpoint)), + ) + .filter(|outpoint| outpoint.txid.contains(address)) + .count() as u64 + + 1 + } + + pub fn build_transfer( + &self, + wallet: &Wallet, + to: impl Into<String>, + amount: Amount, + fee: Amount, + ) -> Result<Transaction> { + let required = amount + .checked_add(fee) + .context("transfer amount plus fee overflows")?; + let (inputs, input_total) = self.select_inputs(wallet.address(), required)?; + let mut outputs = vec![TxOutput { + address: to.into(), + amount, + }]; + let change = input_total + .checked_sub(required) + .context("selected inputs do not cover transfer")?; + if change > 0 { + outputs.push(TxOutput { + address: wallet.address().to_string(), + amount: change, + }); + } + let transaction = UnsignedUtxoTransaction::Transfer { + inputs, + outputs, + fee, + } + .sign(wallet); + self.validate_new_transaction(&transaction)?; + Ok(transaction) + } + + pub fn build_burn(&self, wallet: &Wallet, amount: Amount, fee: Amount) -> Result<Transaction> { + let required = amount + .checked_add(fee) + .context("burn amount plus fee overflows")?; + let (inputs, input_total) = self.select_inputs(wallet.address(), required)?; + let change_amount = input_total + .checked_sub(required) + .context("selected inputs do not cover burn")?; + let change = if change_amount > 0 { + vec![TxOutput { + address: wallet.address().to_string(), + amount: change_amount, + }] + } else { + Vec::new() }; - while self - .pending - .iter() - .any(|tx| tx.sender() == address && tx.nonce() == next) - { - let Some(candidate) = next.checked_add(1) else { - return u64::MAX; - }; - next = candidate; + let transaction = UnsignedUtxoTransaction::Burn { + inputs, + change, + amount, + fee, } - next + .sign(wallet); + self.validate_new_transaction(&transaction)?; + Ok(transaction) } pub fn submit_transaction(&mut self, transaction: Transaction) -> Result<bool> { @@ -1051,11 +1232,7 @@ impl Ledger { transaction.verify_signature()?; - if self - .pending - .iter() - .any(|tx| tx.sender() == transaction.sender() && tx.nonce() == transaction.nonce()) - { + if transaction_inputs_spent_by(&transaction, &self.pending) { return Ok(false); } @@ -1063,22 +1240,12 @@ impl Ledger { bail!("mempool is full"); } - let mut balances = self.balances.clone(); - let mut nonces = self.nonces.clone(); - for pending in self.valid_pending_transactions() { - apply_transaction(&pending, &mut balances, &mut nonces)?; - } - - let expected_nonce = next_expected_nonce(&nonces, transaction.sender())?; - if transaction.nonce() < expected_nonce { - return Ok(false); - } - if transaction.nonce() > expected_nonce { + let mut utxos = self.utxos_after_valid_pending()?; + if transaction_has_missing_inputs(&transaction, &utxos) { self.pending.push(transaction); return Ok(true); } - - apply_transaction(&transaction, &mut balances, &mut nonces)?; + apply_transaction(&transaction, &mut utxos)?; self.pending.push(transaction); Ok(true) } @@ -1148,21 +1315,20 @@ impl Ledger { bail!("block VDF output is invalid"); } - let mut balances = self.balances.clone(); - let mut nonces = self.nonces.clone(); + let mut utxos = self.utxos.clone(); let mut signatures = BTreeSet::new(); for tx in &block.transactions { if !signatures.insert(tx.signature()) { bail!("duplicate transaction in block"); } - apply_transaction(tx, &mut balances, &mut nonces)?; + apply_transaction(tx, &mut utxos)?; } if block.reward != reward_with_fees(self.block_reward, &block.transactions)? { bail!("block reward is invalid"); } let mut tickets = self.tickets.clone(); consume_leader_ticket(&block, &mut tickets)?; - credit_balance(&mut balances, &block.miner, block.reward)?; + credit_reward_output(&mut utxos, &block)?; tickets.extend(tickets_created_by_block(&block, &self.launch_profile)?); let mined_signatures = block @@ -1170,12 +1336,12 @@ impl Ledger { .iter() .map(|tx| tx.signature().to_string()) .collect::<BTreeSet<_>>(); - self.balances = balances; - self.nonces = nonces; + self.utxos = utxos; self.tickets = tickets; + let available = self.utxos.clone(); self.pending.retain(|tx| { !mined_signatures.contains(tx.signature()) - && tx.nonce() > self.nonces.get(tx.sender()).copied().unwrap_or(0) + && transaction_inputs_available(tx, &available) }); self.chain.push(block); self.vdf_rounds = self.next_vdf_rounds_after_tip(); @@ -1278,8 +1444,7 @@ impl Ledger { } fn valid_pending_transactions(&self) -> Vec<Transaction> { - let mut balances = self.balances.clone(); - let mut nonces = self.nonces.clone(); + let mut utxos = self.utxos.clone(); let mut valid = Vec::new(); let mut remaining = self.pending.iter().collect::<Vec<_>>(); @@ -1288,7 +1453,7 @@ impl Ledger { let mut still_pending = Vec::new(); for tx in remaining { - if apply_transaction(tx, &mut balances, &mut nonces).is_ok() { + if apply_transaction(tx, &mut utxos).is_ok() { valid.push(tx.clone()); progressed = true; } else { @@ -1307,43 +1472,76 @@ impl Ledger { } fn select_block_transactions(&self) -> Result<Vec<Transaction>> { - let mut balances = self.balances.clone(); - let mut nonces = self.nonces.clone(); + let mut utxos = self.utxos.clone(); let mut remaining = self.valid_pending_transactions(); let mut selected = Vec::new(); - if let Some(index) = best_selectable_transaction_index( - &remaining, - &balances, - &nonces, - Some(TransactionKind::Burn), - ) { + if let Some(index) = + best_selectable_transaction_index(&remaining, &utxos, Some(TransactionKind::Burn)) + { let tx = remaining.remove(index); let mut candidate = selected.clone(); candidate.push(tx.clone()); if estimated_block_size_bytes(&candidate)? <= self.launch_profile.max_block_bytes { - apply_transaction(&tx, &mut balances, &mut nonces)?; + apply_transaction(&tx, &mut utxos)?; selected.push(tx); } } while selected.len() < self.launch_profile.max_block_transactions { - let Some(index) = - best_selectable_transaction_index(&remaining, &balances, &nonces, None) - else { + let Some(index) = best_selectable_transaction_index(&remaining, &utxos, None) else { break; }; let tx = remaining.remove(index); let mut candidate = selected.clone(); candidate.push(tx.clone()); if estimated_block_size_bytes(&candidate)? <= self.launch_profile.max_block_bytes { - apply_transaction(&tx, &mut balances, &mut nonces)?; + apply_transaction(&tx, &mut utxos)?; selected.push(tx); } } Ok(selected) } + fn select_inputs( + &self, + address: &str, + amount: Amount, + ) -> Result<(Vec<UnsignedTxInput>, Amount)> { + let utxos = self.utxos_after_valid_pending()?; + let mut selected = Vec::new(); + let mut total = 0_u64; + for (outpoint, output) in &utxos { + if output.address != address { + continue; + } + selected.push(UnsignedTxInput { + outpoint: outpoint.clone(), + owner: address.to_string(), + }); + total = total + .checked_add(output.amount) + .context("selected input total overflows")?; + if total >= amount { + return Ok((selected, total)); + } + } + bail!("insufficient funds for {address}") + } + + fn validate_new_transaction(&self, transaction: &Transaction) -> Result<()> { + let mut utxos = self.utxos_after_valid_pending()?; + apply_transaction(transaction, &mut utxos) + } + + fn utxos_after_valid_pending(&self) -> Result<BTreeMap<OutPoint, TxOutput>> { + let mut utxos = self.utxos.clone(); + for pending in self.valid_pending_transactions() { + apply_transaction(&pending, &mut utxos)?; + } + Ok(utxos) + } + fn selected_ticket_for_height(&self, height: u64) -> Option<BurnTicket> { select_weighted_ticket(self.tip(), height, &self.tickets) } @@ -1402,7 +1600,7 @@ fn tickets_created_by_block(block: &Block, profile: &LaunchProfile) -> Result<Ve let mut tickets = Vec::new(); for tx in &block.transactions { let Transaction::Burn { - from, + inputs, amount, signature, .. @@ -1410,6 +1608,9 @@ fn tickets_created_by_block(block: &Block, profile: &LaunchProfile) -> Result<Ve else { continue; }; + let Some(owner) = inputs.first().map(|input| input.owner.clone()) else { + continue; + }; if *amount == 0 { continue; } @@ -1422,7 +1623,7 @@ fn tickets_created_by_block(block: &Block, profile: &LaunchProfile) -> Result<Ve .with_context(|| format!("ticket expiry height overflow at block {}", block.height))?; tickets.push(BurnTicket { id: signature.clone(), - owner: from.clone(), + owner, amount: *amount, eligible_from_height: target_height, eligible_until_height, @@ -1445,7 +1646,7 @@ fn genesis_tickets( .iter() .filter_map(|tx| { let Transaction::Burn { - from, + inputs, amount, signature, .. @@ -1453,7 +1654,8 @@ fn genesis_tickets( else { return None; }; - (*amount > 0).then(|| (from.clone(), *amount, signature.clone())) + let owner = inputs.first()?.owner.clone(); + (*amount > 0).then(|| (owner, *amount, signature.clone())) }) .collect::<Vec<_>>(); @@ -1540,8 +1742,7 @@ fn fee_rate_key(transaction: &Transaction) -> u128 { fn best_selectable_transaction_index( transactions: &[Transaction], - balances: &BTreeMap<String, Amount>, - nonces: &BTreeMap<String, u64>, + utxos: &BTreeMap<OutPoint, TxOutput>, required_kind: Option<TransactionKind>, ) -> Option<usize> { transactions @@ -1552,16 +1753,14 @@ fn best_selectable_transaction_index( None => true, }) .filter(|(_, tx)| { - let mut balances = balances.clone(); - let mut nonces = nonces.clone(); - apply_transaction(tx, &mut balances, &mut nonces).is_ok() + let mut utxos = utxos.clone(); + apply_transaction(tx, &mut utxos).is_ok() }) .max_by(|(_, left), (_, right)| { fee_rate_key(left) .cmp(&fee_rate_key(right)) .then_with(|| left.fee().cmp(&right.fee())) .then_with(|| left.is_burn().cmp(&right.is_burn())) - .then_with(|| right.nonce().cmp(&left.nonce())) .then_with(|| right.signature().cmp(left.signature())) }) .map(|(index, _)| index) @@ -1644,27 +1843,40 @@ fn vdf_seed_for_child(prev_hash: &str, height: u64) -> String { fn apply_transaction( transaction: &Transaction, - balances: &mut BTreeMap<String, Amount>, - nonces: &mut BTreeMap<String, u64>, + utxos: &mut BTreeMap<OutPoint, TxOutput>, ) -> Result<()> { transaction.verify_signature()?; - - let from = transaction.sender(); - let expected_nonce = next_expected_nonce(nonces, from)?; - if transaction.nonce() != expected_nonce { - bail!( - "invalid nonce for {from}: expected {expected_nonce}, got {}", - transaction.nonce() + ensure_single_input_owner(transaction)?; + let input_total = spend_inputs(transaction, utxos)?; + let output_total = transaction + .outputs() + .iter() + .try_fold(0_u64, |total, output| { + total + .checked_add(output.amount) + .context("transaction outputs overflow") + })?; + let required = output_total + .checked_add(transaction.fee()) + .context("transaction outputs plus fee overflow")? + .checked_add(match transaction { + Transaction::Burn { amount, .. } => *amount, + Transaction::Transfer { .. } => 0, + }) + .context("transaction outputs plus burn overflow")?; + if input_total != required { + bail!("transaction inputs do not balance outputs, burn, and fee"); + } + ensure_outputs_do_not_overflow(utxos, transaction.outputs())?; + for (index, output) in transaction.outputs().iter().enumerate() { + utxos.insert( + OutPoint { + txid: transaction.signature().to_string(), + index: index as u32, + }, + output.clone(), ); } - debit_balance(balances, from, transaction.total_debit()?)?; - match transaction { - Transaction::Transfer { to, amount, .. } => { - credit_balance(balances, to, *amount)?; - } - Transaction::Burn { .. } => {} - } - nonces.insert(from.to_string(), transaction.nonce()); Ok(()) } @@ -1676,37 +1888,83 @@ fn reward_with_fees(base_reward: Amount, transactions: &[Transaction]) -> Result }) } -fn next_expected_nonce(nonces: &BTreeMap<String, u64>, address: &str) -> Result<u64> { - nonces - .get(address) - .copied() - .unwrap_or(0) - .checked_add(1) - .with_context(|| format!("nonce space exhausted for {address}")) +fn spend_inputs( + transaction: &Transaction, + utxos: &mut BTreeMap<OutPoint, TxOutput>, +) -> Result<Amount> { + let mut seen = BTreeSet::new(); + let mut total = 0_u64; + for input in transaction.inputs() { + if !seen.insert(input.outpoint.clone()) { + bail!("duplicate input in transaction"); + } + let output = utxos.remove(&input.outpoint).with_context(|| { + format!("transaction spends missing output {}", input.outpoint.id()) + })?; + if output.address != input.owner { + bail!("transaction input owner does not match spent output"); + } + total = total + .checked_add(output.amount) + .context("transaction input total overflows")?; + } + Ok(total) } -fn debit_balance( - balances: &mut BTreeMap<String, Amount>, - address: &str, - amount: Amount, -) -> Result<()> { - let balance = balances.entry(address.to_string()).or_insert(0); - if *balance < amount { - bail!("insufficient funds for {address}"); +fn transaction_has_missing_inputs( + transaction: &Transaction, + utxos: &BTreeMap<OutPoint, TxOutput>, +) -> bool { + transaction + .inputs() + .iter() + .any(|input| !utxos.contains_key(&input.outpoint)) +} + +fn ensure_single_input_owner(transaction: &Transaction) -> Result<()> { + let Some(first) = transaction.inputs().first() else { + bail!("transaction has no inputs"); + }; + if transaction + .inputs() + .iter() + .any(|input| input.owner != first.owner) + { + bail!("transaction inputs must have one owner"); } - *balance -= amount; Ok(()) } -fn credit_balance( - balances: &mut BTreeMap<String, Amount>, - address: &str, - amount: Amount, +fn credit_reward_output(utxos: &mut BTreeMap<OutPoint, TxOutput>, block: &Block) -> Result<()> { + if block.reward == 0 { + return Ok(()); + } + let output = TxOutput { + address: block.miner.clone(), + amount: block.reward, + }; + ensure_outputs_do_not_overflow(utxos, std::slice::from_ref(&output))?; + utxos.insert(reward_outpoint(&block.hash), output); + Ok(()) +} + +fn ensure_outputs_do_not_overflow( + utxos: &BTreeMap<OutPoint, TxOutput>, + outputs: &[TxOutput], ) -> Result<()> { - let balance = balances.entry(address.to_string()).or_insert(0); - *balance = balance - .checked_add(amount) - .with_context(|| format!("balance overflow for {address}"))?; + let mut balances = BTreeMap::new(); + for output in utxos.values() { + let balance = balances.entry(output.address.clone()).or_insert(0_u64); + *balance = balance + .checked_add(output.amount) + .with_context(|| format!("balance overflow for {}", output.address))?; + } + for output in outputs { + let balance = balances.entry(output.address.clone()).or_insert(0_u64); + *balance = balance + .checked_add(output.amount) + .with_context(|| format!("balance overflow for {}", output.address))?; + } Ok(()) } @@ -1738,6 +1996,62 @@ fn build_genesis_block( genesis } +fn utxos_after_genesis( + genesis_allocations: &BTreeMap<String, Amount>, + genesis: &Block, +) -> Result<BTreeMap<OutPoint, TxOutput>> { + let mut utxos = genesis_allocation_utxos(genesis_allocations); + for transaction in &genesis.transactions { + match transaction { + Transaction::Burn { .. } => apply_transaction(transaction, &mut utxos)?, + Transaction::Transfer { .. } => bail!("genesis only supports burn transactions"), + } + } + credit_reward_output(&mut utxos, genesis)?; + Ok(utxos) +} + +fn genesis_allocation_utxos( + genesis_allocations: &BTreeMap<String, Amount>, +) -> BTreeMap<OutPoint, TxOutput> { + genesis_allocations + .iter() + .filter(|(_, amount)| **amount > 0) + .map(|(address, amount)| { + ( + genesis_allocation_outpoint(address), + TxOutput { + address: address.clone(), + amount: *amount, + }, + ) + }) + .collect() +} + +fn balances_from_utxos(utxos: &BTreeMap<OutPoint, TxOutput>) -> BTreeMap<String, Amount> { + let mut balances = BTreeMap::new(); + for output in utxos.values() { + let balance = balances.entry(output.address.clone()).or_insert(0_u64); + *balance = balance.saturating_add(output.amount); + } + balances +} + +fn genesis_allocation_outpoint(address: &str) -> OutPoint { + OutPoint { + txid: hex_hash(format!("luun-genesis-allocation:{address}")), + index: 0, + } +} + +fn reward_outpoint(block_hash: &str) -> OutPoint { + OutPoint { + txid: block_hash.to_string(), + index: u32::MAX, + } +} + fn validate_genesis_block(block: &Block) -> Result<()> { if block.height != 0 { bail!("genesis block height must be 0"); @@ -1773,7 +2087,7 @@ fn genesis_miner( transactions .iter() .filter_map(|transaction| match transaction { - Transaction::Burn { from, .. } => Some(from.as_str()), + Transaction::Burn { inputs, .. } => inputs.first().map(|input| input.owner.as_str()), Transaction::Transfer { .. } => None, }) .find(|from| genesis_allocations.contains_key(*from)) @@ -1793,31 +2107,6 @@ fn genesis_reward( } } -fn balances_after_genesis( - genesis_allocations: &BTreeMap<String, Amount>, - transactions: &[Transaction], -) -> Result<BTreeMap<String, Amount>> { - let mut balances = genesis_allocations.clone(); - for transaction in transactions { - match transaction { - Transaction::Burn { from, amount, .. } => { - let balance = balances.entry(from.clone()).or_insert(0); - if *balance < *amount { - bail!("genesis burn exceeds allocation for {from}"); - } - *balance -= *amount; - } - Transaction::Transfer { .. } => bail!("genesis only supports burn transactions"), - } - } - let reward = genesis_reward(genesis_allocations, transactions); - if reward > 0 { - let miner = genesis_miner(genesis_allocations, transactions); - credit_balance(&mut balances, &miner, reward)?; - } - Ok(balances) -} - pub fn run_vdf(seed: &str, rounds: u32) -> String { let x = vdf_seed_element(seed); let mut y = x; diff --git a/src/main.rs b/src/main.rs @@ -563,9 +563,8 @@ mod tests { fn ledger_with_one_mined_block(wallet: &Wallet) -> Ledger { let mut ledger = ledger_with_one_spendable_luun(wallet); - ledger - .submit_transaction(wallet.burn(1, ledger.next_nonce(wallet.address()))) - .unwrap(); + let burn = ledger.build_burn(wallet, 1, 0).unwrap(); + ledger.submit_transaction(burn).unwrap(); let block = ledger.mine_next_block(wallet, 1_000).unwrap(); ledger.apply_locally_mined_block(block).unwrap(); ledger diff --git a/tests/luun.rs b/tests/luun.rs @@ -32,15 +32,42 @@ fn allocations(wallets: &[Wallet], amount: Amount) -> BTreeMap<String, Amount> { } fn mine_wallet_burn_block(ledger: &mut Ledger, wallet: &Wallet, timestamp_ms: u64) -> String { - ledger - .submit_transaction(wallet.burn(1, ledger.next_nonce(wallet.address()))) - .unwrap(); + let burn = ledger.build_burn(wallet, 1, 0).unwrap(); + ledger.submit_transaction(burn).unwrap(); let block = ledger.mine_next_block(wallet, timestamp_ms).unwrap(); let hash = block.hash.clone(); ledger.apply_block(block).unwrap(); hash } +fn submit_burn(ledger: &mut Ledger, wallet: &Wallet, amount: Amount) { + let tx = ledger.build_burn(wallet, amount, 0).unwrap(); + ledger.submit_transaction(tx).unwrap(); +} + +fn burn_tx(ledger: &Ledger, wallet: &Wallet, amount: Amount) -> luun::domain::Transaction { + ledger.build_burn(wallet, amount, 0).unwrap() +} + +fn transfer_tx( + ledger: &Ledger, + wallet: &Wallet, + to: impl Into<String>, + amount: Amount, +) -> luun::domain::Transaction { + ledger.build_transfer(wallet, to, amount, 0).unwrap() +} + +fn transfer_fee_tx( + ledger: &Ledger, + wallet: &Wallet, + to: impl Into<String>, + amount: Amount, + fee: Amount, +) -> luun::domain::Transaction { + ledger.build_transfer(wallet, to, amount, fee).unwrap() +} + fn fork_with_better_vrf_block( base: &Ledger, wallet: &Wallet, @@ -158,9 +185,7 @@ fn burn_in_block_creates_ticket_after_maturity_delay() { allocations.insert(bob.address().to_string(), 1_000); let mut ledger = Ledger::new(allocations, 10); - ledger - .submit_transaction(bob.burn(80, ledger.next_nonce(bob.address()))) - .unwrap(); + submit_burn(&mut ledger, &bob, 80); let launch_leader = if ledger.expected_leader_for_next_block().as_deref() == Some(alice.address()) { @@ -178,9 +203,7 @@ fn burn_in_block_creates_ticket_after_maturity_delay() { } else { &bob }; - ledger - .submit_transaction(leader_wallet.burn(1, ledger.next_nonce(leader_wallet.address()))) - .unwrap(); + submit_burn(&mut ledger, leader_wallet, 1); let block = ledger.mine_next_block(leader_wallet, height).unwrap(); ledger.apply_block(block).unwrap(); } @@ -195,9 +218,7 @@ fn burn_in_block_creates_ticket_after_maturity_delay() { "the block 1 burn should not be eligible before its maturity delay, and only Bob should hold it at block 4" ); - ledger - .submit_transaction(bob.burn(1, ledger.next_nonce(bob.address()))) - .unwrap(); + submit_burn(&mut ledger, &bob, 1); assert!(ledger.mine_next_block(&bob, 4).is_ok()); } @@ -210,12 +231,10 @@ fn transfer_and_burn_update_balances_when_block_is_applied() { allocations.insert(bob.address().to_string(), 100); let mut ledger = Ledger::new(allocations, 10); - ledger - .submit_transaction(alice.transfer(bob.address(), 125, ledger.next_nonce(alice.address()))) - .unwrap(); - ledger - .submit_transaction(alice.burn(25, ledger.next_nonce(alice.address()))) - .unwrap(); + let transfer = transfer_tx(&ledger, &alice, bob.address(), 125); + ledger.submit_transaction(transfer).unwrap(); + let burn = burn_tx(&ledger, &alice, 25); + ledger.submit_transaction(burn).unwrap(); let block = ledger.mine_next_block(&alice, 1).unwrap(); ledger.apply_block(block).unwrap(); @@ -232,9 +251,9 @@ fn forged_transaction_is_rejected() { allocations.insert(bob.address().to_string(), 1_000); let mut ledger = Ledger::new(allocations, 10); - let mut forged = bob.burn(10, 1); - if let luun::domain::Transaction::Burn { from, .. } = &mut forged { - *from = alice.address().to_string(); + let mut forged = burn_tx(&ledger, &bob, 10); + if let luun::domain::Transaction::Burn { inputs, .. } = &mut forged { + inputs[0].owner = alice.address().to_string(); } let error = ledger.submit_transaction(forged).unwrap_err(); @@ -247,9 +266,7 @@ fn block_with_forged_transaction_is_rejected() { let mut allocations = BTreeMap::new(); allocations.insert(alice.address().to_string(), 1_000); let mut ledger = Ledger::new(allocations, 10); - ledger - .submit_transaction(alice.burn(1, ledger.next_nonce(alice.address()))) - .unwrap(); + submit_burn(&mut ledger, &alice, 1); let mut block = ledger.mine_next_block(&alice, 1).unwrap(); if let luun::domain::Transaction::Burn { signature, .. } = &mut block.transactions[0] { @@ -269,9 +286,7 @@ fn block_reward_is_fixed_at_one_hundred_luun() { allocations.insert(alice.address().to_string(), 1_000); let mut ledger = Ledger::new(allocations, 10); - ledger - .submit_transaction(alice.burn(1, ledger.next_nonce(alice.address()))) - .unwrap(); + submit_burn(&mut ledger, &alice, 1); let block = ledger.mine_next_block(&alice, 1).unwrap(); assert_eq!(block.reward, BLOCK_REWARD); @@ -290,17 +305,9 @@ fn transaction_fees_are_paid_to_the_block_miner() { let mut ledger = Ledger::new_with_genesis_burns(allocations, vec![GenesisBurn::new(alice.address(), 1)], 10) .unwrap(); - ledger - .submit_transaction(alice.burn(1, ledger.next_nonce(alice.address()))) - .unwrap(); - ledger - .submit_transaction(bob.transfer_with_fee( - alice.address(), - 10, - 7, - ledger.next_nonce(bob.address()), - )) - .unwrap(); + submit_burn(&mut ledger, &alice, 1); + let tx = transfer_fee_tx(&ledger, &bob, alice.address(), 10, 7); + ledger.submit_transaction(tx).unwrap(); let block = ledger.mine_next_block(&alice, 1).unwrap(); assert_eq!(block.reward, BLOCK_REWARD + 7); @@ -321,10 +328,9 @@ fn miner_orders_block_transactions_by_fee_rate_after_required_burn() { let mut ledger = Ledger::new_with_genesis_burns(allocations, vec![GenesisBurn::new(alice.address(), 1)], 10) .unwrap(); - let required_burn = alice.burn(1, ledger.next_nonce(alice.address())); - let low_fee = bob.transfer_with_fee(alice.address(), 1, 1, ledger.next_nonce(bob.address())); - let high_fee = - carol.transfer_with_fee(alice.address(), 1, 20, ledger.next_nonce(carol.address())); + let required_burn = burn_tx(&ledger, alice, 1); + let low_fee = transfer_fee_tx(&ledger, bob, alice.address(), 1, 1); + let high_fee = transfer_fee_tx(&ledger, carol, alice.address(), 1, 20); ledger.submit_transaction(low_fee.clone()).unwrap(); ledger.submit_transaction(high_fee.clone()).unwrap(); ledger.submit_transaction(required_burn.clone()).unwrap(); @@ -357,16 +363,9 @@ fn oversized_blocks_are_rejected() { let mut ledger = Ledger::new_with_genesis_burns(allocations, vec![GenesisBurn::new(alice.address(), 1)], 10) .unwrap(); - ledger - .submit_transaction(alice.burn(1, ledger.next_nonce(alice.address()))) - .unwrap(); + submit_burn(&mut ledger, &alice, 1); let mut block = ledger.mine_next_block(&alice, 1).unwrap(); - let oversized_transfer = bob.transfer_with_fee( - "x".repeat(MAX_BLOCK_BYTES), - 1, - 3, - ledger.next_nonce(bob.address()), - ); + 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.hash = block.compute_hash(); @@ -391,17 +390,9 @@ fn miner_skips_oversized_pending_transaction_and_keeps_fitting_fee_transaction() let mut ledger = Ledger::new_with_genesis_burns(allocations, vec![GenesisBurn::new(alice.address(), 1)], 10) .unwrap(); - ledger - .submit_transaction(alice.burn(1, ledger.next_nonce(alice.address()))) - .unwrap(); - let oversized = bob.transfer_with_fee( - "x".repeat(MAX_BLOCK_BYTES), - 1, - 100_000, - ledger.next_nonce(bob.address()), - ); - let fitting = - carol.transfer_with_fee(alice.address(), 1, 5, ledger.next_nonce(carol.address())); + submit_burn(&mut ledger, alice, 1); + let oversized = transfer_fee_tx(&ledger, bob, "x".repeat(MAX_BLOCK_BYTES), 1, 100_000); + let fitting = transfer_fee_tx(&ledger, carol, alice.address(), 1, 5); ledger.submit_transaction(oversized.clone()).unwrap(); ledger.submit_transaction(fitting.clone()).unwrap(); @@ -425,9 +416,9 @@ fn transfer_that_would_overflow_recipient_balance_is_rejected() { allocations.insert(alice.address().to_string(), 1); allocations.insert(bob.address().to_string(), Amount::MAX); - let mut ledger = Ledger::new(allocations, 10); + let ledger = Ledger::new(allocations, 10); let error = ledger - .submit_transaction(alice.transfer(bob.address(), 1, ledger.next_nonce(alice.address()))) + .build_transfer(&alice, bob.address(), 1, 0) .unwrap_err(); assert!(format!("{error:#}").contains("balance overflow")); @@ -440,9 +431,7 @@ fn block_reward_that_would_overflow_miner_balance_is_rejected() { allocations.insert(alice.address().to_string(), Amount::MAX); let mut ledger = Ledger::new(allocations, 10); - ledger - .submit_transaction(alice.burn(1, ledger.next_nonce(alice.address()))) - .unwrap(); + submit_burn(&mut ledger, &alice, 1); let block = ledger.mine_next_block(&alice, 1).unwrap(); let error = ledger.apply_block(block).unwrap_err(); @@ -479,9 +468,7 @@ fn leader_block_without_burn_is_rejected() { allocations.insert(alice.address().to_string(), 1_000); let mut ledger = Ledger::new(allocations, 10); - ledger - .submit_transaction(alice.burn(1, ledger.next_nonce(alice.address()))) - .unwrap(); + submit_burn(&mut ledger, &alice, 1); let mut block = ledger.mine_next_block(&alice, 1).unwrap(); block.transactions.clear(); block.hash = block.compute_hash(); @@ -497,9 +484,7 @@ fn block_hash_is_bound_to_block_contents() { allocations.insert(alice.address().to_string(), 1_000); let mut ledger = Ledger::new(allocations, 10); - ledger - .submit_transaction(alice.burn(1, ledger.next_nonce(alice.address()))) - .unwrap(); + submit_burn(&mut ledger, &alice, 1); let mut block = ledger.mine_next_block(&alice, 1).unwrap(); block.timestamp_ms += 1; @@ -608,9 +593,7 @@ fn setting_burn_rate_after_running_at_zero_adds_mempool_burn() { allocations.insert(bob.address().to_string(), 100); let mut ledger = Ledger::new(allocations.clone(), 25); - ledger - .submit_transaction(alice.burn(1, ledger.next_nonce(alice.address()))) - .unwrap(); + submit_burn(&mut ledger, &alice, 1); let first = ledger.mine_next_block(&alice, 1).unwrap(); ledger.apply_block(first).unwrap(); @@ -640,9 +623,7 @@ fn automatic_mining_waits_when_wallet_is_not_selected_leader() { allocations.insert(bob.address().to_string(), 1_000); let mut ledger = Ledger::new(allocations.clone(), 25); - ledger - .submit_transaction(alice.burn(1, ledger.next_nonce(alice.address()))) - .unwrap(); + submit_burn(&mut ledger, &alice, 1); let first = ledger.mine_next_block(&alice, 1).unwrap(); ledger.apply_block(first).unwrap(); @@ -667,9 +648,7 @@ fn block_with_wrong_vdf_rounds_is_rejected() { let mut ledger = Ledger::new_with_genesis_burns(genesis, vec![GenesisBurn::new(wallet.address(), 1)], 25) .unwrap(); - ledger - .submit_transaction(wallet.burn(1, ledger.next_nonce(wallet.address()))) - .unwrap(); + submit_burn(&mut ledger, &wallet, 1); let mut block = ledger.mine_next_block(&wallet, 1).unwrap(); block.vdf_rounds = 1; @@ -696,9 +675,7 @@ fn vdf_rounds_retarget_toward_one_minute_blocks() { genesis.insert(wallet.address().to_string(), 1_000); let mut ledger = Ledger::new(genesis, 100); - ledger - .submit_transaction(wallet.burn(1, ledger.next_nonce(wallet.address()))) - .unwrap(); + submit_burn(&mut ledger, &wallet, 1); let block1 = ledger .mine_next_block(&wallet, VDF_TARGET_BLOCK_MS) .unwrap(); @@ -706,9 +683,7 @@ fn vdf_rounds_retarget_toward_one_minute_blocks() { ledger.apply_block(block1).unwrap(); assert_eq!(ledger.vdf_rounds(), 100); - ledger - .submit_transaction(wallet.burn(1, ledger.next_nonce(wallet.address()))) - .unwrap(); + submit_burn(&mut ledger, &wallet, 1); let block2 = ledger .mine_next_block(&wallet, VDF_TARGET_BLOCK_MS + VDF_TARGET_BLOCK_MS / 2) .unwrap(); @@ -716,9 +691,7 @@ fn vdf_rounds_retarget_toward_one_minute_blocks() { ledger.apply_block(block2).unwrap(); assert_eq!(ledger.vdf_rounds(), 110); - ledger - .submit_transaction(wallet.burn(1, ledger.next_nonce(wallet.address()))) - .unwrap(); + submit_burn(&mut ledger, &wallet, 1); let block3 = ledger .mine_next_block( &wallet, @@ -731,28 +704,24 @@ fn vdf_rounds_retarget_toward_one_minute_blocks() { } #[test] -fn future_nonce_transactions_wait_for_missing_gap() { +fn conflicting_utxo_spends_are_not_accepted_together() { let wallet = Wallet::from_seed("alice"); let mut genesis = BTreeMap::new(); genesis.insert(wallet.address().to_string(), 1_000); let mut ledger = Ledger::new(genesis, 25); - let tx3 = wallet.burn(3, 3); - ledger.submit_transaction(tx3.clone()).unwrap(); - assert_eq!(ledger.next_nonce(wallet.address()), 1); + let first = burn_tx(&ledger, &wallet, 3); + let conflicting = burn_tx(&ledger, &wallet, 4); + ledger.submit_transaction(first.clone()).unwrap(); + assert!(!ledger.submit_transaction(conflicting).unwrap()); assert_eq!(ledger.pending().len(), 1); - ledger.submit_transaction(wallet.burn(1, 1)).unwrap(); - assert_eq!(ledger.next_nonce(wallet.address()), 2); - ledger.submit_transaction(wallet.burn(2, 2)).unwrap(); - assert_eq!(ledger.next_nonce(wallet.address()), 4); - let block = ledger .prepare_next_block(wallet.address(), 1) .unwrap() .finish(&wallet, "test-vdf".to_string()); - assert_eq!(block.transactions.len(), 3); - assert!(block.transactions.contains(&tx3)); + assert_eq!(block.transactions.len(), 1); + assert!(block.transactions.contains(&first)); } #[test] @@ -923,7 +892,7 @@ fn joined_nodes_import_transfer_block_and_every_wallet_mines() { block5 .transactions .iter() - .any(|tx| matches!(tx, luun::domain::Transaction::Transfer { to, amount, .. } if to == bob.address() && *amount == 30)) + .any(|tx| tx.to() == Some(bob.address()) && tx.amount() == 30) ); mined_by.push(block5.miner.clone()); let block5_outbox = network.node_mut("a").unwrap().drain_outbox(); @@ -1477,9 +1446,7 @@ fn chain_snapshot_round_trips_ledger_state() { allocations.insert(alice.address().to_string(), 1_000); let mut ledger = Ledger::new(allocations, 10); - ledger - .submit_transaction(alice.burn(10, ledger.next_nonce(alice.address()))) - .unwrap(); + submit_burn(&mut ledger, &alice, 10); let block = ledger.mine_next_block(&alice, 1).unwrap(); ledger.apply_block(block).unwrap(); @@ -1722,7 +1689,7 @@ fn transactions_from_abandoned_fork_blocks_return_to_mempool_after_switch() { mine_wallet_burn_block(&mut common, &alice, 1); let mut local = common.clone(); - let abandoned_transfer = bob.transfer(carol.address(), 7, local.next_nonce(bob.address())); + let abandoned_transfer = transfer_tx(&local, &bob, carol.address(), 7); local .submit_transaction(abandoned_transfer.clone()) .unwrap(); @@ -1762,21 +1729,17 @@ fn longer_valid_fork_snapshot_reorgs_and_preserves_local_transactions() { ) .unwrap(); - let local_burn = alice.burn(1, local.next_nonce(alice.address())); + let local_burn = burn_tx(&local, &alice, 1); local.submit_transaction(local_burn.clone()).unwrap(); let local_block = local.mine_next_block(&alice, 1).unwrap(); local.apply_block(local_block).unwrap(); - let local_transfer = bob.transfer(alice.address(), 5, local.next_nonce(bob.address())); + let local_transfer = transfer_tx(&local, &bob, alice.address(), 5); local.submit_transaction(local_transfer.clone()).unwrap(); - remote - .submit_transaction(alice.burn(1, remote.next_nonce(alice.address()))) - .unwrap(); + submit_burn(&mut remote, &alice, 1); let remote_block_1 = remote.mine_next_block(&alice, 1).unwrap(); remote.apply_block(remote_block_1).unwrap(); - remote - .submit_transaction(alice.burn(1, remote.next_nonce(alice.address()))) - .unwrap(); + submit_burn(&mut remote, &alice, 1); let remote_block_2 = remote.mine_next_block(&alice, 2).unwrap(); remote.apply_block(remote_block_2).unwrap();