commit 6c465040423a2ca22e89abd4c194e9ab58a394de
parent 6c15f2d507f6ea3b73a01e61d6f7c314f258cbcc
Author: Joris Hartog <jorishartog@hotmail.com>
Date: Sun, 9 Aug 2026 21:49:45 +0200
Add wallet address book
Diffstat:
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;
},