iuna

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

commit 6c465040423a2ca22e89abd4c194e9ab58a394de
parent 6c15f2d507f6ea3b73a01e61d6f7c314f258cbcc
Author: Joris Hartog <jorishartog@hotmail.com>
Date:   Sun,  9 Aug 2026 21:49:45 +0200

Add wallet address book

Diffstat:
Msrc/adapters/config_store.rs | 14++++++++++++++
Msrc/adapters/http.rs | 353++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----
Msrc/domain.rs | 2+-
Mwww/assets/iuna-ui.js | 135+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----
4 files changed, 477 insertions(+), 27 deletions(-)

diff --git a/src/adapters/config_store.rs b/src/adapters/config_store.rs @@ -1,6 +1,7 @@ #[cfg(unix)] use std::os::unix::fs::OpenOptionsExt; use std::{ + collections::BTreeMap, fs::{self, File, OpenOptions}, io::Write, path::Path, @@ -35,6 +36,7 @@ pub struct UiConfig { pub p2p_accept_inbound: bool, pub p2p_announce_addr: Option<String>, pub peers: Vec<String>, + pub address_book: BTreeMap<String, String>, } impl Default for UiConfig { @@ -52,6 +54,7 @@ impl Default for UiConfig { p2p_accept_inbound: false, p2p_announce_addr: None, peers: Vec::new(), + address_book: BTreeMap::new(), } } } @@ -84,6 +87,8 @@ struct ConfigFile { p2p_announce_addr: Option<String>, #[serde(default)] peers: Vec<String>, + #[serde(default)] + address_book: BTreeMap<String, String>, } pub fn load_or_create(path: &Path) -> Result<UiConfig> { @@ -117,6 +122,7 @@ pub fn save(path: &Path, config: &UiConfig) -> Result<()> { p2p_accept_inbound: Some(config.p2p_accept_inbound), p2p_announce_addr: config.p2p_announce_addr.clone(), peers: config.peers.clone(), + address_book: config.address_book.clone(), }; let bytes = serde_json::to_vec_pretty(&stored).context("failed to serialize config file")?; let mut file = create_config_file(path)?; @@ -173,6 +179,7 @@ fn load(path: &Path) -> Result<UiConfig> { p2p_accept_inbound, p2p_announce_addr: stored.p2p_announce_addr, peers: stored.peers, + address_book: stored.address_book, }) } @@ -228,6 +235,7 @@ mod tests { assert!(stored.contains("\"p2p_accept_inbound\": false")); assert!(stored.contains("\"p2p_announce_addr\": null")); assert!(stored.contains("\"peers\": []")); + assert!(stored.contains("\"address_book\": {}")); } #[test] @@ -249,6 +257,7 @@ mod tests { p2p_accept_inbound: true, p2p_announce_addr: Some("203.0.113.10:9444".to_string()), peers: vec!["127.0.0.1:9444".to_string()], + address_book: [("iuna-address".to_string(), "Alice".to_string())].into(), ..UiConfig::default() }, ) @@ -269,6 +278,10 @@ mod tests { Some("203.0.113.10:9444") ); assert_eq!(config.peers, vec!["127.0.0.1:9444"]); + assert_eq!( + config.address_book.get("iuna-address"), + Some(&"Alice".to_string()) + ); } #[test] @@ -309,6 +322,7 @@ mod tests { assert!(!config.keep_track_of_metrics); assert!(!config.p2p_accept_inbound); assert_eq!(config.peers, vec!["127.0.0.1:9444"]); + assert!(config.address_book.is_empty()); } #[test] diff --git a/src/adapters/http.rs b/src/adapters/http.rs @@ -37,7 +37,7 @@ use crate::{ BLINDED_REVEAL_BUNDLE_SIGNER_FEE_BPS, BlindedReveal, BlindedTransaction, Block, BurnLeaderRank, ChainSnapshot, Ledger, MINE_FINALIZER_FEE, MINE_REWARD, OutPoint, REVEAL_COMMITTEE_SIZE, RevealedBlindedTransaction, Transaction, TxInput, TxOutput, Wallet, - blinded_reveal_finalizer_fee, hex_hash, revealed_blinded_transactions, + blinded_reveal_finalizer_fee, hex_hash, revealed_blinded_transactions, validate_address, }, }; @@ -186,6 +186,18 @@ struct PeerForm { } #[derive(Debug, Deserialize)] +struct AddressBookForm { + address: String, + name: String, + old_address: Option<String>, +} + +#[derive(Debug, Deserialize)] +struct AddressBookDeleteForm { + address: String, +} + +#[derive(Debug, Deserialize)] struct ConfigForm { setup_complete: bool, #[serde(default)] @@ -479,6 +491,10 @@ pub async fn serve( .route("/api/status", get(api_status)) .route("/api/blocks", get(api_blocks)) .route("/api/config", get(api_config).post(api_config_form)) + .route( + "/api/address-book", + post(api_address_book_form).delete(api_address_book_delete_form), + ) .route("/api/wallet/setup", get(api_wallet_setup)) .route("/api/wallet/generate", post(api_wallet_generate_form)) .route("/api/wallet/import", post(api_wallet_import_form)) @@ -1185,6 +1201,20 @@ async fn api_peer_delete_form( action_json(result) } +async fn api_address_book_form( + State(state): State<HttpState>, + Form(form): Form<AddressBookForm>, +) -> Json<ActionResponse> { + action_json(upsert_address_book_entry(&state, form.address, form.name, form.old_address).await) +} + +async fn api_address_book_delete_form( + State(state): State<HttpState>, + Form(form): Form<AddressBookDeleteForm>, +) -> Json<ActionResponse> { + action_json(remove_address_book_entry(&state, form.address).await) +} + async fn peer_form(State(state): State<HttpState>, Form(form): Form<PeerForm>) -> Response { match add_peer(&state, form.peer).await { Ok(()) => Redirect::to("/").into_response(), @@ -1384,6 +1414,37 @@ async fn remove_peer(state: &HttpState, peer: String) -> Result<()> { config_store::save(&state.config_path, &config) } +async fn upsert_address_book_entry( + state: &HttpState, + address: String, + name: String, + old_address: Option<String>, +) -> Result<()> { + let address = validate_address_book_address(address)?; + let name = validate_address_book_name(name)?; + let old_address = old_address.map(validate_address_book_address).transpose()?; + let mut config = state.ui_config.lock().await; + if let Some(old_address) = old_address.as_deref() { + if old_address != address { + if config.address_book.contains_key(&address) { + bail!("address is already saved"); + } + config.address_book.remove(old_address); + } + } else if config.address_book.contains_key(&address) { + bail!("address is already saved"); + } + config.address_book.insert(address, name); + config_store::save(&state.config_path, &config) +} + +async fn remove_address_book_entry(state: &HttpState, address: String) -> Result<()> { + let address = validate_address_book_address(address)?; + let mut config = state.ui_config.lock().await; + config.address_book.remove(&address); + config_store::save(&state.config_path, &config) +} + fn validate_peer_address(peer: String) -> Result<String> { let peer = peer.trim().to_string(); if peer.is_empty() { @@ -1392,6 +1453,23 @@ fn validate_peer_address(peer: String) -> Result<String> { Ok(peer) } +fn validate_address_book_address(address: String) -> Result<String> { + let address = address.trim().to_string(); + if address.is_empty() { + bail!("address is required"); + } + validate_address(&address, "address book")?; + Ok(address.to_ascii_lowercase()) +} + +fn validate_address_book_name(name: String) -> Result<String> { + let name = name.trim().to_string(); + if name.is_empty() { + bail!("name is required"); + } + Ok(name) +} + fn network_health( status: &NodeStatus, peers: &[PeerInfo], @@ -3314,6 +3392,24 @@ const INDEX_HTML: &str = r#"<!doctype html> .compact-number-field input:focus { border-color: #d5f55f; outline: 2px solid rgba(213,245,95,.2); outline-offset: 2px; } .receive-address { display: grid; gap: 8px; } .address-box { border: 1px solid #2f363c; border-radius: 8px; padding: 11px; background: #111316; } + .address-book-list { display: grid; gap: 8px; margin-top: 12px; } + .address-book-row { width: 100%; display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 8px; align-items: center; text-align: left; border: 1px solid #2f363c; border-radius: 8px; padding: 9px; background: #111316; color: inherit; } + .address-book-row:hover { border-color: #4c565c; background: #15181b; } + .address-book-row > svg { width: 18px; height: 18px; stroke: currentColor; stroke-width: 2; fill: none; stroke-linecap: round; stroke-linejoin: round; color: #9fa8ad; } + .address-book-name { font-weight: 800; color: #eef6f8; overflow-wrap: anywhere; } + .address-book-actions { display: flex; gap: 6px; align-items: center; } + .address-book-modal { width: min(560px, 100%); } + .address-book-modal form { width: 100%; } + .address-book-modal form label { flex: 1 0 100%; } + .address-book-modal-actions { flex: 1 0 100%; width: 100%; display: flex; justify-content: flex-end; gap: 8px; margin-top: 12px; } + .address-book-picker-list { display: grid; gap: 8px; } + .address-book-picker-row { width: 100%; display: grid; gap: 3px; text-align: left; border: 1px solid #2f363c; border-radius: 8px; padding: 10px; background: #111316; color: inherit; } + .address-book-picker-row:hover { border-color: #4c565c; background: #15181b; } + .recipient-field { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 8px; align-items: end; } + .icon-button { display: inline-grid; place-items: center; width: 38px; height: 38px; padding: 0; line-height: 0; border-radius: 8px; } + .icon-button svg { width: 19px; height: 19px; stroke: currentColor; stroke-width: 2; fill: none; stroke-linecap: round; stroke-linejoin: round; } + .modal-delete-button { color: #ffb4b4; } + input.invalid { border-color: #e36a6a; outline: 2px solid rgba(227,106,106,.16); outline-offset: 2px; } .panel-head { display: flex; justify-content: space-between; gap: 12px; align-items: center; margin-bottom: 12px; } .panel-head h2, .panel-head h3 { margin-bottom: 0; } .switch { display: inline-flex; grid-template-columns: none; align-items: center; gap: 8px; color: #d6dee2; font-weight: 700; } @@ -3538,7 +3634,12 @@ const INDEX_HTML: &str = r#"<!doctype html> <div class="panel"> <h3>Send</h3> <form @submit.prevent="sendTransfer"> - <label>Recipient<input x-model="transferTo" @input="scheduleFeeEstimates" autocomplete="off" required></label> + <div class="recipient-field"> + <label>Recipient<input x-model="transferTo" @input="scheduleFeeEstimates" autocomplete="off" required></label> + <button class="icon-button" type="button" @click="openAddressBookPicker()" title="Choose contact" aria-label="Choose contact"> + <svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 5.5A2.5 2.5 0 0 1 6.5 3H20v18H6.5A2.5 2.5 0 0 1 4 18.5z"></path><path d="M8 7h8"></path><path d="M8 11h6"></path><path d="M8 15h4"></path></svg> + </button> + </div> <label>Amount<input x-model="transferAmount" @input="scheduleFeeEstimates" type="number" min="0.000001" step="0.000001" required></label> <label>Fee / byte<input x-model="transferFee" @input="scheduleFeeEstimates" type="number" min="0" step="0.000001" required></label> <div class="fee-preview" x-text="feeEstimateLabel('transfer')"></div> @@ -3587,6 +3688,28 @@ const INDEX_HTML: &str = r#"<!doctype html> <div class="address-box"><code x-text="status.wallet_address || '-'"></code></div> </div> </div> + <div class="panel"> + <div class="panel-head"> + <h3>Address Book</h3> + <div class="address-book-actions"> + <button class="icon-button" type="button" @click="openAddressBookModal()" title="Add contact" aria-label="Add contact"> + <svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 5v14"></path><path d="M5 12h14"></path></svg> + </button> + </div> + </div> + <div class="address-book-list"> + <template x-for="entry in addressBookEntries()" :key="entry.address"> + <button class="address-book-row" type="button" @click="editAddressBookEntry(entry)" :title="`Edit ${entry.name}`"> + <div> + <div class="address-book-name" x-text="entry.name"></div> + <code class="tx-value hash" x-text="short(entry.address)"></code> + </div> + <svg viewBox="0 0 24 24" aria-hidden="true"><path d="M9 18l6-6-6-6"></path></svg> + </button> + </template> + <div class="muted" x-show="addressBookEntries().length === 0">No saved addresses</div> + </div> + </div> </div> <div class="panel wallet-tx-panel"> <div class="panel-head"> @@ -3615,8 +3738,8 @@ const INDEX_HTML: &str = r#"<!doctype html> <div class="tx-field"><span class="tx-label">Fee</span><span class="tx-value money" x-text="txFeeLabel(tx)"></span></div> <div class="tx-field"><span class="tx-label">Status</span><span class="tx-value text" x-text="txTitle(tx)"></span></div> <div class="tx-field"><span class="tx-label">Time</span><span class="tx-value text" x-text="walletTxTimeLabel(tx)"></span></div> - <div class="tx-field"><span class="tx-label">From</span><code class="tx-value hash" x-text="short(tx.from)"></code></div> - <div class="tx-field" x-show="tx.to"><span class="tx-label">To</span><code class="tx-value hash" x-text="short(tx.to)"></code></div> + <div class="tx-field"><span class="tx-label">From</span><code class="tx-value hash" x-text="shortAddressLabel(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="shortAddressLabel(tx.to)"></code></div> <div class="tx-field" x-show="isMineTx(tx)"><span class="tx-label">Proof Bits</span><span class="tx-value number"><span x-text="txProofBits(tx) ?? '-'"></span> / <span x-text="txDifficultyBits(tx) ?? '-'"></span></span></div> <div class="tx-field" x-show="isMineTx(tx)"><span class="tx-label">Proof Hash</span><code class="tx-value hash" x-text="short(txProofHash(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> @@ -3894,7 +4017,7 @@ const INDEX_HTML: &str = r#"<!doctype html> <div class="detail-kv"> <div class="key">Finalizer</div> <button class="detail-link" type="button" @click="openBurnLeaderRanksModal(selectedBlock)" title="Burn leader ranks"> - <code x-text="short(selectedBlock.miner)"></code> + <code x-text="shortAddressLabel(selectedBlock.miner)"></code> </button> </div> <div class="detail-kv"><div class="key">Mode</div><div x-text="selectedBlock.finalizer_mode === 'recovery' ? 'Recovery' : `Rank ${selectedBlock.finalizer_rank ?? 0}`"></div></div> @@ -3913,14 +4036,14 @@ const INDEX_HTML: &str = r#"<!doctype html> <div class="tx-list"> <h3>Transactions</h3> <div class="tx-section"> - <div class="tx-section-title"><span>Envelope</span><span class="tx-section-meta" x-text="short(selectedBlock.miner)"></span></div> + <div class="tx-section-title"><span>Envelope</span><span class="tx-section-meta" x-text="shortAddressLabel(selectedBlock.miner)"></span></div> <template x-for="tx in selectedBlock.transactions" :key="tx.signature"> <div class="tx-card" role="button" tabindex="0" @click="openTransactionModal(tx, { source: 'Envelope', blockHeight: selectedBlock.height, blockFinalizer: selectedBlock.miner })" @keydown.enter.prevent="openTransactionModal(tx, { source: 'Envelope', blockHeight: selectedBlock.height, blockFinalizer: selectedBlock.miner })" @keydown.space.prevent="openTransactionModal(tx, { source: 'Envelope', blockHeight: selectedBlock.height, blockFinalizer: selectedBlock.miner })"> <span class="pill" :class="txPillClass(tx)" x-text="txPillLabel(tx)"></span> <div class="tx-field" x-show="!isBlindedMempoolItem(tx)"><span class="tx-label">Amount</span><span class="tx-value money">IUNA <span x-text="amountLabel(txAmount(tx))"></span></span></div> <div class="tx-field"><span class="tx-label">Fee</span><span class="tx-value money" x-text="txFeeLabel(tx)"></span></div> - <div class="tx-field" x-show="!isBlindedMempoolItem(tx)"><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" x-show="!isBlindedMempoolItem(tx)"><span class="tx-label">From</span><code class="tx-value hash" x-text="shortAddressLabel(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="shortAddressLabel(txTo(tx))"></code></div> <div class="tx-field" x-show="isBlindedMempoolItem(tx)"><span class="tx-label">Commitment</span><code class="tx-value hash" x-text="short(tx.commitment || tx.signature)"></code></div> <div class="tx-field" x-show="tx.encrypted_size || tx.encryptedSize"><span class="tx-label">Bytes</span><span class="tx-value number" x-text="tx.encrypted_size || tx.encryptedSize"></span></div> <div class="tx-field" x-show="tx.expires_at_height || tx.expiresAtHeight"><span class="tx-label">Expires</span><span class="tx-value number" x-text="tx.expires_at_height || tx.expiresAtHeight"></span></div> @@ -3933,14 +4056,14 @@ const INDEX_HTML: &str = r#"<!doctype html> </div> <template x-for="bundle in selectedBlock.reveal_bundles || selectedBlock.revealBundles || []" :key="bundle.hash"> <details class="tx-section"> - <summary class="tx-section-title"><span x-text="`Reveal bundle ${bundle.slot}`"></span><span class="tx-section-meta"><span x-text="short(bundle.member)"></span> · <span x-text="bundle.byte_size || bundle.byteSize || 0"></span>B</span></summary> + <summary class="tx-section-title"><span x-text="`Reveal bundle ${bundle.slot}`"></span><span class="tx-section-meta"><span x-text="shortAddressLabel(bundle.member)"></span> · <span x-text="bundle.byte_size || bundle.byteSize || 0"></span>B</span></summary> <template x-for="tx in bundle.reveals" :key="tx.signature"> <div class="tx-card" role="button" tabindex="0" @click="openTransactionModal(tx, { source: 'Reveal bundle', blockHeight: selectedBlock.height, blockFinalizer: bundle.member })" @keydown.enter.prevent="openTransactionModal(tx, { source: 'Reveal bundle', blockHeight: selectedBlock.height, blockFinalizer: bundle.member })" @keydown.space.prevent="openTransactionModal(tx, { source: 'Reveal bundle', blockHeight: selectedBlock.height, blockFinalizer: bundle.member })"> <span class="pill" :class="txPillClass(tx)" x-text="txPillLabel(tx)"></span> <div class="tx-field" x-show="!isBlindedMempoolItem(tx)"><span class="tx-label">Amount</span><span class="tx-value money">IUNA <span x-text="amountLabel(txAmount(tx))"></span></span></div> <div class="tx-field"><span class="tx-label">Fee</span><span class="tx-value money" x-text="txFeeLabel(tx)"></span></div> - <div class="tx-field" x-show="!isBlindedMempoolItem(tx)"><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" x-show="!isBlindedMempoolItem(tx)"><span class="tx-label">From</span><code class="tx-value hash" x-text="shortAddressLabel(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="shortAddressLabel(txTo(tx))"></code></div> <div class="tx-field" x-show="isBlindedMempoolItem(tx)"><span class="tx-label">Commitment</span><code class="tx-value hash" x-text="short(tx.commitment || tx.signature)"></code></div> <div class="tx-field" x-show="!isBlindedMempoolItem(tx)"><span class="tx-label">Signature</span><code class="tx-value hash" x-text="short(tx.signature)"></code></div> </div> @@ -3969,8 +4092,8 @@ const INDEX_HTML: &str = r#"<!doctype html> </div> <div class="tx-field" x-show="!isBlindedMempoolItem(tx)"><span class="tx-label">Amount</span><span class="tx-value money">IUNA <span x-text="amountLabel(txAmount(tx))"></span></span></div> <div class="tx-field"><span class="tx-label">Fee</span><span class="tx-value money" x-text="txFeeLabel(tx)"></span></div> - <div class="tx-field" x-show="!isBlindedMempoolItem(tx)"><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" x-show="!isBlindedMempoolItem(tx)"><span class="tx-label">From</span><code class="tx-value hash" x-text="shortAddressLabel(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="shortAddressLabel(txTo(tx))"></code></div> <div class="tx-field" x-show="isBlindedMempoolItem(tx)"><span class="tx-label">Commitment</span><code class="tx-value hash" x-text="short(tx.commitment || tx.signature)"></code></div> <div class="tx-field" x-show="tx.encrypted_size || tx.encryptedSize"><span class="tx-label">Bytes</span><span class="tx-value number" x-text="tx.encrypted_size || tx.encryptedSize"></span></div> <div class="tx-field" x-show="tx.expires_at_height || tx.expiresAtHeight"><span class="tx-label">Expires</span><span class="tx-value number" x-text="tx.expires_at_height || tx.expiresAtHeight"></span></div> @@ -4162,7 +4285,7 @@ const INDEX_HTML: &str = r#"<!doctype html> <div class="wallet-utxo-row"> <div class="utxo-node-label"><span>UTXO</span><span class="utxo-node-amount">IUNA <span x-text="amountLabel(utxo.amount)"></span></span></div> <div class="tx-field"><span class="tx-label">Outpoint</span><code class="tx-value hash" x-text="txInputOutpoint({ outpoint: utxo.outpoint })"></code></div> - <div class="tx-field"><span class="tx-label">Address</span><code class="tx-value hash" x-text="utxo.address"></code></div> + <div class="tx-field"><span class="tx-label">Address</span><code class="tx-value hash" x-text="addressLabel(utxo.address)"></code></div> </div> </template> <div class="dataset-loader" x-show="walletUtxoPage.loading" aria-hidden="true"> @@ -4223,7 +4346,7 @@ const INDEX_HTML: &str = r#"<!doctype html> <div class="tx-modal-head"> <div class="tx-modal-title"> <h2 id="burn-ranks-title" x-text="burnLeaderRanksTitle(selectedBurnLeaderBlock)"></h2> - <div class="tx-field"><span class="tx-label">Finalizer</span><code class="tx-value hash" x-text="selectedBurnLeaderBlock ? selectedBurnLeaderBlock.miner : '-'"></code></div> + <div class="tx-field"><span class="tx-label">Finalizer</span><code class="tx-value hash" x-text="selectedBurnLeaderBlock ? addressLabel(selectedBurnLeaderBlock.miner) : '-'"></code></div> </div> <button type="button" @click="closeBurnLeaderRanksModal">Close</button> </div> @@ -4232,7 +4355,7 @@ const INDEX_HTML: &str = r#"<!doctype html> <div class="rank-row"> <div class="rank-number" x-text="burnLeaderRankLabel(rank)"></div> <div class="rank-details"> - <div class="tx-field"><span class="tx-label">Owner</span><code class="tx-value hash" x-text="rank.owner"></code></div> + <div class="tx-field"><span class="tx-label">Owner</span><code class="tx-value hash" x-text="addressLabel(rank.owner)"></code></div> <div class="tx-field"><span class="tx-label">Burn</span><span class="tx-value money">IUNA <span x-text="amountLabel(rank.amount)"></span></span></div> <div class="tx-field"><span class="tx-label">Ticket</span><code class="tx-value hash" x-text="short(rank.ticket_id ?? rank.ticketId)"></code></div> <div class="tx-field"><span class="tx-label">Eligible</span><span class="tx-value number" x-text="burnLeaderEligibilityLabel(rank)"></span></div> @@ -4257,8 +4380,8 @@ const INDEX_HTML: &str = r#"<!doctype html> <div class="tx-field"><span class="tx-label">Source</span><span class="tx-value text" x-text="selectedTransactionLabel()"></span></div> <div class="tx-field" x-show="!isBlindedMempoolItem(selectedTransaction?.tx)"><span class="tx-label">Amount</span><span class="tx-value money">IUNA <span x-text="amountLabel(txAmount(selectedTransaction?.tx || {}))"></span></span></div> <div class="tx-field"><span class="tx-label">Fee</span><span class="tx-value money" x-text="txFeeLabel(selectedTransaction?.tx)"></span></div> - <div class="tx-field" x-show="!isBlindedMempoolItem(selectedTransaction?.tx)"><span class="tx-label">From</span><code class="tx-value hash" x-text="txFrom(selectedTransaction?.tx || {})"></code></div> - <div class="tx-field" x-show="txTo(selectedTransaction?.tx || {})"><span class="tx-label">To</span><code class="tx-value hash" x-text="txTo(selectedTransaction?.tx || {})"></code></div> + <div class="tx-field" x-show="!isBlindedMempoolItem(selectedTransaction?.tx)"><span class="tx-label">From</span><code class="tx-value hash" x-text="addressLabel(txFrom(selectedTransaction?.tx || {}))"></code></div> + <div class="tx-field" x-show="txTo(selectedTransaction?.tx || {})"><span class="tx-label">To</span><code class="tx-value hash" x-text="addressLabel(txTo(selectedTransaction?.tx || {}))"></code></div> <div class="tx-field" x-show="isBlindedMempoolItem(selectedTransaction?.tx) || selectedTransaction?.tx?.commitment"><span class="tx-label">Commitment</span><code class="tx-value hash" x-text="selectedTransaction?.tx?.commitment || selectedTransaction?.tx?.signature || '-'"></code></div> <div class="tx-field" x-show="selectedTransaction?.tx?.encrypted_size || selectedTransaction?.tx?.encryptedSize"><span class="tx-label">Encrypted Bytes</span><span class="tx-value number" x-text="selectedTransaction?.tx?.encrypted_size || selectedTransaction?.tx?.encryptedSize"></span></div> <div class="tx-field" x-show="selectedTransaction?.tx?.expires_at_height || selectedTransaction?.tx?.expiresAtHeight"><span class="tx-label">Expires</span><span class="tx-value number" x-text="selectedTransaction?.tx?.expires_at_height || selectedTransaction?.tx?.expiresAtHeight"></span></div> @@ -4274,7 +4397,7 @@ const INDEX_HTML: &str = r#"<!doctype html> <div class="utxo-node-label"><span>Input <span x-text="index + 1"></span></span><span>spent</span></div> <div class="utxo-node-ref" x-text="txInputOutpoint(input)"></div> <div class="tx-field"><span class="tx-label">Value</span><span class="tx-value money" x-text="txInputAmountLabel(input)"></span></div> - <div class="tx-field"><span class="tx-label">Owner</span><code class="tx-value hash" x-text="input.owner"></code></div> + <div class="tx-field"><span class="tx-label">Owner</span><code class="tx-value hash" x-text="addressLabel(input.owner)"></code></div> <div class="tx-field"><span class="tx-label">Sig</span><code class="tx-value hash" x-text="short(input.signature)"></code></div> </div> </template> @@ -4288,7 +4411,7 @@ const INDEX_HTML: &str = r#"<!doctype html> <div class="utxo-node-label"><span x-text="output.label"></span><span x-text="output.kind"></span></div> <div class="utxo-node-amount">IUNA <span x-text="amountLabel(output.amount)"></span></div> <template x-if="output.address"> - <div class="tx-field"><span class="tx-label">To</span><code class="tx-value hash" x-text="output.address"></code></div> + <div class="tx-field"><span class="tx-label">To</span><code class="tx-value hash" x-text="addressLabel(output.address)"></code></div> </template> <template x-if="output.detail"> <div class="tx-field"><span class="tx-label" x-text="output.detailLabel"></span><code class="tx-value hash" x-text="output.detail"></code></div> @@ -4300,6 +4423,52 @@ const INDEX_HTML: &str = r#"<!doctype html> </div> </section> </div> + <div class="setup-overlay transaction-overlay" x-show="addressBookModalOpen" x-transition.opacity @click.self="closeAddressBookModal()" role="dialog" aria-modal="true" aria-label="Address Book"> + <section class="tx-modal address-book-modal"> + <div class="tx-modal-head"> + <div class="tx-modal-title"> + <span class="pill">Address Book</span> + </div> + <button class="icon-button" type="button" @click="closeAddressBookModal()" title="Close" aria-label="Close"> + <svg viewBox="0 0 24 24" aria-hidden="true"><path d="M18 6 6 18"></path><path d="m6 6 12 12"></path></svg> + </button> + </div> + <form @submit.prevent="saveAddressBookEntry"> + <label>Name<input x-model="addressBookDraftName" autocomplete="off" required></label> + <label>Address<input x-model="addressBookDraftAddress" autocomplete="off" required :class="{ invalid: addressBookDraftAddress && !validAddressBookAddress(addressBookDraftAddress) }"></label> + <div class="setup-feedback error" x-show="addressBookDraftAddress && !validAddressBookAddress(addressBookDraftAddress)">Address must be a 64 character hex public key</div> + <div class="address-book-modal-actions"> + <button class="icon-button modal-delete-button" type="button" x-show="addressBookEditingAddress" @click="removeAddressBookEntry({ address: addressBookEditingAddress, name: addressBookDraftName || addressBookEditingAddress })" title="Delete contact" aria-label="Delete contact"> + <svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 7h16"></path><path d="M10 11v6"></path><path d="M14 11v6"></path><path d="M6 7l1 14h10l1-14"></path><path d="M9 7V4h6v3"></path></svg> + </button> + <button class="primary icon-button" type="submit" title="Save contact" aria-label="Save contact"> + <svg viewBox="0 0 24 24" aria-hidden="true"><path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2Z"></path><path d="M7 3v6h8"></path><path d="M7 21v-8h10v8"></path></svg> + </button> + </div> + </form> + </section> + </div> + <div class="setup-overlay transaction-overlay" x-show="addressBookPickerOpen" x-transition.opacity @click.self="closeAddressBookPicker()" role="dialog" aria-modal="true" aria-labelledby="address-book-picker-title"> + <section class="tx-modal address-book-modal"> + <div class="tx-modal-head"> + <div class="tx-modal-title"> + <span class="pill">Send</span> + <h2 id="address-book-picker-title">Choose Contact</h2> + </div> + <button class="icon-button" type="button" @click="closeAddressBookPicker()" title="Close" aria-label="Close"> + <svg viewBox="0 0 24 24" aria-hidden="true"><path d="M18 6 6 18"></path><path d="m6 6 12 12"></path></svg> + </button> + </div> + <div class="address-book-picker-list"> + <template x-for="entry in addressBookEntries()" :key="entry.address"> + <button class="address-book-picker-row" type="button" @click="selectTransferContact(entry.address)" :title="`Send to ${entry.name}`"> + <span class="address-book-name" x-text="entry.name"></span> + <code class="tx-value hash" x-text="short(entry.address)"></code> + </button> + </template> + </div> + </section> + </div> <div class="setup-overlay" x-show="showingSetup()" x-transition.opacity role="dialog" aria-modal="true" aria-labelledby="setup-title"> <section class="setup-modal"> <div class="setup-modal-head"> @@ -5037,6 +5206,115 @@ mod tests { } #[tokio::test] + async fn address_book_updates_config_file() { + let dir = tempfile::tempdir().unwrap(); + let config_path = dir.path().join("config.json"); + let state = auth_test_state( + config_path.clone(), + UiConfig { + setup_complete: true, + ..UiConfig::default() + }, + ) + .await; + let alice = Wallet::from_seed("address-book-alice"); + let bob = Wallet::from_seed("address-book-bob"); + + super::upsert_address_book_entry( + &state, + format!(" {} ", alice.address().to_ascii_uppercase()), + " Alice ".to_string(), + None, + ) + .await + .unwrap(); + let config = config_store::load_or_create(&config_path).unwrap(); + assert_eq!( + config.address_book.get(alice.address()), + Some(&"Alice".to_string()) + ); + + super::upsert_address_book_entry( + &state, + alice.address().to_string(), + "Alice Prime".to_string(), + Some(alice.address().to_string()), + ) + .await + .unwrap(); + let config = config_store::load_or_create(&config_path).unwrap(); + assert_eq!( + config.address_book.get(alice.address()), + Some(&"Alice Prime".to_string()) + ); + + let error = super::upsert_address_book_entry( + &state, + alice.address().to_string(), + "Alice Duplicate".to_string(), + None, + ) + .await + .unwrap_err(); + assert!(format!("{error:#}").contains("address is already saved")); + + let carol = Wallet::from_seed("address-book-carol"); + super::upsert_address_book_entry( + &state, + carol.address().to_string(), + "Carol".to_string(), + None, + ) + .await + .unwrap(); + let error = super::upsert_address_book_entry( + &state, + carol.address().to_string(), + "Bob As Carol".to_string(), + Some(alice.address().to_string()), + ) + .await + .unwrap_err(); + assert!(format!("{error:#}").contains("address is already saved")); + + super::upsert_address_book_entry( + &state, + bob.address().to_string(), + "Bob".to_string(), + Some(alice.address().to_string()), + ) + .await + .unwrap(); + let config = config_store::load_or_create(&config_path).unwrap(); + assert!(!config.address_book.contains_key(alice.address())); + assert_eq!( + config.address_book.get(bob.address()), + Some(&"Bob".to_string()) + ); + assert_eq!( + config.address_book.get(carol.address()), + Some(&"Carol".to_string()) + ); + + let error = super::upsert_address_book_entry( + &state, + "iuna-address".to_string(), + "Not Alice".to_string(), + None, + ) + .await + .unwrap_err(); + assert!(format!("{error:#}").contains("invalid address book address")); + + super::remove_address_book_entry(&state, bob.address().to_string()) + .await + .unwrap(); + let config = config_store::load_or_create(&config_path).unwrap(); + assert!(!config.address_book.contains_key(bob.address())); + assert!(config.address_book.contains_key(carol.address())); + } + + #[tokio::test] async fn p2p_announce_setting_persists_config_and_waits_for_public_node() { let dir = tempfile::tempdir().unwrap(); let config_path = dir.path().join("config.json"); @@ -5808,6 +6086,41 @@ mod tests { } #[test] + fn wallet_screen_includes_address_book_alias_controls() { + let app_js = include_str!("../../www/assets/iuna-ui.js"); + assert!(super::INDEX_HTML.contains("<h3>Address Book</h3>")); + assert!(super::INDEX_HTML.contains("saveAddressBookEntry")); + assert!(super::INDEX_HTML.contains("addressBookEntries()")); + assert!(super::INDEX_HTML.contains("openAddressBookModal()")); + assert!(super::INDEX_HTML.contains("addressBookModalOpen")); + assert!(super::INDEX_HTML.contains("openAddressBookPicker()")); + assert!(super::INDEX_HTML.contains("addressBookPickerOpen")); + assert!(super::INDEX_HTML.contains("selectTransferContact(entry.address)")); + assert!(super::INDEX_HTML.contains("editAddressBookEntry(entry)")); + assert!( + super::INDEX_HTML + .contains("removeAddressBookEntry({ address: addressBookEditingAddress") + ); + assert!(super::INDEX_HTML.contains("aria-label=\"Choose contact\"")); + assert!(super::INDEX_HTML.contains("aria-label=\"Delete contact\"")); + assert!(super::INDEX_HTML.contains("shortAddressLabel(tx.from)")); + assert!(super::INDEX_HTML.contains("addressLabel(input.owner)")); + assert!(app_js.contains("addressBook: {}")); + assert!(app_js.contains("addressBookVersion: 0")); + assert!(app_js.contains("addressBookPickerOpen: false")); + assert!(app_js.contains("this.config.address_book || this.config.addressBook")); + assert!(app_js.contains("options.addressBookVersion >= this.addressBookVersion")); + assert!(app_js.contains("async saveAddressBookEntry()")); + assert!(app_js.contains("Address is already saved")); + assert!(app_js.contains("validAddressBookAddress(address)")); + assert!(app_js.contains("openAddressBookPicker()")); + assert!(app_js.contains("await this.submitForm(\"/api/address-book\"")); + assert!(app_js.contains("\"/api/address-book\"")); + assert!(app_js.contains("addressLabel(address)")); + assert!(app_js.contains("shortAddressLabel(address)")); + } + + #[test] fn initial_setup_includes_node_mode_choices() { assert!(super::INDEX_HTML.contains("aria-label=\"Initial node mode\"")); assert!(super::INDEX_HTML.contains("selectSetupNodeMode('wallet')")); diff --git a/src/domain.rs b/src/domain.rs @@ -4660,7 +4660,7 @@ fn validate_genesis_burn_transaction(transaction: &Transaction) -> Result<()> { Ok(()) } -fn validate_address(address: &str, label: &str) -> Result<()> { +pub fn validate_address(address: &str, label: &str) -> Result<()> { decode_hex_array::<PUBLIC_KEY_BYTES>(address) .with_context(|| format!("invalid {label} address"))?; Ok(()) diff --git a/www/assets/iuna-ui.js b/www/assets/iuna-ui.js @@ -49,6 +49,13 @@ window.iunaApp = function iunaApp() { settingsPasswordConfirm: "", settingsFeedback: null, keepTrackOfMetrics: false, + addressBook: {}, + addressBookVersion: 0, + addressBookModalOpen: false, + addressBookPickerOpen: false, + addressBookEditingAddress: null, + addressBookDraftAddress: "", + addressBookDraftName: "", p2pAcceptInbound: false, p2pAnnounceAddr: "", p2pAnnounceDirty: false, @@ -373,10 +380,10 @@ window.iunaApp = function iunaApp() { async refreshConfig() { this.config = await this.fetchJson("/api/config"); - this.syncConfigState(); + this.syncConfigState({ addressBookVersion: this.addressBookVersion }); }, - syncConfigState() { + syncConfigState(options = {}) { this.keepTrackOfMetrics = this.config.keep_track_of_metrics === true; this.recoveryVdfTopRankPercent = Number( this.config.recovery_vdf_top_rank_percent ?? @@ -384,6 +391,12 @@ window.iunaApp = function iunaApp() { this.recoveryVdfTopRankPercent ); this.p2pAcceptInbound = this.config.p2p_accept_inbound === true; + if ( + options.addressBookVersion === undefined || + options.addressBookVersion >= this.addressBookVersion + ) { + this.addressBook = this.config.address_book || this.config.addressBook || {}; + } if (!this.p2pAnnounceDirty) { this.p2pAnnounceAddr = this.config.p2p_announce_addr || ""; } @@ -592,6 +605,7 @@ window.iunaApp = function iunaApp() { async refreshNow(options = {}) { if (!this.canUseProtectedApi()) return; + const addressBookVersion = this.addressBookVersion; try { const [config, status, blocks, p2pMetrics, blockchainMetrics, networkHealth] = await Promise.all([ this.fetchJson("/api/config"), @@ -610,7 +624,7 @@ window.iunaApp = function iunaApp() { this.refreshPagedDataset("peer", { silent: options.silent === true }), ]); this.config = config; - this.syncConfigState(); + this.syncConfigState({ addressBookVersion }); if (!this.allowedTabs().includes(this.tab)) { this.setTab("wallet"); } @@ -1407,7 +1421,7 @@ window.iunaApp = function iunaApp() { const leader = this.status.mining?.current_leader ?? this.status.chain?.next_leader; if (!leader) return "-"; if (leader === this.status.wallet_address) return "you"; - return this.short(leader); + return this.shortAddressLabel(leader); }, localMiningMempoolLabel() { @@ -1752,6 +1766,101 @@ window.iunaApp = function iunaApp() { } }, + addressBookEntries() { + return Object.entries(this.addressBook || {}) + .map(([address, name]) => ({ address, name })) + .sort((left, right) => left.name.localeCompare(right.name) || left.address.localeCompare(right.address)); + }, + + validAddressBookAddress(address) { + return /^[0-9a-fA-F]{64}$/.test(String(address ?? "").trim()); + }, + + selectTransferContact(address) { + if (!address) return; + this.transferTo = address; + this.scheduleFeeEstimates(); + this.closeAddressBookPicker(); + }, + + openAddressBookModal(entry = null) { + this.addressBookEditingAddress = entry?.address || null; + this.addressBookDraftAddress = entry?.address || ""; + this.addressBookDraftName = entry?.name || ""; + this.addressBookModalOpen = true; + }, + + closeAddressBookModal() { + this.addressBookModalOpen = false; + this.addressBookEditingAddress = null; + this.addressBookDraftAddress = ""; + this.addressBookDraftName = ""; + }, + + openAddressBookPicker() { + if (this.addressBookEntries().length === 0) { + this.showFlash("No contacts saved yet", "error"); + return; + } + this.addressBookPickerOpen = true; + }, + + closeAddressBookPicker() { + this.addressBookPickerOpen = false; + }, + + async saveAddressBookEntry() { + const address = this.addressBookDraftAddress.trim().toLowerCase(); + const name = this.addressBookDraftName.trim(); + if (!address || !name) { + this.showFlash("Address and name are required", "error"); + return; + } + if (!this.validAddressBookAddress(address)) { + this.showFlash("Address must be a 64 character hex public key", "error"); + return; + } + const oldAddress = this.addressBookEditingAddress; + if (this.addressBook?.[address] && address !== oldAddress) { + this.showFlash("Address is already saved", "error"); + return; + } + try { + const fields = oldAddress ? { address, name, old_address: oldAddress } : { address, name }; + await this.submitForm("/api/address-book", fields); + this.addressBookVersion += 1; + const nextBook = { ...(this.addressBook || {}) }; + if (oldAddress && oldAddress !== address) delete nextBook[oldAddress]; + nextBook[address] = name; + this.addressBook = nextBook; + this.config = { ...this.config, address_book: this.addressBook }; + this.closeAddressBookModal(); + this.showFlash(`Saved ${name}`, "success"); + } catch (error) { + this.showFlash(error.message, "error"); + } + }, + + editAddressBookEntry(entry) { + this.openAddressBookModal(entry); + }, + + async removeAddressBookEntry(entry) { + try { + await this.submitForm("/api/address-book", { address: entry.address }, "DELETE"); + this.addressBookVersion += 1; + const nextBook = { ...(this.addressBook || {}) }; + delete nextBook[entry.address]; + this.addressBook = nextBook; + this.config = { ...this.config, address_book: nextBook }; + if (this.addressBookEditingAddress === entry.address) this.closeAddressBookModal(); + if (this.addressBookEntries().length === 0) this.closeAddressBookPicker(); + this.showFlash(`Removed ${entry.name}`, "success"); + } catch (error) { + this.showFlash(error.message, "error"); + } + }, + async copyAddress() { try { await navigator.clipboard.writeText(this.setupAddress()); @@ -1790,6 +1899,19 @@ window.iunaApp = function iunaApp() { return `${value.slice(0, 8)}...${value.slice(-8)}`; }, + addressName(address) { + if (!address) return null; + return this.addressBook?.[address] || null; + }, + + addressLabel(address) { + return this.addressName(address) || address || "-"; + }, + + shortAddressLabel(address) { + return this.addressName(address) || this.short(address); + }, + txFrom(tx) { return tx.from ?? tx.inputs?.[0]?.owner ?? ""; }, @@ -1956,7 +2078,8 @@ window.iunaApp = function iunaApp() { txFeeRecipient(tx) { const context = this.selectedTransaction?.context || {}; - return tx.blockFinalizer ?? tx.blockMiner ?? context.blockFinalizer ?? context.blockMiner ?? "future block finalizer"; + const address = tx.blockFinalizer ?? tx.blockMiner ?? context.blockFinalizer ?? context.blockMiner; + return address ? this.addressLabel(address) : "future block finalizer"; }, selectedTransactionLabel() { @@ -2075,7 +2198,7 @@ window.iunaApp = function iunaApp() { }, blockFinalizerLabel(block) { - const finalizer = this.short(block.miner); + const finalizer = this.shortAddressLabel(block.miner); const owner = block.miner === this.status.wallet_address ? `${finalizer} (me)` : finalizer; return block.finalizer_mode === "recovery" ? `${owner} · Recovery` : owner; },