iuna

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

commit c71fd75ad36b2a12ff91de5335b9b670fb5326a9
parent eb06c2e50ee9d4d85cdb1014047a0b650d5fee6d
Author: Joris Hartog <jorishartog@hotmail.com>
Date:   Fri, 24 Jul 2026 11:29:02 +0200

Add manual UTXO selection for transfers

Diffstat:
Massets/luun-ui.js | 38++++++++++++++++++++++++++++++++++++--
Msrc/adapters/http.rs | 100+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------
Msrc/app.rs | 18+++++++++++++++++-
Msrc/domain.rs | 168+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
4 files changed, 314 insertions(+), 10 deletions(-)

diff --git a/assets/luun-ui.js b/assets/luun-ui.js @@ -30,6 +30,8 @@ window.luunApp = function luunApp() { transferTo: "", transferAmount: null, transferFee: "1", + showSendAdvanced: false, + selectedTransferUtxos: [], peerAddress: "", flash: null, flashTimer: null, @@ -421,7 +423,11 @@ window.luunApp = function luunApp() { async postForm(path, fields, successMessage) { const body = new URLSearchParams(); for (const [key, value] of Object.entries(fields)) { - body.set(key, value); + if (Array.isArray(value)) { + for (const item of value) body.append(key, item); + } else { + body.set(key, value); + } } const response = await fetch(path, { method: "POST", @@ -540,16 +546,25 @@ window.luunApp = function luunApp() { const recipient = this.short(this.transferTo); await this.postForm( "/api/transfer", - { to: this.transferTo, amount, fee }, + { to: this.transferTo, amount, fee, utxos: this.selectedTransferUtxos }, `Queued transfer of ${this.amountLabel(amount)} LUUN to ${recipient} with ${this.amountLabel(fee)} fee` ); this.transferTo = ""; this.transferAmount = null; + this.selectedTransferUtxos = []; + this.showSendAdvanced = false; } catch (error) { this.showFlash(error.message, "error"); } }, + toggleSendAdvanced() { + this.showSendAdvanced = !this.showSendAdvanced; + if (!this.showSendAdvanced) { + this.selectedTransferUtxos = []; + } + }, + async addPeer() { try { const peer = this.peerAddress; @@ -661,6 +676,25 @@ window.luunApp = function luunApp() { return `${txid}:${index}`; }, + utxoOutpoint(utxo) { + return this.txInputOutpoint({ outpoint: utxo.outpoint }); + }, + + selectedTransferUtxoTotal() { + const selected = new Set(this.selectedTransferUtxos); + return this.walletUtxos + .filter((utxo) => selected.has(this.utxoOutpoint(utxo))) + .reduce((sum, utxo) => sum + Number(utxo.amount || 0), 0); + }, + + transferRequiredTotal() { + return this.parseLuunAmount(this.transferAmount) + this.parseLuunAmount(this.transferFee); + }, + + selectedTransferUtxosCoverTransfer() { + return this.selectedTransferUtxos.length === 0 || this.selectedTransferUtxoTotal() >= this.transferRequiredTotal(); + }, + txInputAmountLabel(input) { return input.amount === null || input.amount === undefined ? "-" : `LUUN ${this.amountLabel(input.amount)}`; }, diff --git a/src/adapters/http.rs b/src/adapters/http.rs @@ -51,6 +51,8 @@ struct TransferForm { to: String, amount: Amount, fee: Option<Amount>, + #[serde(default)] + utxos: Vec<String>, } #[derive(Debug, Deserialize)] @@ -293,7 +295,8 @@ async fn api_wallet_utxos(State(state): State<HttpState>) -> Json<Vec<WalletUtxo let wallet = node.wallet_address().to_string(); let mut utxos = node .ledger() - .utxos_for_address(&wallet) + .available_utxos_for_address(&wallet) + .unwrap_or_default() .into_iter() .map(|(outpoint, output)| WalletUtxoRow { outpoint, @@ -741,11 +744,15 @@ fn dev_seed_verify_bypass_allowed(env_present: bool) -> bool { } async fn transfer(state: &HttpState, form: TransferForm) -> Result<()> { - let (to, amount, fee) = validate_transfer_form(form)?; + let (to, amount, fee, selected_utxos) = validate_transfer_form(form)?; let result = { let mut node = state.node.lock().await; - let result = node.transfer_with_fee(to, amount, fee); + let result = if selected_utxos.is_empty() { + node.transfer_with_fee(to, amount, fee) + } else { + node.transfer_with_fee_spending(to, amount, fee, &selected_utxos) + }; let outbox = node.drain_outbox(); (result, outbox) }; @@ -756,7 +763,7 @@ async fn transfer(state: &HttpState, form: TransferForm) -> Result<()> { } } -fn validate_transfer_form(form: TransferForm) -> Result<(String, Amount, Amount)> { +fn validate_transfer_form(form: TransferForm) -> Result<(String, Amount, Amount, Vec<OutPoint>)> { let to = form.to.trim(); if to.is_empty() { bail!("recipient is required"); @@ -765,7 +772,28 @@ fn validate_transfer_form(form: TransferForm) -> Result<(String, Amount, Amount) bail!("amount must be greater than zero"); } let fee = form.fee.context("fee is required")?; - Ok((to.to_string(), form.amount, fee)) + let selected_utxos = form + .utxos + .iter() + .filter(|value| !value.trim().is_empty()) + .map(|value| parse_outpoint(value)) + .collect::<Result<Vec<_>>>()?; + Ok((to.to_string(), form.amount, fee, selected_utxos)) +} + +fn parse_outpoint(value: &str) -> Result<OutPoint> { + let (txid, index) = value + .rsplit_once(':') + .with_context(|| format!("invalid UTXO reference {value}"))?; + if txid.is_empty() { + bail!("invalid UTXO reference {value}"); + } + Ok(OutPoint { + txid: txid.to_string(), + index: index + .parse::<u32>() + .with_context(|| format!("invalid UTXO reference {value}"))?, + }) } fn action_json(result: Result<()>) -> Json<ActionResponse> { @@ -882,6 +910,11 @@ const INDEX_HTML: &str = r#"<!doctype html> .setup-status { border: 1px solid #566d25; border-radius: 8px; padding: 10px; background: #1c2516; color: #d5f55f; font-weight: 800; } .wallet-grid { width: 100%; display: grid; grid-template-columns: minmax(0, 1fr) minmax(300px, .8fr); gap: 12px; align-items: start; } .wallet-actions { display: grid; gap: 12px; } + .advanced-toggle { justify-self: start; border: 0; padding: 0; background: transparent; color: #d5f55f; } + .send-utxo-list { display: grid; gap: 8px; max-height: 260px; overflow: auto; border: 1px solid #2f363c; border-radius: 8px; padding: 8px; background: #111316; } + .send-utxo-option { display: grid; grid-template-columns: auto minmax(0, 1fr); gap: 8px; align-items: start; border: 1px solid #2f363c; border-radius: 8px; padding: 8px; background: #181b1f; } + .send-utxo-option input { min-width: auto; margin-top: 3px; } + .send-utxo-summary { display: grid; gap: 5px; color: #9eb3bc; font-size: 13px; } .wallet-balance-line { display: inline-grid; grid-template-columns: auto auto; gap: 10px; align-items: baseline; padding: 8px 10px; border: 1px solid #2f363c; border-radius: 8px; background: #111316; color: inherit; cursor: pointer; } .wallet-balance-line:hover, .wallet-balance-line:focus-visible { border-color: #d5f55f; outline: none; } .wallet-balance-line .tx-value { font-size: 16px; font-weight: 850; } @@ -1034,6 +1067,25 @@ const INDEX_HTML: &str = r#"<!doctype html> <label>Recipient<input x-model="transferTo" autocomplete="off" 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="advanced-toggle" type="button" @click="toggleSendAdvanced" x-text="showSendAdvanced ? 'Hide advanced' : 'Advanced'"></button> + <div class="send-utxo-summary" x-show="showSendAdvanced"> + <div>Selected UTXOs: <span x-text="selectedTransferUtxos.length"></span></div> + <div>Selected total: LUUN <span x-text="amountLabel(selectedTransferUtxoTotal())"></span></div> + <div>Required: LUUN <span x-text="amountLabel(transferRequiredTotal())"></span></div> + <div class="setup-feedback error" x-show="!selectedTransferUtxosCoverTransfer()">Selected UTXOs do not cover amount plus fee</div> + <div class="send-utxo-list"> + <template x-for="utxo in walletUtxos" :key="utxoOutpoint(utxo)"> + <label class="send-utxo-option"> + <input type="checkbox" :value="utxoOutpoint(utxo)" x-model="selectedTransferUtxos"> + <span> + <span class="utxo-node-label"><span>UTXO</span><span class="utxo-node-amount">LUUN <span x-text="amountLabel(utxo.amount)"></span></span></span> + <code class="tx-value hash" x-text="utxoOutpoint(utxo)"></code> + </span> + </label> + </template> + <div class="tx-modal-empty" x-show="walletUtxos.length === 0">No spendable UTXOs</div> + </div> + </div> <button class="primary" type="submit">Send</button> </form> </div> @@ -1429,7 +1481,7 @@ mod tests { use crate::{ adapters::{config_store, config_store::UiConfig}, - domain::{Block, MICRO_LUUN, Transaction, Wallet}, + domain::{Block, MICRO_LUUN, OutPoint, Transaction, Wallet}, }; use super::{ @@ -1523,6 +1575,7 @@ mod tests { to: " ".to_string(), amount: 1, fee: Some(1), + utxos: Vec::new(), }) .unwrap_err(); assert!(error.to_string().contains("recipient is required")); @@ -1531,6 +1584,7 @@ mod tests { to: "abc".to_string(), amount: 0, fee: Some(1), + utxos: Vec::new(), }) .unwrap_err(); assert!( @@ -1543,6 +1597,7 @@ mod tests { to: "abc".to_string(), amount: 1, fee: None, + utxos: Vec::new(), }) .unwrap_err(); assert!(error.to_string().contains("fee is required")); @@ -1550,15 +1605,46 @@ mod tests { #[test] fn transfer_form_trims_recipient() { - let (to, amount, fee) = validate_transfer_form(TransferForm { + let (to, amount, fee, utxos) = validate_transfer_form(TransferForm { to: " abc ".to_string(), amount: 2, fee: Some(3), + utxos: Vec::new(), }) .unwrap(); assert_eq!(to, "abc"); assert_eq!(amount, 2); assert_eq!(fee, 3); + assert!(utxos.is_empty()); + } + + #[test] + fn transfer_form_parses_selected_utxos() { + let (_, _, _, utxos) = validate_transfer_form(TransferForm { + to: "abc".to_string(), + amount: 2, + fee: Some(3), + utxos: vec![ + "tx-one:0".to_string(), + "tx:with:colons:7".to_string(), + "".to_string(), + ], + }) + .unwrap(); + + assert_eq!( + utxos, + vec![ + OutPoint { + txid: "tx-one".to_string(), + index: 0 + }, + OutPoint { + txid: "tx:with:colons".to_string(), + index: 7 + } + ] + ); } } diff --git a/src/app.rs b/src/app.rs @@ -10,7 +10,7 @@ use tokio::sync::Mutex; use crate::domain::{ Amount, Block, ChainSnapshot, ChainStatus, DEFAULT_TRANSACTION_FEE, LaunchProfile, Ledger, - PreparedBlock, Transaction, VDF_TARGET_BLOCK_MS, Wallet, run_vdf, + OutPoint, PreparedBlock, Transaction, VDF_TARGET_BLOCK_MS, Wallet, run_vdf, }; pub type SharedNode = Arc<Mutex<NodeCore>>; @@ -434,6 +434,22 @@ impl NodeCore { Ok(tx) } + pub fn transfer_with_fee_spending( + &mut self, + to: impl Into<String>, + amount: Amount, + fee: Amount, + outpoints: &[OutPoint], + ) -> Result<Transaction> { + let tx = + self.ledger + .build_transfer_with_inputs(&self.wallet, to, amount, fee, outpoints)?; + 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 @@ -1156,6 +1156,14 @@ impl Ledger { .collect() } + pub fn available_utxos_for_address(&self, address: &str) -> Result<Vec<(OutPoint, TxOutput)>> { + Ok(self + .utxos_after_valid_pending()? + .into_iter() + .filter(|(_, output)| output.address == address) + .collect()) + } + pub fn next_nonce(&self, address: &str) -> u64 { self.utxos .keys() @@ -1203,6 +1211,42 @@ impl Ledger { Ok(transaction) } + pub fn build_transfer_with_inputs( + &self, + wallet: &Wallet, + to: impl Into<String>, + amount: Amount, + fee: Amount, + outpoints: &[OutPoint], + ) -> Result<Transaction> { + let required = amount + .checked_add(fee) + .context("transfer amount plus fee overflows")?; + let (inputs, input_total) = + self.select_inputs_by_outpoint(wallet.address(), required, outpoints)?; + 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) @@ -1538,6 +1582,43 @@ impl Ledger { bail!("insufficient funds for {address}") } + fn select_inputs_by_outpoint( + &self, + address: &str, + amount: Amount, + outpoints: &[OutPoint], + ) -> Result<(Vec<UnsignedTxInput>, Amount)> { + if outpoints.is_empty() { + bail!("at least one UTXO must be selected"); + } + let utxos = self.utxos_after_valid_pending()?; + let mut seen = BTreeSet::new(); + let mut selected = Vec::new(); + let mut total = 0_u64; + for outpoint in outpoints { + if !seen.insert(outpoint.clone()) { + bail!("selected UTXO {} is duplicated", outpoint.id()); + } + let output = utxos + .get(outpoint) + .with_context(|| format!("selected UTXO {} is not spendable", outpoint.id()))?; + if output.address != address { + bail!("selected UTXO {} is not owned by {address}", outpoint.id()); + } + selected.push(UnsignedTxInput { + outpoint: outpoint.clone(), + owner: address.to_string(), + }); + total = total + .checked_add(output.amount) + .context("selected input total overflows")?; + } + if total < amount { + bail!("selected UTXOs do not cover transfer amount plus fee"); + } + Ok((selected, total)) + } + fn validate_new_transaction(&self, transaction: &Transaction) -> Result<()> { let mut utxos = self.utxos_after_valid_pending()?; apply_transaction(transaction, &mut utxos) @@ -2430,6 +2511,93 @@ mod tests { } #[test] + fn transfer_can_spend_selected_utxos_when_they_cover_amount_and_fee() { + let alice = Wallet::from_seed("selected-utxos-alice"); + let bob = Wallet::from_seed("selected-utxos-bob"); + let mut ledger = ledger_with_wallet_utxos(&alice, &[2, 3, 5]); + let selected = vec![OutPoint { + txid: "test-utxo-2".to_string(), + index: 0, + }]; + + let tx = ledger + .build_transfer_with_inputs(&alice, bob.address(), 2, 1, &selected) + .unwrap(); + + let Transaction::Transfer { + inputs, outputs, .. + } = &tx + else { + panic!("expected transfer"); + }; + assert_eq!(inputs.len(), 1); + assert_eq!(inputs[0].outpoint, selected[0]); + assert_eq!( + outputs, + &[ + TxOutput { + address: bob.address().to_string(), + amount: 2 + }, + TxOutput { + address: alice.address().to_string(), + amount: 2 + } + ] + ); + + ledger.submit_transaction(tx).unwrap(); + let balances = pending_balances(&ledger); + assert_eq!(balances.get(bob.address()).copied(), Some(2)); + assert_eq!(balances.get(alice.address()).copied(), Some(7)); + } + + #[test] + fn transfer_rejects_selected_utxos_that_do_not_cover_amount_plus_fee() { + let alice = Wallet::from_seed("selected-utxos-insufficient-alice"); + let bob = Wallet::from_seed("selected-utxos-insufficient-bob"); + let ledger = ledger_with_wallet_utxos(&alice, &[2, 3, 5]); + let selected = vec![OutPoint { + txid: "test-utxo-0".to_string(), + index: 0, + }]; + + let error = ledger + .build_transfer_with_inputs(&alice, bob.address(), 2, 1, &selected) + .unwrap_err(); + + assert!(format!("{error:#}").contains("selected UTXOs do not cover")); + } + + #[test] + fn transfer_rejects_selected_utxos_owned_by_someone_else() { + let alice = Wallet::from_seed("selected-utxos-owner-alice"); + let bob = Wallet::from_seed("selected-utxos-owner-bob"); + let carol = Wallet::from_seed("selected-utxos-owner-carol"); + let mut ledger = ledger_with_wallet_utxos(&alice, &[5]); + ledger.utxos.insert( + OutPoint { + txid: "carol-utxo".to_string(), + index: 0, + }, + TxOutput { + address: carol.address().to_string(), + amount: 5, + }, + ); + let selected = vec![OutPoint { + txid: "carol-utxo".to_string(), + index: 0, + }]; + + let error = ledger + .build_transfer_with_inputs(&alice, bob.address(), 2, 1, &selected) + .unwrap_err(); + + assert!(format!("{error:#}").contains("is not owned")); + } + + #[test] fn transfer_rejects_when_combined_utxos_do_not_cover_amount_plus_fee() { let alice = Wallet::from_seed("combine-insufficient-alice"); let bob = Wallet::from_seed("combine-insufficient-bob");