commit d5c2ec9da29b51b29e11428642c6d9d54d3a3400
parent f49110a8233a86fb588be2f5fb713db81dbfa0fe
Author: Joris Hartog <jorishartog@hotmail.com>
Date: Sat, 29 Aug 2026 20:25:24 +0200
Add checksummed network addresses
Diffstat:
16 files changed, 556 insertions(+), 38 deletions(-)
diff --git a/README.md b/README.md
@@ -223,7 +223,11 @@ iuna can expose a Stratum V1 endpoint for SHA-256 ASIC miners such as a Bitaxe:
./iuna --stratum <bind-address>:<port>
```
-Use your iuna wallet address as the worker username. Accepted shares become PoW mine actions in the node mempool and are gossiped to peers.
+Use the checksummed Bech32m address shown by your iuna wallet as the worker
+username (`iuna1...` on mainnet/mainnet-candidate or `tiuna1...` on local
+testnet). Hex public keys and addresses for another network are rejected.
+Accepted shares become PoW mine actions in the node mempool and are gossiped to
+peers.
## Operator Docs
diff --git a/docs/operator-playbooks.md b/docs/operator-playbooks.md
@@ -34,6 +34,27 @@ iuna --help
The process prints the wallet file, config file, chain database, management UI, and P2P listener on startup.
+## Legacy address-book migration
+
+The upgraded wallet shows checksummed Bech32m receive addresses: `iuna1...` for
+mainnet/mainnet-candidate and `tiuna1...` for local testnet. Transfer,
+address-book, and Stratum input no longer accepts a 64-character hex public key.
+This is intentional: silently accepting hex would bypass typo and
+wrong-network protection.
+
+Before genesis or before reusing an old contact, ask the recipient to copy a new
+address from their upgraded wallet and replace the old address-book entry.
+Never change `iuna` to `tiuna` (or the reverse) by hand; the checksum covers the
+prefix. Existing wallet files and chain databases retain their internal hex
+keys and must not be rewritten. Controlled migration tools may call the domain
+`migrate_legacy_address` function only when the target network is explicitly
+known.
+
+No block-height activation is needed because on-chain and P2P address bytes do
+not change. Treat this as a client compatibility rollout instead: deploy the
+wallet UI and backend together, have recipients publish their new display
+address, and update Stratum worker usernames before restarting miners.
+
## Stalled Height
Symptoms:
diff --git a/docs/protocol.md b/docs/protocol.md
@@ -57,6 +57,41 @@ The compact representation stores binary hashes, addresses, signatures, and VDF
P2P messages and management API responses still use JSON. Their byte length is not the consensus block size. Transaction fee-rate ordering uses a separate compact economic transaction weight, so changing JSON whitespace, key order, or hexadecimal formatting cannot change consensus size or fee priority.
+## Wallet address format
+
+Wallets display and accept version `0` Bech32m addresses. Mainnet and the
+mainnet-candidate use the human-readable prefix `iuna`; local testnet uses
+`tiuna`. The payload is one 5-bit version value followed by the wallet's exact
+32-byte Ed25519 verifying key converted from 8-bit to 5-bit groups. Decoding
+must verify the Bech32m constant, reject mixed case and non-zero padding, require
+the expected network prefix and version, require exactly 32 payload bytes, and
+parse those bytes as a non-weak Ed25519 verifying key. An all-uppercase address
+is accepted and normalized to lowercase; mixed uppercase/lowercase is invalid.
+
+Consensus objects and persisted chainstate continue to store the same public
+key as canonical 64-character lowercase hexadecimal. The management wallet,
+address book, transfer endpoints, and Stratum worker usernames decode Bech32m
+to that internal key before constructing a transaction. This boundary keeps the
+checksum and network distinction out of consensus identity while avoiding any
+rewrite of genesis allocation outpoints, historical UTXOs, signatures, or block
+hashes.
+
+Legacy hexadecimal addresses are therefore not valid recipient or address-book
+input. Before mainnet genesis, contacts must replace them with an address newly
+copied from the owner's upgraded wallet. The code exposes an explicit
+`migrate_legacy_address` conversion for controlled migration tooling, but no UI
+silently guesses a network or upgrades an entered hex string. Wallet files keep
+their internal hex public key and need no seed or file migration.
+
+This address rollout does not require a consensus activation height: decoded
+transactions contain the same internal public-key bytes and therefore produce
+the same signatures, transaction IDs, blocks, and UTXO identities understood by
+older nodes. It does require a coordinated client rollout. Older wallets expose
+hex receive values that upgraded send forms reject, and existing Stratum miners
+must replace their hex worker username with the Bech32m form before connecting
+to an upgraded node. Wallet UI, backend, and Stratum configuration should be
+upgraded together.
+
## Coins and Transactions
iuna uses a UTXO-style ledger. The main transaction types are:
diff --git a/src/adapters/http.rs b/src/adapters/http.rs
@@ -21,7 +21,6 @@ use tokio::{net::TcpListener, sync::Mutex};
use crate::{
adapters::{config_store, config_store::UiConfig, p2p::GossipNetwork},
app::{SharedNode, SharedPeerBook},
- domain::validate_address,
};
mod actions;
diff --git a/src/adapters/http/actions.rs b/src/adapters/http/actions.rs
@@ -18,7 +18,7 @@ use super::types::{
use super::{
HttpState, action_json, api_error, config_store, estimate_burn_fee, estimate_mine_fee,
estimate_transfer_fee, fee_estimate_json, required_fee_per_byte_burn, transfer,
- validate_address, wallet_setup_json,
+ wallet_setup_json,
};
use crate::{
adapters::{
@@ -455,9 +455,14 @@ pub(super) async fn upsert_address_book_entry(
name: String,
old_address: Option<String>,
) -> Result<()> {
- let address = validate_address_book_address(address)?;
+ let address = validate_address_book_address(state, address).await?;
let name = validate_address_book_name(name)?;
- let old_address = old_address.map(validate_address_book_address).transpose()?;
+ let old_address = match old_address {
+ Some(old_address) => {
+ Some(validate_existing_address_book_address(state, old_address).await?)
+ }
+ None => None,
+ };
let mut config = state.ui_config.lock().await;
if let Some(old_address) = old_address.as_deref() {
if old_address != address {
@@ -474,7 +479,7 @@ pub(super) async fn upsert_address_book_entry(
}
pub(super) async fn remove_address_book_entry(state: &HttpState, address: String) -> Result<()> {
- let address = validate_address_book_address(address)?;
+ let address = validate_existing_address_book_address(state, address).await?;
let mut config = state.ui_config.lock().await;
config.address_book.remove(&address);
config_store::save(&state.config_path, &config)
@@ -488,12 +493,38 @@ fn validate_peer_address(peer: String) -> Result<String> {
Ok(peer)
}
-fn validate_address_book_address(address: String) -> Result<String> {
+async fn validate_address_book_address(state: &HttpState, address: String) -> Result<String> {
+ let address = address.trim().to_string();
+ if address.is_empty() {
+ bail!("address is required");
+ }
+ state
+ .node
+ .lock()
+ .await
+ .normalize_user_address(&address)
+ .context("invalid address book address")?;
+ Ok(address.to_ascii_lowercase())
+}
+
+async fn validate_existing_address_book_address(
+ state: &HttpState,
+ address: String,
+) -> Result<String> {
let address = address.trim().to_string();
if address.is_empty() {
bail!("address is required");
}
- validate_address(&address, "address book")?;
+ if state
+ .node
+ .lock()
+ .await
+ .normalize_user_address(&address)
+ .is_err()
+ {
+ crate::domain::validate_address(&address, "legacy address book")
+ .context("invalid existing address book address")?;
+ }
Ok(address.to_ascii_lowercase())
}
diff --git a/src/adapters/http/index_html.rs b/src/adapters/http/index_html.rs
@@ -616,6 +616,7 @@ pub(super) const INDEX_HTML: &str = concat!(
<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>
+ <div class="fee-preview" x-show="transferTo.trim()">Verify recipient: <code x-text="transferTo.trim()"></code></div>
<div class="amount-field">
<label>Amount<input x-model="transferAmount" @input="scheduleFeeEstimates" type="number" min="0.000001" step="0.000001" required></label>
<button class="amount-max-button" type="button" @click="setMaxTransferAmount" :disabled="transferMaxDisabled()" title="Use maximum spendable amount">Max</button>
@@ -665,7 +666,7 @@ pub(super) const INDEX_HTML: &str = concat!(
</div>
<div class="receive-address">
<div class="muted">Public key / address</div>
- <div class="address-box"><code x-text="status.wallet_address || '-'"></code></div>
+ <div class="address-box"><code x-text="setupAddress()"></code></div>
</div>
</div>
<div class="panel">
@@ -1634,7 +1635,7 @@ pub(super) const INDEX_HTML: &str = concat!(
<form class="address-book-form" x-show="addressBookModalOpen" @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="setup-feedback error" x-show="addressBookDraftAddress && !validAddressBookAddress(addressBookDraftAddress)">Address must be a Bech32m address for this network</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>
diff --git a/src/adapters/http/wallet.rs b/src/adapters/http/wallet.rs
@@ -32,7 +32,7 @@ pub(super) async fn wallet_setup_response(
} else {
wallet_store::setup_seed_phrase_with_password(&state.wallet_path, password.as_deref())?
};
- let address = state.node.lock().await.wallet_address().to_string();
+ let address = state.node.lock().await.wallet_receive_address()?;
Ok(WalletSetupResponse {
ok: true,
error: None,
@@ -57,8 +57,8 @@ pub(super) async fn replace_setup_wallet_with_generated_seed(
.context("wallet password session is required")?;
let (wallet, seed_phrase) =
wallet_store::replace_with_generated_seed_phrase_encrypted(&state.wallet_path, &password)?;
- let address = wallet.address().to_string();
state.node.lock().await.replace_wallet(wallet);
+ let address = state.node.lock().await.wallet_receive_address()?;
Ok(WalletSetupResponse {
ok: true,
error: None,
@@ -83,8 +83,8 @@ pub(super) async fn import_setup_wallet_seed(
seed_phrase,
&password,
)?;
- let address = wallet.address().to_string();
state.node.lock().await.replace_wallet(wallet);
+ let address = state.node.lock().await.wallet_receive_address()?;
Ok(WalletSetupResponse {
ok: true,
error: None,
@@ -131,6 +131,7 @@ pub(super) async fn transfer(state: &HttpState, form: TransferForm) -> Result<()
let result = {
let mut node = state.node.lock().await;
+ let to = node.normalize_user_address(&to)?;
let result = node.transfer_with_fee_rate(to, amount, fee_per_byte, &selected_utxos);
let outbox = node.drain_outbox();
(result, outbox)
@@ -169,11 +170,9 @@ pub(super) async fn estimate_transfer_fee(
form: TransferForm,
) -> Result<FeeEstimate> {
let (to, amount, fee_per_byte, selected_utxos) = validate_transfer_form(form)?;
- state
- .node
- .lock()
- .await
- .estimate_transfer_fee(to, amount, fee_per_byte, &selected_utxos)
+ let node = state.node.lock().await;
+ let to = node.normalize_user_address(&to)?;
+ node.estimate_transfer_fee(to, amount, fee_per_byte, &selected_utxos)
}
pub(super) async fn estimate_burn_fee(
diff --git a/src/adapters/stratum.rs b/src/adapters/stratum.rs
@@ -234,6 +234,7 @@ impl StratumSession {
.and_then(Value::as_str)
.context("mining.authorize requires worker address")?
.to_string();
+ self.normalized_worker_recipient(&worker).await?;
self.authorized_worker = Some(worker.clone());
self.send_response(id, json!(true)).await?;
self.send_job(&worker, true).await?;
@@ -267,12 +268,13 @@ impl StratumSession {
let job_id = self.next_job_id.to_string();
self.next_job_id = self.next_job_id.saturating_add(1);
let salt = self.server.next_job_salt.fetch_add(1, Ordering::Relaxed);
+ let recipient = self.normalized_worker_recipient(worker).await?;
let mine = self
.server
.node
.lock()
.await
- .external_mine_job(recipient_from_worker(worker), salt)?;
+ .external_mine_job(recipient, salt)?;
let difficulty = stratum_difficulty_for_bits(mine.template.difficulty_bits);
self.send_notification("mining.set_difficulty", json!([difficulty]))
.await?;
@@ -323,8 +325,9 @@ impl StratumSession {
let (result, outbox) = {
let mut node = self.server.node.lock().await;
+ let recipient = node.normalize_user_address(recipient_from_worker(worker))?;
let result = node.submit_external_mine(
- recipient_from_worker(worker),
+ recipient,
job.mine.template.clone(),
StratumMineShare {
extranonce2,
@@ -345,6 +348,15 @@ impl StratumSession {
}
}
+ async fn normalized_worker_recipient(&self, worker: &str) -> Result<String> {
+ self.server
+ .node
+ .lock()
+ .await
+ .normalize_user_address(recipient_from_worker(worker))
+ .context("invalid Stratum worker address")
+ }
+
async fn send_response(&self, id: Value, result: Value) -> Result<()> {
self.send(json!({ "id": id, "result": result, "error": null }))
.await
diff --git a/src/app/node_lifecycle.rs b/src/app/node_lifecycle.rs
@@ -1,8 +1,13 @@
use std::collections::{BTreeMap, BTreeSet};
+use anyhow::Result;
+
use crate::{
adapters::config_store::{DEFAULT_POW_MINING_WORKERS, clamp_pow_mining_workers},
- domain::{Amount, BurnBundle, DEFAULT_FEE_PER_BYTE, Ledger, Wallet},
+ domain::{
+ AddressNetwork, Amount, BurnBundle, DEFAULT_FEE_PER_BYTE, Ledger, Wallet, decode_address,
+ encode_address,
+ },
};
use super::{GossipEnvelope, NodeConfig, NodeCore, NodeWallet};
@@ -127,6 +132,18 @@ impl NodeCore {
self.wallet.address()
}
+ pub fn wallet_receive_address(&self) -> Result<String> {
+ encode_address(self.wallet.address(), self.address_network())
+ }
+
+ pub fn normalize_user_address(&self, address: &str) -> Result<String> {
+ decode_address(address, self.address_network())
+ }
+
+ fn address_network(&self) -> AddressNetwork {
+ AddressNetwork::from_profile_id(&self.ledger.launch_profile().profile_id)
+ }
+
pub fn wallet_is_locked(&self) -> bool {
self.wallet.is_locked()
}
@@ -141,7 +158,14 @@ impl NodeCore {
}
pub fn reset_chain_to_setup_placeholder(&mut self) {
- self.ledger = Ledger::new(BTreeMap::new(), 1);
+ let launch_profile = self.ledger.launch_profile().clone();
+ self.ledger = Ledger::new_with_genesis_burns_and_profile(
+ BTreeMap::new(),
+ Vec::new(),
+ 1,
+ launch_profile,
+ )
+ .expect("empty setup ledger is valid");
self.reset_automatic_mining_progress();
self.burn_bundles.clear();
self.equivocated_burn_bundle_slots.clear();
diff --git a/src/app/status.rs b/src/app/status.rs
@@ -24,6 +24,7 @@ impl NodeCore {
NodeStatus {
app_version: env!("CARGO_PKG_VERSION").to_string(),
wallet_address: self.wallet.address().to_string(),
+ wallet_receive_address: self.wallet_receive_address().unwrap_or_default(),
wallet_balance: self.wallet_projected_balance(),
wallet_locked: self.wallet.is_locked(),
launch_profile: LaunchProfileStatus {
@@ -168,7 +169,7 @@ mod tests {
use crate::{
app::{NodeConfig, NodeCore},
- domain::{Ledger, Wallet},
+ domain::{LaunchProfile, Ledger, Wallet},
};
#[test]
@@ -184,7 +185,55 @@ mod tests {
recovery_vdf_top_rank_percent: 100,
});
- assert_eq!(node.status().app_version, env!("CARGO_PKG_VERSION"));
+ let status = node.status();
+ assert_eq!(status.app_version, env!("CARGO_PKG_VERSION"));
+ assert!(status.wallet_receive_address.starts_with("iuna1q"));
+ }
+
+ #[test]
+ fn local_testnet_status_uses_a_distinct_receive_address_prefix() {
+ let wallet = Wallet::from_seed("status-testnet-wallet");
+ let ledger = Ledger::new_with_genesis_burns_and_profile(
+ BTreeMap::new(),
+ Vec::new(),
+ 1,
+ LaunchProfile::local_testnet(),
+ )
+ .unwrap();
+ let node = NodeCore::from_ledger(wallet, ledger, 0);
+ let status = node.status();
+
+ assert!(status.wallet_receive_address.starts_with("tiuna1q"));
+ assert!(
+ node.normalize_user_address(&status.wallet_receive_address)
+ .is_ok()
+ );
+ let wrong_network = status.wallet_receive_address.replacen("tiuna", "iuna", 1);
+ assert!(node.normalize_user_address(&wrong_network).is_err());
+
+ let receive_address = status.wallet_receive_address;
+ let mut reset_node = node;
+ reset_node.reset_chain_to_setup_placeholder();
+ assert_eq!(
+ reset_node.wallet_receive_address().unwrap(),
+ receive_address
+ );
+ }
+
+ #[test]
+ fn status_does_not_panic_on_invalid_locked_wallet_metadata() {
+ let node = NodeCore::from_locked_wallet_address(
+ "corrupt-wallet-address",
+ Ledger::new(BTreeMap::new(), 1),
+ false,
+ 0,
+ 0,
+ );
+
+ let status = node.status();
+
+ assert_eq!(status.wallet_address, "corrupt-wallet-address");
+ assert!(status.wallet_receive_address.is_empty());
}
#[test]
diff --git a/src/app/types.rs b/src/app/types.rs
@@ -126,6 +126,7 @@ pub struct BlockInventory {
pub struct NodeStatus {
pub app_version: String,
pub wallet_address: String,
+ pub wallet_receive_address: String,
pub wallet_balance: Amount,
pub wallet_locked: bool,
pub launch_profile: LaunchProfileStatus,
diff --git a/src/domain.rs b/src/domain.rs
@@ -1,5 +1,6 @@
use std::collections::BTreeSet;
+mod address;
#[cfg(test)]
mod adversarial_tests;
mod block;
@@ -30,6 +31,7 @@ mod transaction;
mod validation;
mod vdf;
mod wallet;
+pub use address::{AddressNetwork, decode_address, encode_address, migrate_legacy_address};
use block::LeaderProofPayload;
pub use block::{
Block, BurnLeaderRank, ChainSnapshot, ChainStatus, FinalizerMode, LeaderProof, PreparedBlock,
diff --git a/src/domain/address.rs b/src/domain/address.rs
@@ -0,0 +1,306 @@
+use anyhow::{Context, Result, bail};
+use ed25519_dalek::VerifyingKey;
+
+use super::{PUBLIC_KEY_BYTES, decode_hex_array, hex_encode};
+
+const ADDRESS_VERSION: u8 = 0;
+const BECH32M_CONST: u32 = 0x2bc8_30a3;
+const BECH32_CHARSET: &[u8; 32] = b"qpzry9x8gf2tvdw0s3jn54khce6mua7l";
+const MAX_BECH32_LENGTH: usize = 90;
+
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
+pub enum AddressNetwork {
+ Mainnet,
+ Testnet,
+}
+
+impl AddressNetwork {
+ pub fn from_profile_id(profile_id: &str) -> Self {
+ if profile_id == "iuna-local-testnet-v1" {
+ Self::Testnet
+ } else {
+ Self::Mainnet
+ }
+ }
+
+ fn hrp(self) -> &'static str {
+ match self {
+ Self::Mainnet => "iuna",
+ Self::Testnet => "tiuna",
+ }
+ }
+}
+
+/// Encodes the internal canonical Ed25519 public-key representation for display.
+pub fn encode_address(public_key_hex: &str, network: AddressNetwork) -> Result<String> {
+ let public_key = decode_hex_array::<PUBLIC_KEY_BYTES>(public_key_hex)
+ .context("address public key must be 32-byte hexadecimal")?;
+ validate_public_key(&public_key)?;
+
+ let mut data = vec![ADDRESS_VERSION];
+ data.extend(convert_bits(&public_key, 8, 5, true)?);
+ let checksum = create_checksum(network.hrp(), &data);
+ let mut encoded = String::with_capacity(network.hrp().len() + 1 + data.len() + 6);
+ encoded.push_str(network.hrp());
+ encoded.push('1');
+ for value in data.into_iter().chain(checksum) {
+ encoded.push(char::from(BECH32_CHARSET[usize::from(value)]));
+ }
+ Ok(encoded)
+}
+
+/// Decodes a user-facing Bech32m address to the internal canonical public-key hex.
+///
+/// Legacy hexadecimal addresses are deliberately not accepted here. They remain
+/// valid only inside existing consensus data and wallet files.
+pub fn decode_address(address: &str, expected_network: AddressNetwork) -> Result<String> {
+ let address = address.trim();
+ if address.is_empty() {
+ bail!("address is required");
+ }
+ if address.len() > MAX_BECH32_LENGTH || !address.is_ascii() {
+ bail!("address is not valid Bech32m");
+ }
+ let has_lower = address.bytes().any(|byte| byte.is_ascii_lowercase());
+ let has_upper = address.bytes().any(|byte| byte.is_ascii_uppercase());
+ if has_lower && has_upper {
+ bail!("Bech32m address must not mix uppercase and lowercase");
+ }
+ let normalized = address.to_ascii_lowercase();
+ let separator = normalized
+ .rfind('1')
+ .context("address is missing the Bech32m separator")?;
+ if separator == 0 || separator + 7 > normalized.len() {
+ bail!("address has an invalid Bech32m structure");
+ }
+ let hrp = &normalized[..separator];
+ if hrp != expected_network.hrp() {
+ bail!(
+ "address belongs to {} instead of {}",
+ network_label_for_hrp(hrp),
+ network_label(expected_network)
+ );
+ }
+ let data = normalized[separator + 1..]
+ .bytes()
+ .map(decode_charset)
+ .collect::<Result<Vec<_>>>()?;
+ if !verify_checksum(hrp, &data) {
+ bail!("address checksum is invalid");
+ }
+ let payload = &data[..data.len() - 6];
+ let Some((&version, encoded_key)) = payload.split_first() else {
+ bail!("address payload is empty");
+ };
+ if version != ADDRESS_VERSION {
+ bail!("unsupported address version {version}");
+ }
+ let public_key = convert_bits(encoded_key, 5, 8, false)?;
+ let public_key: [u8; PUBLIC_KEY_BYTES] = public_key.try_into().map_err(|bytes: Vec<u8>| {
+ anyhow::anyhow!("address public key has {} bytes", bytes.len())
+ })?;
+ validate_public_key(&public_key)?;
+ Ok(hex_encode(public_key))
+}
+
+/// Converts a pre-mainnet hex address for one-time display/migration tooling.
+pub fn migrate_legacy_address(address: &str, network: AddressNetwork) -> Result<String> {
+ encode_address(&address.to_ascii_lowercase(), network)
+}
+
+fn validate_public_key(public_key: &[u8; PUBLIC_KEY_BYTES]) -> Result<()> {
+ let verifying_key = VerifyingKey::from_bytes(public_key)
+ .context("address payload is not a valid Ed25519 verifying key")?;
+ if verifying_key.is_weak() {
+ bail!("address payload contains a weak Ed25519 verifying key");
+ }
+ Ok(())
+}
+
+fn network_label(network: AddressNetwork) -> &'static str {
+ match network {
+ AddressNetwork::Mainnet => "mainnet",
+ AddressNetwork::Testnet => "testnet",
+ }
+}
+
+fn network_label_for_hrp(hrp: &str) -> &'static str {
+ match hrp {
+ "iuna" => "mainnet",
+ "tiuna" => "testnet",
+ _ => "an unknown network",
+ }
+}
+
+fn decode_charset(byte: u8) -> Result<u8> {
+ BECH32_CHARSET
+ .iter()
+ .position(|candidate| *candidate == byte)
+ .map(|index| index as u8)
+ .context("address contains a character outside the Bech32 alphabet")
+}
+
+fn create_checksum(hrp: &str, data: &[u8]) -> [u8; 6] {
+ let mut values = hrp_expand(hrp);
+ values.extend_from_slice(data);
+ values.extend_from_slice(&[0; 6]);
+ let polymod = polymod(&values) ^ BECH32M_CONST;
+ std::array::from_fn(|index| ((polymod >> (5 * (5 - index))) & 31) as u8)
+}
+
+fn verify_checksum(hrp: &str, data: &[u8]) -> bool {
+ let mut values = hrp_expand(hrp);
+ values.extend_from_slice(data);
+ polymod(&values) == BECH32M_CONST
+}
+
+fn hrp_expand(hrp: &str) -> Vec<u8> {
+ let mut expanded = Vec::with_capacity(hrp.len() * 2 + 1);
+ expanded.extend(hrp.bytes().map(|byte| byte >> 5));
+ expanded.push(0);
+ expanded.extend(hrp.bytes().map(|byte| byte & 31));
+ expanded
+}
+
+fn polymod(values: &[u8]) -> u32 {
+ const GENERATORS: [u32; 5] = [
+ 0x3b6a_57b2,
+ 0x2650_8e6d,
+ 0x1ea1_19fa,
+ 0x3d42_33dd,
+ 0x2a14_62b3,
+ ];
+ let mut checksum = 1_u32;
+ for value in values {
+ let top = checksum >> 25;
+ checksum = (checksum & 0x01ff_ffff) << 5 ^ u32::from(*value);
+ for (index, generator) in GENERATORS.iter().enumerate() {
+ if (top >> index) & 1 != 0 {
+ checksum ^= generator;
+ }
+ }
+ }
+ checksum
+}
+
+fn convert_bits(data: &[u8], from: u8, to: u8, pad: bool) -> Result<Vec<u8>> {
+ let mut accumulator = 0_u32;
+ let mut bit_count = 0_u8;
+ let max_value = (1_u32 << to) - 1;
+ let max_accumulator = (1_u32 << (from + to - 1)) - 1;
+ let mut converted = Vec::new();
+ for value in data {
+ if u32::from(*value) >> from != 0 {
+ bail!("address payload contains an out-of-range value");
+ }
+ accumulator = ((accumulator << from) | u32::from(*value)) & max_accumulator;
+ bit_count += from;
+ while bit_count >= to {
+ bit_count -= to;
+ converted.push(((accumulator >> bit_count) & max_value) as u8);
+ }
+ }
+ if pad {
+ if bit_count > 0 {
+ converted.push(((accumulator << (to - bit_count)) & max_value) as u8);
+ }
+ } else if bit_count >= from || ((accumulator << (to - bit_count)) & max_value) != 0 {
+ bail!("address payload has invalid padding");
+ }
+ Ok(converted)
+}
+
+#[cfg(test)]
+mod tests {
+ use super::{
+ AddressNetwork, decode_address, decode_charset, encode_address, migrate_legacy_address,
+ verify_checksum,
+ };
+ use crate::domain::Wallet;
+
+ fn wallet_key() -> String {
+ Wallet::from_seed("address-format-test")
+ .address()
+ .to_string()
+ }
+
+ #[test]
+ fn checksum_matches_bip350_bech32m_vectors() {
+ let valid = "lqfn3a"
+ .bytes()
+ .map(decode_charset)
+ .collect::<anyhow::Result<Vec<_>>>()
+ .unwrap();
+ let old_bech32 = "g7sgd8"
+ .bytes()
+ .map(decode_charset)
+ .collect::<anyhow::Result<Vec<_>>>()
+ .unwrap();
+
+ assert!(verify_checksum("a", &valid)); // BIP-350: A1LQFN3A
+ assert!(!verify_checksum("a", &old_bech32)); // Bech32, not Bech32m
+ }
+
+ #[test]
+ fn mainnet_and_testnet_addresses_roundtrip_to_the_same_key() {
+ let key = wallet_key();
+ let mainnet = encode_address(&key, AddressNetwork::Mainnet).unwrap();
+ let testnet = encode_address(&key, AddressNetwork::Testnet).unwrap();
+
+ assert!(mainnet.starts_with("iuna1q"));
+ assert!(testnet.starts_with("tiuna1q"));
+ assert_eq!(
+ decode_address(&mainnet, AddressNetwork::Mainnet).unwrap(),
+ key
+ );
+ assert_eq!(
+ decode_address(&testnet, AddressNetwork::Testnet).unwrap(),
+ key
+ );
+ assert_ne!(mainnet, testnet);
+ }
+
+ #[test]
+ fn checksum_typos_and_network_mixups_are_rejected() {
+ let key = wallet_key();
+ let mainnet = encode_address(&key, AddressNetwork::Mainnet).unwrap();
+ let mut typo = mainnet.clone();
+ let replacement = if typo.ends_with('q') { 'p' } else { 'q' };
+ typo.pop();
+ typo.push(replacement);
+
+ assert!(decode_address(&typo, AddressNetwork::Mainnet).is_err());
+ assert!(
+ decode_address(&mainnet, AddressNetwork::Testnet)
+ .unwrap_err()
+ .to_string()
+ .contains("instead of testnet")
+ );
+ }
+
+ #[test]
+ fn uppercase_is_accepted_but_mixed_case_is_rejected() {
+ let key = wallet_key();
+ let address = encode_address(&key, AddressNetwork::Mainnet).unwrap();
+
+ assert_eq!(
+ decode_address(&address.to_ascii_uppercase(), AddressNetwork::Mainnet).unwrap(),
+ key
+ );
+ let mut mixed = address;
+ mixed.replace_range(..1, "I");
+ assert!(decode_address(&mixed, AddressNetwork::Mainnet).is_err());
+ }
+
+ #[test]
+ fn malformed_keys_hex_and_legacy_user_input_are_rejected() {
+ let invalid_key = "00".repeat(32);
+ assert!(encode_address(&invalid_key, AddressNetwork::Mainnet).is_err());
+ assert!(decode_address(&wallet_key(), AddressNetwork::Mainnet).is_err());
+ assert!(
+ migrate_legacy_address(&wallet_key(), AddressNetwork::Mainnet)
+ .unwrap()
+ .starts_with("iuna1q")
+ );
+ }
+}
diff --git a/src/main.rs b/src/main.rs
@@ -503,7 +503,7 @@ async fn initialize_ledger(
};
if snapshot.launch_profile.profile_id != expected_profile.profile_id {
return Ok(InitializedLedger {
- ledger: setup_ledger(),
+ ledger: setup_ledger(local_testnet),
migration_from: Some(snapshot.launch_profile.profile_id),
});
}
@@ -524,7 +524,7 @@ async fn initialize_ledger(
})
} else {
let ledger = match opts.chain_mode {
- ChainMode::Setup => Ok(setup_ledger()),
+ ChainMode::Setup => Ok(setup_ledger(local_testnet)),
ChainMode::Genesis => start_genesis_ledger(wallet_address, local_testnet),
ChainMode::Join => join_chain_ledger(&opts.join_peers, advertised_p2p_addr).await,
}?;
@@ -563,8 +563,18 @@ fn snapshot_height(snapshot: &ChainSnapshot) -> u64 {
.unwrap_or(0)
}
-fn setup_ledger() -> Ledger {
- Ledger::new(BTreeMap::new(), 1)
+fn setup_ledger(local_testnet: bool) -> Ledger {
+ if local_testnet {
+ Ledger::new_with_genesis_burns_and_profile(
+ BTreeMap::new(),
+ Vec::new(),
+ 1,
+ LaunchProfile::local_testnet(),
+ )
+ .expect("empty local-testnet setup ledger is valid")
+ } else {
+ Ledger::new(BTreeMap::new(), 1)
+ }
}
fn start_genesis_ledger(wallet_address: &str, local_testnet: bool) -> Result<Ledger> {
diff --git a/src/main_tests.rs b/src/main_tests.rs
@@ -24,7 +24,8 @@ use super::{
initial_burn_per_block, initialize_ledger, load_startup_wallet, measure_vdf_rounds,
parse_startup_bool_env_value, parse_startup_pow_mining_workers_env_value,
persist_chain_snapshot, project_ui_data_store, run_chain_persistence_with_interval,
- should_defer_sync_checkpoint, should_log_automatic_finalization_skip, validate_wallet_for_mode,
+ setup_ledger, should_defer_sync_checkpoint, should_log_automatic_finalization_skip,
+ validate_wallet_for_mode,
};
fn parse(args: &[&str]) -> anyhow::Result<Option<CliOptions>> {
@@ -406,6 +407,18 @@ fn no_args_starts_setup_mode() {
}
#[test]
+fn setup_ledger_uses_the_requested_address_network_profile() {
+ assert_eq!(
+ setup_ledger(false).launch_profile().profile_id,
+ "iuna-mainnet-candidate"
+ );
+ assert_eq!(
+ setup_ledger(true).launch_profile().profile_id,
+ "iuna-local-testnet-v1"
+ );
+}
+
+#[test]
fn stratum_port_can_be_configured() {
let opts = parse(&["--stratum", "127.0.0.1:3333"]).unwrap().unwrap();
assert_eq!(opts.stratum_addr, Some("127.0.0.1:3333".parse().unwrap()));
diff --git a/www/assets/iuna-ui.js b/www/assets/iuna-ui.js
@@ -505,7 +505,7 @@ window.iunaApp = function iunaApp() {
},
setupAddress() {
- return this.setupWallet.address || this.status.wallet_address || "-";
+ return this.setupWallet.address || this.status.wallet_receive_address || "-";
},
selectSetupWalletMode(mode) {
@@ -2365,10 +2365,14 @@ window.iunaApp = function iunaApp() {
try {
const amount = this.parseiunaAmount(this.transferAmount);
const fee = this.parseiunaAmountRequired(this.transferFee, "Transfer fee per byte is required");
- const recipient = this.short(this.transferTo);
+ const fullRecipient = this.transferTo.trim();
+ if (!window.confirm(`Confirm transfer\n\nRecipient:\n${fullRecipient}\n\nAmount: ${this.amountLabel(amount)} IUNA`)) {
+ return;
+ }
+ const recipient = this.short(fullRecipient);
await this.postForm(
"/api/transfer",
- { to: this.transferTo, amount, fee_per_byte: fee, utxos: this.selectedTransferUtxos.join("\n") },
+ { to: fullRecipient, amount, fee_per_byte: fee, utxos: this.selectedTransferUtxos.join("\n") },
`Queued transfer of ${this.amountLabel(amount)} IUNA to ${recipient}`
);
this.transferTo = "";
@@ -2491,7 +2495,13 @@ window.iunaApp = function iunaApp() {
},
validAddressBookAddress(address) {
- return /^[0-9a-fA-F]{64}$/.test(String(address ?? "").trim());
+ const text = String(address ?? "").trim();
+ const hasLower = /[a-z]/.test(text);
+ const hasUpper = /[A-Z]/.test(text);
+ if (hasLower && hasUpper) return false;
+ const normalized = text.toLowerCase();
+ const prefix = this.setupAddress().startsWith("tiuna1") ? "tiuna1" : "iuna1";
+ return normalized.startsWith(prefix) && /^[02-9ac-hj-np-z]{59}$/.test(normalized.slice(prefix.length));
},
selectTransferContact(address) {
@@ -2526,18 +2536,19 @@ window.iunaApp = function iunaApp() {
},
async saveAddressBookEntry() {
- const address = this.addressBookDraftAddress.trim().toLowerCase();
+ const address = this.addressBookDraftAddress.trim();
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");
+ this.showFlash("Address must be a Bech32m address for this network", "error");
return;
}
+ const canonicalAddress = address.toLowerCase();
const oldAddress = this.addressBookEditingAddress;
- if (this.addressBook?.[address] && address !== oldAddress) {
+ if (this.addressBook?.[canonicalAddress] && canonicalAddress !== oldAddress) {
this.showFlash("Address is already saved", "error");
return;
}
@@ -2546,8 +2557,8 @@ window.iunaApp = function iunaApp() {
await this.submitForm("/api/address-book", fields);
this.addressBookVersion += 1;
const nextBook = { ...(this.addressBook || {}) };
- if (oldAddress && oldAddress !== address) delete nextBook[oldAddress];
- nextBook[address] = name;
+ if (oldAddress && oldAddress !== canonicalAddress) delete nextBook[oldAddress];
+ nextBook[canonicalAddress] = name;
this.addressBook = nextBook;
this.config = { ...this.config, address_book: this.addressBook };
this.closeAddressBookModal();