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:
| M | assets/luun-ui.js | | | 38 | ++++++++++++++++++++++++++++++++++++-- |
| M | src/adapters/http.rs | | | 100 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------ |
| M | src/app.rs | | | 18 | +++++++++++++++++- |
| M | src/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");