iuna

iuna

iuna - experimental mainnet-candidate protocol
git clone https://getiuna.org/git/iuna.git
Log | Files | Refs | README | LICENSE

commit d5c2ec9da29b51b29e11428642c6d9d54d3a3400
parent f49110a8233a86fb588be2f5fb713db81dbfa0fe
Author: Joris Hartog <jorishartog@hotmail.com>
Date:   Sat, 29 Aug 2026 20:25:24 +0200

Add checksummed network addresses

Diffstat:
MREADME.md | 6+++++-
Mdocs/operator-playbooks.md | 21+++++++++++++++++++++
Mdocs/protocol.md | 35+++++++++++++++++++++++++++++++++++
Msrc/adapters/http.rs | 1-
Msrc/adapters/http/actions.rs | 43+++++++++++++++++++++++++++++++++++++------
Msrc/adapters/http/index_html.rs | 5+++--
Msrc/adapters/http/wallet.rs | 15+++++++--------
Msrc/adapters/stratum.rs | 16++++++++++++++--
Msrc/app/node_lifecycle.rs | 28++++++++++++++++++++++++++--
Msrc/app/status.rs | 53+++++++++++++++++++++++++++++++++++++++++++++++++++--
Msrc/app/types.rs | 1+
Msrc/domain.rs | 2++
Asrc/domain/address.rs | 306+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/main.rs | 18++++++++++++++----
Msrc/main_tests.rs | 15++++++++++++++-
Mwww/assets/iuna-ui.js | 29++++++++++++++++++++---------
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();