iuna

iuna

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

commit 44ac97184c7e44356f65813c8ce284230b20c32e
parent d1c78d55a5b65611e44a7c37ebf07a0aa20c394a
Author: Joris Hartog <jorishartog@hotmail.com>
Date:   Thu, 24 Sep 2026 20:35:57 +0200

feat: upgrade web wallet to hybrid addresses

Diffstat:
M.dockerignore | 1+
MREADME.md | 25++++++++++++++++---------
Msrc/adapters/http.rs | 4++--
Msrc/adapters/http/types.rs | 22+++++++++++-----------
Msrc/adapters/http/ui.rs | 14+++++++-------
Msrc/adapters/wallet_endpoint.rs | 584++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Msrc/app/wallet.rs | 25+++++++++++++++++++++++++
Msrc/domain.rs | 1+
Msrc/domain/ledger_builders.rs | 11+++++++++++
Msrc/domain/ledger_queries.rs | 19+++++++++++++++++++
Msrc/domain/wallet.rs | 87++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Msrc/main_tests.rs | 2+-
Mtests/lightweight-wallet.test.cjs | 54++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mwallet/app.js | 217++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------------
Awallet/crypto-wasm/.gitignore | 1+
Awallet/crypto-wasm/Cargo.lock | 594+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Awallet/crypto-wasm/Cargo.toml | 28++++++++++++++++++++++++++++
Awallet/crypto-wasm/src/lib.rs | 607+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Awallet/crypto/.gitignore | 7+++++++
Awallet/crypto/iuna_wallet_crypto.d.ts | 43+++++++++++++++++++++++++++++++++++++++++++
Awallet/crypto/iuna_wallet_crypto.js | 394+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Awallet/crypto/iuna_wallet_crypto_bg.wasm | 0
Awallet/crypto/iuna_wallet_crypto_bg.wasm.d.ts | 10++++++++++
Awallet/crypto/package.json | 21+++++++++++++++++++++
Mwallet/multi-wallet.css | 4++++
Mwallet/wallet-core.js | 15+++++++++++----
26 files changed, 2710 insertions(+), 80 deletions(-)

diff --git a/.dockerignore b/.dockerignore @@ -19,6 +19,7 @@ !www/** !downloads/** !wallet/** +wallet/crypto-wasm/** !scripts/inject_cloudflare_analytics.py !scripts/postprocess_stagit_site.py !src-tauri/icons/32x32.png diff --git a/README.md b/README.md @@ -297,27 +297,34 @@ with `IUNA_WALLET_ENDPOINT_ENABLED=true` and `IUNA_WALLET_ENDPOINT_PORT=18662`. A settings change takes effect after restart. Keep port `18661` local or access-controlled; forward only the wallet API port. -The v1 API provides: +The public API provides: -- `GET /v1/status` — chain/signing parameters and current tip; +- `GET /v1/status` — chain/signing/v2 parameters and current tip; +- `POST /v1/wallets/snapshot` — aggregate balance, address-use state, and spendable + outputs across a deterministic wallet address set; +- `POST /v1/wallets/transactions` — merged legacy/v2 history across that address set; - `GET /v1/addresses/{address}/balance` — confirmed and spendable balance; - `GET /v1/addresses/{address}/utxos` — spendable inputs for local signing; - `GET /v1/addresses/{address}/transactions` — paginated pending/confirmed history; - `GET /v1/transactions/{signature}` — pending or confirmed transaction lookup; -- `POST /v1/transactions` — validate, relay, and gossip a signed transfer/burn. +- `POST /v1/transactions` — validate, relay, and gossip a signed legacy transfer/burn; +- `POST /v1/transactions-v2` — validate, relay, and gossip a canonical signed migration + or hybrid transfer envelope. Clients keep private keys and seed phrases locally. The public endpoint never creates signatures and does not expose management, local-wallet, mining, peer, -configuration, or authentication routes. JSON request bodies are capped at 64 -KiB. The production manifest exposes this API as `https://iuna.jhx.app/v1` while +configuration, or authentication routes. JSON request bodies are capped just above twice +the consensus block budget so a hex-encoded v2 envelope can fit. The production manifest exposes this API as `https://iuna.jhx.app/v1` while `https://admin.iuna.jhx.app/` remains the separately protected management UI. The lightweight browser wallet lives in `wallet/` and is served at `https://wallet.getiuna.org/`. It supports multiple named wallets. Signing -wallets keep each seed encrypted in browser `localStorage`; watch-only wallets -store only a public address and cannot sign or send. The app talks only to the -public v1 wallet endpoint. Requests to the old `/wallet/` path redirect to the -dedicated host. +wallets keep each seed encrypted in browser `localStorage`, derive ML-DSA-44 hybrid +keys inside a bundled WebAssembly module, recover addresses with the protocol gap limit, +and rotate the displayed receive/change address after it is used. Legacy funds remain +visible and can be migrated in the app. Watch-only wallets store one public address and +cannot follow future private derivations, sign, or send. The app talks only to the public +wallet endpoint. Requests to the old `/wallet/` path redirect to the dedicated host. ## Optional: Stratum Mining diff --git a/src/adapters/http.rs b/src/adapters/http.rs @@ -35,7 +35,7 @@ mod quantum_migration; mod request_auth; mod state; mod static_assets; -mod ui; +pub(crate) mod ui; mod wallet; use actions::{ api_address_book_delete_form, api_address_book_form, api_burn_fee_estimate_form, @@ -97,7 +97,7 @@ const UNKNOWN_CLIENT_KEY: &str = "unknown"; const PEER_STALE_AFTER_MS: u64 = 20 * 60 * 1_000; const SLOW_UI_REQUEST_LOG_MS: u128 = 250; -mod types; +pub(crate) mod types; use types::{ ActionResponse, AuthForm, AuthStatusResponse, BlocksQuery, ChangePasswordForm, ConfigForm, ConfigResponse, MempoolCounts, MetricsQuery, MetricsResponse, NetworkHealthLocalState, diff --git a/src/adapters/http/types.rs b/src/adapters/http/types.rs @@ -250,11 +250,11 @@ impl WalletTransactionsQuery { } #[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub(super) struct WalletTransactionFilters { - pub(super) transfer: bool, - pub(super) mine: bool, - pub(super) burn: bool, - pub(super) reward: bool, +pub(crate) struct WalletTransactionFilters { + pub(crate) transfer: bool, + pub(crate) mine: bool, + pub(crate) burn: bool, + pub(crate) reward: bool, } impl Default for WalletTransactionFilters { @@ -432,7 +432,7 @@ mod secret_tests { #[derive(Clone, Debug, Eq, PartialEq, Serialize)] #[serde(rename_all = "camelCase")] -pub(super) struct WalletTransactionRow { +pub(crate) struct WalletTransactionRow { pub(super) kind: &'static str, pub(super) from: String, pub(super) to: Option<String>, @@ -482,11 +482,11 @@ pub(super) struct WalletUtxoRow { } #[derive(Clone, Debug)] -pub(super) struct WalletTransactionContext { - pub(super) status: &'static str, - pub(super) block_height: Option<u64>, - pub(super) timestamp_ms: Option<u64>, - pub(super) block_finalizer: Option<String>, +pub(crate) struct WalletTransactionContext { + pub(crate) status: &'static str, + pub(crate) block_height: Option<u64>, + pub(crate) timestamp_ms: Option<u64>, + pub(crate) block_finalizer: Option<String>, } #[derive(Clone, Debug, Eq, PartialEq, Serialize)] diff --git a/src/adapters/http/ui.rs b/src/adapters/http/ui.rs @@ -15,7 +15,7 @@ use super::types::{ WalletTransactionRow, }; -pub(super) fn wallet_transaction_rows( +pub(crate) fn wallet_transaction_rows( wallet_addresses: &[String], pending: Vec<Transaction>, chain: &[Block], @@ -66,7 +66,7 @@ pub(super) fn wallet_transaction_rows( rows.into_iter().map(|(_, row)| row).collect() } -pub(super) fn wallet_transaction_v2_rows( +pub(crate) fn wallet_transaction_v2_rows( wallet_addresses: &[String], pending: &[TransactionV2], outputs: &BTreeMap<OutPoint, TxOutput>, @@ -99,7 +99,7 @@ pub(super) fn wallet_transaction_v2_rows( .collect() } -pub(super) fn wallet_transaction_v2_row( +pub(crate) fn wallet_transaction_v2_row( wallet_addresses: &[String], transaction: &TransactionV2, outputs: &BTreeMap<OutPoint, TxOutput>, @@ -178,7 +178,7 @@ pub(super) fn wallet_transaction_row( ) } -pub(super) fn wallet_transaction_row_for_addresses( +pub(crate) fn wallet_transaction_row_for_addresses( wallet_addresses: &[String], tx: &Transaction, outputs_by_outpoint: &BTreeMap<OutPoint, TxOutput>, @@ -865,7 +865,7 @@ pub(super) fn ui_transaction_v2( } } -pub(super) fn transaction_v2_input_outpoints(transaction: &TransactionV2) -> Vec<OutPoint> { +pub(crate) fn transaction_v2_input_outpoints(transaction: &TransactionV2) -> Vec<OutPoint> { match transaction { TransactionV2::Migration { inputs, .. } => inputs .iter() @@ -888,7 +888,7 @@ pub(super) fn transaction_v2_input_outpoints(transaction: &TransactionV2) -> Vec } } -pub(super) fn add_pending_v2_outputs( +pub(crate) fn add_pending_v2_outputs( outputs: &mut BTreeMap<OutPoint, TxOutput>, pending: &[TransactionV2], domain: &TransactionV2Domain, @@ -1007,7 +1007,7 @@ fn hex_nibble(byte: u8) -> Option<u8> { } } -pub(super) fn add_pending_outputs( +pub(crate) fn add_pending_outputs( outputs: &mut BTreeMap<OutPoint, TxOutput>, pending: &[Transaction], ) { diff --git a/src/adapters/wallet_endpoint.rs b/src/adapters/wallet_endpoint.rs @@ -1,4 +1,8 @@ -use std::{net::SocketAddr, sync::Arc}; +use std::{ + collections::{BTreeMap, BTreeSet}, + net::SocketAddr, + sync::Arc, +}; use anyhow::{Context, Result}; use axum::{ @@ -14,16 +18,34 @@ use serde::{Deserialize, Serialize}; use tokio::{net::TcpListener, sync::Semaphore}; use crate::{ - adapters::{p2p::GossipNetwork, ui_data_store::SqliteUiDataStore}, + adapters::{ + http::{ + types::{WalletTransactionContext, WalletTransactionFilters, WalletTransactionRow}, + ui::{ + add_pending_outputs, add_pending_v2_outputs, transaction_v2_input_outpoints, + wallet_transaction_row_for_addresses, wallet_transaction_rows, + wallet_transaction_v2_row, wallet_transaction_v2_rows, + }, + }, + p2p::GossipNetwork, + ui_data_store::SqliteUiDataStore, + }, app::{NETWORK_ID, PROTOCOL_VERSION, SharedNode}, domain::{ - Amount, DEFAULT_FEE_PER_BYTE, MICRO_IUNA, OutPoint, TRANSACTION_SIGNING_FORMAT_VERSION, - TRANSACTION_SIGNING_V1_ACTIVATION_HEIGHT, Transaction, TransactionSubmitOutcome, TxOutput, + AddressNetwork, AddressVersion, Amount, DEFAULT_FEE_PER_BYTE, + HYBRID_EXTERNAL_ADDRESS_GAP_LIMIT, MAX_BLOCK_BYTES, MICRO_IUNA, OutPoint, + TRANSACTION_SIGNING_FORMAT_VERSION, TRANSACTION_SIGNING_V1_ACTIVATION_HEIGHT, + TRANSACTION_V2_ACTIVATION_HEIGHT, TRANSACTION_V2_WIRE_VERSION, Transaction, + TransactionSubmitOutcome, TransactionV2, TxOutput, decode_hex, encode_versioned_address, + hex_encode, transaction_v2_is_active, }, }; -const MAX_TRANSACTION_BODY_BYTES: usize = 64 * 1024; +// A canonical v2 envelope is hexadecimal inside JSON, so its HTTP representation can be a little +// over twice the consensus block budget. +const MAX_TRANSACTION_BODY_BYTES: usize = MAX_BLOCK_BYTES * 2 + 4 * 1024; const MAX_CONCURRENT_TRANSACTION_SUBMISSIONS: usize = 32; +const MAX_WALLET_ADDRESSES: usize = 10_001; #[derive(Clone)] struct WalletEndpointState { @@ -54,6 +76,69 @@ struct WalletEndpointStatus { default_fee_per_byte: Amount, amount_unit: &'static str, microiuna_per_iuna: Amount, + transaction_v2_wire_version: u16, + transaction_v2_activation_height: Option<u64>, + transaction_v2_active: bool, + hybrid_address_gap_limit: u32, +} + +#[derive(Debug, Deserialize)] +struct WalletSnapshotRequest { + addresses: Vec<String>, +} + +#[derive(Debug, Serialize)] +struct WalletSnapshotResponse { + addresses: Vec<WalletAddressSnapshot>, + confirmed: Amount, + spendable: Amount, + pending_outgoing: Amount, + pending_incoming: Amount, + height: u64, + tip_hash: String, + utxos: Vec<WalletSnapshotUtxo>, +} + +#[derive(Debug, Serialize)] +struct WalletAddressSnapshot { + address: String, + version: u8, + used: bool, + confirmed: Amount, + spendable: Amount, +} + +#[derive(Debug, Serialize)] +struct WalletSnapshotUtxo { + address: String, + outpoint: OutPoint, + output: TxOutput, +} + +#[derive(Debug, Deserialize)] +struct WalletTransactionsRequest { + addresses: Vec<String>, + #[serde(default = "default_true")] + transfer: bool, + #[serde(default = "default_true")] + mine: bool, + #[serde(default = "default_true")] + burn: bool, + #[serde(default = "default_true")] + reward: bool, + #[serde(default)] + offset: usize, + limit: Option<usize>, +} + +#[derive(Debug, Serialize)] +struct WalletTransactionsResponse { + items: Vec<WalletTransactionRow>, + offset: usize, + limit: usize, + total: usize, + has_more: bool, + next_offset: Option<usize>, } #[derive(Debug, Serialize)] @@ -89,6 +174,11 @@ struct SubmitResponse { status: &'static str, } +#[derive(Debug, Deserialize)] +struct SubmitV2Request { + envelope: String, +} + #[derive(Debug, Serialize)] struct TransactionResponse { transaction_id: String, @@ -189,6 +279,8 @@ pub async fn serve( fn router(node: SharedNode, gossip: GossipNetwork, ui_data_store: SqliteUiDataStore) -> Router { Router::new() .route("/v1/status", get(status)) + .route("/v1/wallets/snapshot", post(wallet_snapshot)) + .route("/v1/wallets/transactions", post(wallet_transactions)) .route("/v1/addresses/{address}/balance", get(balance)) .route("/v1/addresses/{address}/utxos", get(utxos)) .route( @@ -197,6 +289,7 @@ fn router(node: SharedNode, gossip: GossipNetwork, ui_data_store: SqliteUiDataSt ) .route("/v1/transactions/{transaction_id}", get(transaction)) .route("/v1/transactions", post(submit_transaction)) + .route("/v1/transactions-v2", post(submit_transaction_v2)) .layer(DefaultBodyLimit::max(MAX_TRANSACTION_BODY_BYTES)) .layer(middleware::from_fn(cors)) .with_state(WalletEndpointState { @@ -211,7 +304,7 @@ async fn status(State(state): State<WalletEndpointState>) -> Json<WalletEndpoint let node = state.node.lock().await; let ledger = node.ledger(); Json(WalletEndpointStatus { - api_version: 1, + api_version: 2, ready: node.has_real_chain() && node.network_migration_from().is_none(), network_migration_required: node.network_migration_from().is_some(), network_id: NETWORK_ID.to_string(), @@ -225,9 +318,383 @@ async fn status(State(state): State<WalletEndpointState>) -> Json<WalletEndpoint default_fee_per_byte: DEFAULT_FEE_PER_BYTE, amount_unit: "microiuna", microiuna_per_iuna: MICRO_IUNA, + transaction_v2_wire_version: TRANSACTION_V2_WIRE_VERSION, + transaction_v2_activation_height: TRANSACTION_V2_ACTIVATION_HEIGHT, + transaction_v2_active: transaction_v2_is_active(ledger.height().saturating_add(1)), + hybrid_address_gap_limit: HYBRID_EXTERNAL_ADDRESS_GAP_LIMIT, }) } +async fn wallet_snapshot( + State(state): State<WalletEndpointState>, + Json(request): Json<WalletSnapshotRequest>, +) -> Result<Json<WalletSnapshotResponse>, (StatusCode, Json<ApiError>)> { + if request.addresses.is_empty() { + return Err(api_error( + StatusCode::BAD_REQUEST, + "at least one address is required", + )); + } + if request.addresses.len() > MAX_WALLET_ADDRESSES { + return Err(api_error( + StatusCode::BAD_REQUEST, + format!("a wallet snapshot accepts at most {MAX_WALLET_ADDRESSES} addresses"), + )); + } + + let node = state.node.lock().await; + let ledger = node.ledger(); + let network = + crate::domain::AddressNetwork::from_profile_id(&ledger.launch_profile().profile_id); + let used_hybrid = ledger + .used_hybrid_encoded_addresses() + .map_err(internal_error)? + .into_iter() + .collect::<BTreeSet<_>>(); + let mut seen = BTreeSet::new(); + let mut requested_addresses = Vec::new(); + for requested in request.addresses { + let decoded = node.decode_user_address(&requested).map_err(bad_request)?; + let display = encode_versioned_address(decoded, network).map_err(bad_request)?; + if !seen.insert(display.clone()) { + continue; + } + let ledger_address = match decoded.version { + AddressVersion::Ed25519PublicKey => hex_encode(decoded.payload), + AddressVersion::HybridKeyCommitment => display.clone(), + }; + requested_addresses.push((display, decoded.version, ledger_address)); + } + let ledger_addresses = requested_addresses + .iter() + .map(|(_, _, address)| address.clone()) + .collect::<BTreeSet<_>>(); + let display_by_ledger_address = requested_addresses + .iter() + .map(|(display, _, ledger_address)| (ledger_address.clone(), display.clone())) + .collect::<BTreeMap<_, _>>(); + let mut confirmed_by_address = BTreeMap::<String, Amount>::new(); + for (_, output) in ledger.utxos_for_addresses(&ledger_addresses) { + let balance = confirmed_by_address.entry(output.address).or_default(); + *balance = balance.checked_add(output.amount).ok_or_else(|| { + api_error( + StatusCode::INTERNAL_SERVER_ERROR, + "wallet balance overflows", + ) + })?; + } + let available = ledger + .available_utxos_for_addresses(&ledger_addresses) + .map_err(internal_error)?; + let mut spendable_by_address = BTreeMap::<String, Amount>::new(); + for (_, output) in &available { + let balance = spendable_by_address + .entry(output.address.clone()) + .or_default(); + *balance = balance.checked_add(output.amount).ok_or_else(|| { + api_error( + StatusCode::INTERNAL_SERVER_ERROR, + "wallet balance overflows", + ) + })?; + } + let mut confirmed = 0_u64; + let mut spendable = 0_u64; + let addresses = requested_addresses + .into_iter() + .map(|(display, version, ledger_address)| { + let address_confirmed = confirmed_by_address + .get(&ledger_address) + .copied() + .unwrap_or_default(); + let address_spendable = spendable_by_address + .get(&ledger_address) + .copied() + .unwrap_or_default(); + confirmed = confirmed.checked_add(address_confirmed).ok_or_else(|| { + api_error( + StatusCode::INTERNAL_SERVER_ERROR, + "wallet balance overflows", + ) + })?; + spendable = spendable.checked_add(address_spendable).ok_or_else(|| { + api_error( + StatusCode::INTERNAL_SERVER_ERROR, + "wallet balance overflows", + ) + })?; + Ok(WalletAddressSnapshot { + address: display.clone(), + version: version.wire_id(), + used: version == AddressVersion::HybridKeyCommitment + && used_hybrid.contains(&display), + confirmed: address_confirmed, + spendable: address_spendable, + }) + }) + .collect::<Result<Vec<_>, (StatusCode, Json<ApiError>)>>()?; + let mut utxos = available + .into_iter() + .filter_map(|(outpoint, output)| { + display_by_ledger_address + .get(&output.address) + .cloned() + .map(|address| WalletSnapshotUtxo { + address, + outpoint, + output, + }) + }) + .collect::<Vec<_>>(); + utxos.sort_by(|left, right| { + right + .output + .amount + .cmp(&left.output.amount) + .then_with(|| left.outpoint.cmp(&right.outpoint)) + }); + Ok(Json(WalletSnapshotResponse { + addresses, + confirmed, + spendable, + pending_outgoing: confirmed.saturating_sub(spendable), + pending_incoming: spendable.saturating_sub(confirmed), + height: ledger.height(), + tip_hash: ledger.tip_hash().to_string(), + utxos, + })) +} + +async fn wallet_transactions( + State(state): State<WalletEndpointState>, + Json(request): Json<WalletTransactionsRequest>, +) -> Result<Json<WalletTransactionsResponse>, (StatusCode, Json<ApiError>)> { + if request.addresses.is_empty() || request.addresses.len() > MAX_WALLET_ADDRESSES { + return Err(api_error( + StatusCode::BAD_REQUEST, + format!("provide between 1 and {MAX_WALLET_ADDRESSES} wallet addresses"), + )); + } + let offset = request.offset; + let limit = request.limit.unwrap_or(25).clamp(1, 100); + let filters = WalletTransactionFilters { + transfer: request.transfer, + mine: request.mine, + burn: request.burn, + reward: request.reward, + }; + let kinds = wallet_transaction_kinds(filters); + let (addresses, pending, pending_v2, domain, network, mut pending_outputs) = { + let node = state.node.lock().await; + let ledger = node.ledger(); + let network = AddressNetwork::from_profile_id(&ledger.launch_profile().profile_id); + let mut addresses = Vec::new(); + for requested in request.addresses { + let decoded = node.decode_user_address(&requested).map_err(bad_request)?; + let address = match decoded.version { + AddressVersion::Ed25519PublicKey => hex_encode(decoded.payload), + AddressVersion::HybridKeyCommitment => { + encode_versioned_address(decoded, network).map_err(bad_request)? + } + }; + if !addresses.contains(&address) { + addresses.push(address); + } + } + let pending_v2 = node.pending_transactions_v2(); + let pending_outputs = pending_v2 + .iter() + .flat_map(transaction_v2_input_outpoints) + .filter_map(|outpoint| { + ledger + .output_for_outpoint(&outpoint) + .map(|output| (outpoint, output)) + }) + .collect::<BTreeMap<_, _>>(); + ( + addresses, + node.pending_transactions(), + pending_v2, + ledger.transaction_v2_domain().map_err(internal_error)?, + network, + pending_outputs, + ) + }; + + let mut pending_outpoints = BTreeSet::new(); + collect_legacy_input_outpoints(pending.iter(), &mut pending_outpoints); + pending_outpoints.extend(pending_v2.iter().flat_map(transaction_v2_input_outpoints)); + if !pending_outpoints.is_empty() { + let store = state.ui_data_store.clone(); + let stored = tokio::task::spawn_blocking(move || store.load_outputs(&pending_outpoints)) + .await + .map_err(internal_error)? + .map_err(internal_error)?; + pending_outputs.extend(stored); + } + add_pending_outputs(&mut pending_outputs, &pending); + add_pending_v2_outputs(&mut pending_outputs, &pending_v2, &domain, network) + .map_err(internal_error)?; + let mut pending_rows = wallet_transaction_v2_rows( + &addresses, + &pending_v2, + &pending_outputs, + filters, + &domain, + network, + ); + pending_rows.extend(wallet_transaction_rows( + &addresses, + pending, + &[], + &pending_outputs, + filters, + )); + let pending_total = pending_rows.len(); + let mut items = pending_rows + .into_iter() + .skip(offset.min(pending_total)) + .take(limit) + .collect::<Vec<_>>(); + + let confirmed_offset = offset.saturating_sub(pending_total); + let remaining = limit.saturating_sub(items.len()); + let fetch_limit = confirmed_offset.saturating_add(remaining); + let store = state.ui_data_store.clone(); + let query_addresses = addresses.clone(); + let ((legacy_rows, legacy_total), (v2_rows, v2_total)) = + tokio::task::spawn_blocking(move || -> Result<_> { + Ok(( + store.load_wallet_transactions_for_addresses( + &query_addresses, + &kinds, + 0, + fetch_limit, + )?, + store.load_wallet_transactions_v2(&query_addresses, &kinds, 0, fetch_limit)?, + )) + }) + .await + .map_err(internal_error)? + .map_err(internal_error)?; + + let decoded_v2 = v2_rows + .into_iter() + .filter_map(|row| { + let bytes = decode_hex(&row.envelope).ok()?; + let (decoded_domain, transaction) = TransactionV2::decode(&bytes).ok()?; + (decoded_domain == domain).then_some((row, transaction)) + }) + .collect::<Vec<_>>(); + let mut confirmed_outpoints = BTreeSet::new(); + collect_legacy_input_outpoints( + legacy_rows.iter().map(|row| &row.transaction), + &mut confirmed_outpoints, + ); + confirmed_outpoints.extend( + decoded_v2 + .iter() + .flat_map(|(_, transaction)| transaction_v2_input_outpoints(transaction)), + ); + let confirmed_outputs = if confirmed_outpoints.is_empty() { + BTreeMap::new() + } else { + let store = state.ui_data_store.clone(); + tokio::task::spawn_blocking(move || store.load_outputs(&confirmed_outpoints)) + .await + .map_err(internal_error)? + .map_err(internal_error)? + }; + let mut confirmed = legacy_rows + .into_iter() + .filter_map(|row| { + wallet_transaction_row_for_addresses( + &addresses, + &row.transaction, + &confirmed_outputs, + &WalletTransactionContext { + status: "confirmed", + block_height: Some(row.block_height), + timestamp_ms: Some(row.timestamp_ms), + block_finalizer: Some(row.block_finalizer), + }, + ) + .map(|item| (row.sort_key, item)) + }) + .collect::<Vec<_>>(); + confirmed.extend(decoded_v2.into_iter().filter_map(|(row, transaction)| { + wallet_transaction_v2_row( + &addresses, + &transaction, + &confirmed_outputs, + &domain, + network, + &WalletTransactionContext { + status: "confirmed", + block_height: Some(row.block_height), + timestamp_ms: Some(row.timestamp_ms), + block_finalizer: Some(row.block_finalizer), + }, + ) + .ok() + .flatten() + .map(|item| (row.sort_key, item)) + })); + confirmed.sort_by(|left, right| right.0.cmp(&left.0)); + items.extend( + confirmed + .into_iter() + .skip(confirmed_offset) + .take(remaining) + .map(|(_, item)| item), + ); + let total = pending_total + .saturating_add(legacy_total) + .saturating_add(v2_total); + let next_offset = offset.saturating_add(items.len()); + Ok(Json(WalletTransactionsResponse { + items, + offset: offset.min(total), + limit, + total, + has_more: next_offset < total, + next_offset: (next_offset < total).then_some(next_offset), + })) +} + +fn default_true() -> bool { + true +} + +fn wallet_transaction_kinds(filters: WalletTransactionFilters) -> Vec<&'static str> { + let mut kinds = Vec::new(); + if filters.transfer { + kinds.push("transfer"); + } + if filters.mine { + kinds.push("mine"); + } + if filters.burn { + kinds.push("burn"); + } + if filters.reward { + kinds.push("reward"); + } + kinds +} + +fn collect_legacy_input_outpoints<'a>( + transactions: impl IntoIterator<Item = &'a Transaction>, + outpoints: &mut BTreeSet<OutPoint>, +) { + for transaction in transactions { + if let Transaction::Transfer { inputs, .. } | Transaction::Burn { inputs, .. } = transaction + { + outpoints.extend(inputs.iter().map(|input| input.outpoint.clone())); + } + } +} + async fn balance( State(state): State<WalletEndpointState>, Path(address): Path<String>, @@ -467,6 +934,60 @@ async fn submit_transaction( )) } +async fn submit_transaction_v2( + State(state): State<WalletEndpointState>, + Json(request): Json<SubmitV2Request>, +) -> Result<(StatusCode, Json<SubmitResponse>), (StatusCode, Json<ApiError>)> { + let _permit = state + .transaction_slots + .clone() + .try_acquire_owned() + .map_err(|_| { + api_error( + StatusCode::TOO_MANY_REQUESTS, + "too many transaction submissions", + ) + })?; + let (transaction_id, outcome, outbox) = { + let mut node = state.node.lock().await; + if !node.has_real_chain() { + return Err(api_error( + StatusCode::SERVICE_UNAVAILABLE, + "node has not joined a chain yet", + )); + } + if node.network_migration_from().is_some() { + return Err(api_error( + StatusCode::SERVICE_UNAVAILABLE, + "node requires a network migration reset", + )); + } + let (transaction_id, outcome) = node + .submit_external_wallet_transaction_v2(&request.envelope) + .map_err(bad_request)?; + (transaction_id, outcome, node.drain_outbox()) + }; + if outcome.added() { + state + .gossip + .broadcast(outbox) + .await + .map_err(internal_error)?; + } + let (code, status) = match outcome { + TransactionSubmitOutcome::Added => (StatusCode::ACCEPTED, "accepted"), + TransactionSubmitOutcome::AlreadyKnown => (StatusCode::OK, "already_known"), + TransactionSubmitOutcome::ConflictsWithPending => (StatusCode::CONFLICT, "conflict"), + }; + Ok(( + code, + Json(SubmitResponse { + transaction_id, + status, + }), + )) +} + fn bad_request(error: impl std::fmt::Display) -> (StatusCode, Json<ApiError>) { api_error(StatusCode::BAD_REQUEST, error) } @@ -579,6 +1100,32 @@ mod tests { assert_eq!(value["address"], receive_address); assert_eq!(value["public_key"], wallet.address()); + let response = app + .clone() + .oneshot( + Request::builder() + .method(Method::POST) + .uri("/v1/wallets/snapshot") + .header(header::CONTENT_TYPE, "application/json") + .body(Body::from( + serde_json::to_vec(&serde_json::json!({ + "addresses": [receive_address.clone()] + })) + .unwrap(), + )) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + let body = to_bytes(response.into_body(), 64 * 1024).await.unwrap(); + let value: serde_json::Value = serde_json::from_slice(&body).unwrap(); + assert_eq!(value["confirmed"], 5 * MICRO_IUNA); + assert_eq!(value["spendable"], 5 * MICRO_IUNA); + assert_eq!(value["addresses"][0]["version"], 0); + assert_eq!(value["addresses"][0]["used"], false); + assert_eq!(value["utxos"].as_array().unwrap().len(), 1); + let wrong_network = encode_address(wallet.address(), AddressNetwork::Testnet).unwrap(); let response = app .clone() @@ -659,6 +1206,31 @@ mod tests { assert_eq!(value["items"][0]["status"], "pending"); let response = app + .clone() + .oneshot( + Request::builder() + .method(Method::POST) + .uri("/v1/wallets/transactions") + .header(header::CONTENT_TYPE, "application/json") + .body(Body::from( + serde_json::to_vec(&serde_json::json!({ + "addresses": [receive_address.clone()], + "limit": 10 + })) + .unwrap(), + )) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + let body = to_bytes(response.into_body(), 64 * 1024).await.unwrap(); + let value: serde_json::Value = serde_json::from_slice(&body).unwrap(); + assert_eq!(value["total"], 1); + assert_eq!(value["items"][0]["status"], "pending"); + assert_eq!(value["items"][0]["direction"], "sent"); + + let response = app .oneshot( Request::builder() .uri(format!( diff --git a/src/app/wallet.rs b/src/app/wallet.rs @@ -171,6 +171,31 @@ impl NodeCore { Ok(outcome) } + /// Accept a canonical v2 envelope signed by an external/lightweight wallet. + /// Mining and burn transactions remain node-managed operations. + pub fn submit_external_wallet_transaction_v2( + &mut self, + envelope: &str, + ) -> Result<(String, TransactionSubmitOutcome)> { + let encoded = crate::domain::decode_hex(envelope)?; + let transaction = self.ledger.decode_transaction_v2(&encoded)?; + if !matches!( + transaction, + TransactionV2::Migration { .. } | TransactionV2::Transfer { .. } + ) { + bail!("the wallet endpoint accepts only migration and transfer v2 transactions"); + } + let domain = self.ledger.transaction_v2_domain()?; + let transaction_id = hex_encode(transaction.transaction_id(&domain)?); + let outcome = self.ledger.submit_transaction_v2(transaction)?; + if outcome.added() { + self.outbox.push(GossipEnvelope::TransactionV2 { + envelope: hex_encode(encoded), + }); + } + Ok((transaction_id, outcome)) + } + pub fn burn(&mut self, amount: Amount) -> Result<Transaction> { self.burn_with_fee(amount, DEFAULT_TRANSACTION_FEE) } diff --git a/src/domain.rs b/src/domain.rs @@ -50,6 +50,7 @@ use fork::{FinalityCheckpoint, LeaderScore}; pub(crate) use genesis::genesis_allocation_outpoint; pub use hex::hex_hash; pub(crate) use hex::{decode_hex, decode_hex_array, hex_encode}; +pub use ledger_builders::HYBRID_EXTERNAL_ADDRESS_GAP_LIMIT; use ledger_lineage::{ LineageOwnerValues, UtxoLineageRoot, attach_existing_output_lineage, insert_output_with_lineage, newest_lineage_root, output_lineage_root_for_transaction, diff --git a/src/domain/ledger_builders.rs b/src/domain/ledger_builders.rs @@ -186,6 +186,17 @@ impl Ledger { Ok(used) } + /// Returns every hybrid address that has appeared as a transaction or reward output, + /// including unconfirmed outputs. External wallets use this to recover their deterministic + /// receive cursor without exposing their seed or public-key material to the node. + pub fn used_hybrid_encoded_addresses(&self) -> Result<Vec<String>> { + let network = self.address_network(); + self.used_hybrid_addresses()? + .into_iter() + .map(|address| encode_versioned_address(address, network)) + .collect() + } + fn pending_hybrid_addresses(&self) -> Vec<VersionedAddress> { let mut used = Vec::new(); let network = self.address_network(); diff --git a/src/domain/ledger_queries.rs b/src/domain/ledger_queries.rs @@ -610,6 +610,14 @@ impl Ledger { .collect() } + pub fn utxos_for_addresses(&self, addresses: &BTreeSet<String>) -> Vec<(OutPoint, TxOutput)> { + self.utxos + .iter() + .filter(|(_, output)| addresses.contains(&output.address)) + .map(|(outpoint, output)| (outpoint.clone(), output.clone())) + .collect() + } + pub fn all_utxos(&self) -> Vec<(OutPoint, TxOutput)> { self.utxos .iter() @@ -636,6 +644,17 @@ impl Ledger { .collect()) } + pub fn available_utxos_for_addresses( + &self, + addresses: &BTreeSet<String>, + ) -> Result<Vec<(OutPoint, TxOutput)>> { + Ok(self + .utxos_after_spendable_pending()? + .into_iter() + .filter(|(_, output)| addresses.contains(&output.address)) + .collect()) + } + pub fn next_nonce(&self, address: &str) -> u64 { self.utxos .keys() diff --git a/src/domain/wallet.rs b/src/domain/wallet.rs @@ -374,7 +374,9 @@ mod tests { use super::{HybridAddressBranch, Wallet}; use crate::domain::{ - AddressNetwork, SignatureScheme, hex_encode, verify_ed25519, verify_ml_dsa44, + AddressNetwork, LegacyTransactionId, SignatureScheme, TransactionV2, TransactionV2Domain, + TransactionV2Input, TransactionV2LegacyInput, TransactionV2Output, hex_encode, + verify_ed25519, verify_ml_dsa44, }; #[test] @@ -398,6 +400,14 @@ mod tests { assert!(first.hybrid_public_key.get().is_none()); assert_eq!(first.address(), second.address()); assert_eq!(first.hybrid_public_key(), second.hybrid_public_key()); + assert_eq!( + first.hybrid_address(AddressNetwork::Mainnet), + "iuna1py9qlrnw6cm3mpz26hwwkww9spaa96zrw2g34gu0p4y3ea5cqhg0qa82x9s" + ); + assert_eq!( + first.hybrid_address_at(HybridAddressBranch::External, 1, AddressNetwork::Mainnet), + "iuna1pkxdcktlzf5tc2p59xns2nccq7rg5zjhwg2zn5r9u73c5j9jxgk8s0e5l4e" + ); assert!(first.hybrid_public_key.get().is_some()); assert_eq!( first.hybrid_address(AddressNetwork::Mainnet), @@ -422,6 +432,81 @@ mod tests { } #[test] + fn browser_v2_transfer_vector_matches_node_encoding_and_signatures() { + let wallet = Wallet::from_seed("hybrid-wallet-seed"); + let owner = wallet.hybrid_versioned_address_at(HybridAddressBranch::External, 0); + let recipient = wallet.hybrid_versioned_address_at(HybridAddressBranch::External, 1); + let change = wallet.hybrid_versioned_address_at(HybridAddressBranch::External, 2); + let domain = TransactionV2Domain::new("iuna-mainnet-candidate", [0x11; 32]).unwrap(); + let mut transaction = TransactionV2::Transfer { + inputs: vec![TransactionV2Input { + outpoint_txid: [0x22; 32], + outpoint_index: 7, + owner, + }], + outputs: vec![ + TransactionV2Output { + address: recipient, + amount: 1_000_000, + }, + TransactionV2Output { + address: change, + amount: 995_921, + }, + ], + fee: 4_079, + authorizations: Vec::new(), + }; + let payload = transaction.signing_bytes(&domain).unwrap(); + let authorization = wallet.sign_v2_authorization(owner, &payload).unwrap(); + let TransactionV2::Transfer { authorizations, .. } = &mut transaction else { + unreachable!(); + }; + authorizations.push(authorization); + + transaction.verify_authorizations(&domain).unwrap(); + assert_eq!(transaction.encoded_size_bytes(&domain).unwrap(), 4_079); + assert_eq!( + hex_encode(transaction.transaction_id(&domain).unwrap()), + "bda748b6ba9fd6550ca47d580f980a1c00d0050a20992750265a62d23b0b590d" + ); + } + + #[test] + fn browser_v2_migration_vector_matches_node_encoding_and_signature() { + let wallet = Wallet::from_seed("hybrid-wallet-seed"); + let owner = wallet.legacy_versioned_address(); + let destination = wallet.hybrid_versioned_address_at(HybridAddressBranch::External, 2); + let domain = TransactionV2Domain::new("iuna-mainnet-candidate", [0x11; 32]).unwrap(); + let mut transaction = TransactionV2::Migration { + inputs: vec![TransactionV2LegacyInput { + outpoint_id: LegacyTransactionId::Hash([0x33; 32]), + outpoint_index: 4, + owner, + }], + outputs: vec![TransactionV2Output { + address: destination, + amount: 1_999_693, + }], + fee: 307, + authorizations: Vec::new(), + }; + let payload = transaction.signing_bytes(&domain).unwrap(); + let authorization = wallet.sign_v2_authorization(owner, &payload).unwrap(); + let TransactionV2::Migration { authorizations, .. } = &mut transaction else { + unreachable!(); + }; + authorizations.push(authorization); + + transaction.verify_authorizations(&domain).unwrap(); + assert_eq!(transaction.encoded_size_bytes(&domain).unwrap(), 307); + assert_eq!( + hex_encode(transaction.transaction_id(&domain).unwrap()), + "37539c9d89a391c599b838c3f415e7adc93a36c22cd4fb5fe44a79433f336075" + ); + } + + #[test] fn hybrid_authorization_signs_both_components_over_the_same_payload() { let wallet = Wallet::from_seed("hybrid-signing-wallet-seed"); let payload = b"canonical transaction-v2 payload"; diff --git a/src/main_tests.rs b/src/main_tests.rs @@ -282,7 +282,7 @@ fn management_ui_uses_the_native_desktop_updater_when_available() { fn lightweight_wallet_recent_activity_does_not_use_view_all_filters() { let javascript = include_str!("../wallet/app.js"); - assert!(javascript.contains("api(transactionPath(0, 5, ALL_TRANSACTION_FILTERS))")); + assert!(javascript.contains("fetchTransactions(0, 5, ALL_TRANSACTION_FILTERS)")); assert!(javascript.contains("activityList(5, false, state.recentTransactions)")); } diff --git a/tests/lightweight-wallet.test.cjs b/tests/lightweight-wallet.test.cjs @@ -1,6 +1,7 @@ const { test } = require('node:test'); const assert = require('node:assert/strict'); const { readFileSync } = require('node:fs'); +const { pathToFileURL } = require('node:url'); let core; @@ -84,3 +85,56 @@ test('uses the selected fee rate when calculating a transfer', () => { assert.equal(priority.fee, standard.fee * 5n); }); + +test('recognizes hybrid Bech32m addresses without treating them as legacy keys', () => { + const address = 'iuna1py9qlrnw6cm3mpz26hwwkww9spaa96zrw2g34gu0p4y3ea5cqhg0qa82x9s'; + const decoded = core.decodeVersionedAddress(address, 'iuna'); + assert.equal(decoded.version, 1); + assert.equal(decoded.payloadHex.length, 64); + assert.throws(() => core.decodeAddress(address, 'iuna'), /requires a legacy address/); +}); + +test('browser crypto derives the same rotating hybrid address vectors as the node', async () => { + const modulePath = require.resolve('../wallet/crypto/iuna_wallet_crypto.js'); + const wasmPath = require.resolve('../wallet/crypto/iuna_wallet_crypto_bg.wasm'); + const quantum = await import(pathToFileURL(modulePath)); + await quantum.default({ module_or_path: readFileSync(wasmPath) }); + const addresses = JSON.parse(quantum.derive_external_addresses('hybrid-wallet-seed', 2, 'iuna-mainnet-v1')); + assert.deepEqual(addresses, [ + { index: 0, address: 'iuna1py9qlrnw6cm3mpz26hwwkww9spaa96zrw2g34gu0p4y3ea5cqhg0qa82x9s' }, + { index: 1, address: 'iuna1pkxdcktlzf5tc2p59xns2nccq7rg5zjhwg2zn5r9u73c5j9jxgk8s0e5l4e' }, + ]); + + const built = JSON.parse(quantum.build_transfer(JSON.stringify({ + seed: 'hybrid-wallet-seed', + chainId: 'iuna-mainnet-candidate', + genesisHash: '11'.repeat(32), + recipientAddress: addresses[1].address, + amount: '1000000', + feeRate: '1', + changeIndex: 2, + utxos: [{ + addressIndex: 0, + outpoint: { txid: '22'.repeat(32), index: 7 }, + output: { amount: 2000000 }, + }], + }))); + assert.equal(built.fee, '4079'); + assert.equal(built.envelope.length / 2, 4079); + assert.equal(built.transactionId, 'bda748b6ba9fd6550ca47d580f980a1c00d0050a20992750265a62d23b0b590d'); + + const migration = JSON.parse(quantum.build_migration(JSON.stringify({ + seed: 'hybrid-wallet-seed', + chainId: 'iuna-mainnet-candidate', + genesisHash: '11'.repeat(32), + destinationIndex: 2, + feeRate: '1', + utxos: [{ + outpoint: { txid: '33'.repeat(32), index: 4 }, + output: { amount: 2000000 }, + }], + }))); + assert.equal(migration.fee, '307'); + assert.equal(migration.envelope.length / 2, 307); + assert.equal(migration.transactionId, '37539c9d89a391c599b838c3f415e7adc93a36c22cd4fb5fe44a79433f336075'); +}); diff --git a/wallet/app.js b/wallet/app.js @@ -1,9 +1,14 @@ import { - API_BASE, LEGACY_STORAGE_KEY, STORAGE_KEY, api, buildSignedTransfer, decodeAddress, + API_BASE, LEGACY_STORAGE_KEY, STORAGE_KEY, api, buildSignedTransfer, decodeVersionedAddress, decryptWallet, encodeAddress, encryptWallet, formatIuna, hexToBytes, normalizeWalletStore, parseFeeRate, parseIuna, removeWallet, upsertWallet, walletFromSeed, walletId, } from "./wallet-core.js"; import { generateMnemonic, validateMnemonic } from "./mnemonic.js"; +import initQuantumCrypto, { + build_migration as buildQuantumMigration, + build_transfer as buildQuantumTransfer, + derive_external_addresses as deriveExternalAddresses, +} from "./crypto/iuna_wallet_crypto.js"; const app = document.querySelector("#app"); const toastElement = document.querySelector("#toast"); @@ -11,7 +16,9 @@ const TRANSACTION_PAGE_SIZE = 25; const ALL_TRANSACTION_FILTERS = { transfer: true, mine: true, burn: true, reward: true }; const state = { store: null, wallet: null, walletMeta: null, status: null, address: "", balance: null, - utxos: [], transactions: [], recentTransactions: [], view: "home", timer: null, + addresses: [], addressIndex: 0, legacyAddress: "", legacyUtxos: [], hybridUtxos: [], + derivedAddresses: [], derivedWalletId: null, + hybridSpendable: 0, utxos: [], transactions: [], recentTransactions: [], view: "home", timer: null, transactionFilters: { ...ALL_TRANSACTION_FILTERS }, transactionPage: { offset: 0, total: 0, hasMore: true, loading: false, error: "" }, transactionRequest: 0, @@ -19,6 +26,12 @@ const state = { selectedTransaction: null, transactionReturnView: "home", }; +let quantumCryptoPromise; + +async function ensureQuantumCrypto() { + quantumCryptoPromise ||= initQuantumCrypto(); + await quantumCryptoPromise; +} const icon = (name) => { const paths = { send: '<path d="M6 18 18 6M6 6h12v12"/>', @@ -129,50 +142,121 @@ async function saveAndOpen(seed, password, name) { async function openStoredWallet(meta) { state.walletMeta = meta; state.wallet = meta.type === "readonly" - ? { type: "readonly", publicKeyHex: meta.publicKeyHex, publicKey: Uint8Array.from(meta.publicKeyHex.match(/.{2}/g), (pair) => Number.parseInt(pair, 16)) } + ? { type: "readonly", publicKeyHex: meta.publicKeyHex, address: meta.address || "", publicKey: Uint8Array.from(meta.publicKeyHex.match(/.{2}/g), (pair) => Number.parseInt(pair, 16)) } : state.wallet; await openWallet(); } async function fetchWalletData() { + const previousAddress = state.address; state.status = await api("/status"); - const address = encodeAddress(state.wallet.publicKey, state.status.chain_id); - if (state.address && state.address !== address) { + state.legacyAddress = state.walletMeta?.type === "readonly" && state.wallet.address + ? state.wallet.address + : encodeAddress(state.wallet.publicKey, state.status.chain_id); + let snapshot; + if (state.walletMeta?.type === "readonly") { + const address = state.wallet.address || state.legacyAddress; + snapshot = await fetchSnapshot([address]); + state.addresses = [address]; + state.addressIndex = 0; + state.address = address; + } else if (state.status.transaction_v2_active) { + if (Number(state.status.api_version || 0) < 2) throw new Error("The public iuna endpoint must be upgraded for rotating quantum-resistant wallets"); + await ensureQuantumCrypto(); + const gapLimit = Math.max(1, Number(state.status.hybrid_address_gap_limit || 20)); + let count = gapLimit + 1; + let derived; + const derivationScope = `${state.walletMeta.id}:${state.status.chain_id}`; + if (state.derivedWalletId !== derivationScope) { + state.derivedAddresses = []; + state.derivedWalletId = derivationScope; + } + for (;;) { + if (state.derivedAddresses.length < count) { + state.derivedAddresses = JSON.parse(deriveExternalAddresses(state.wallet.seedPhrase, count, state.status.chain_id)); + } + derived = state.derivedAddresses.slice(0, count); + snapshot = await fetchSnapshot([state.legacyAddress, ...derived.map((item) => item.address)]); + const used = new Set(snapshot.addresses.filter((item) => item.used).map((item) => item.address)); + const highestUsed = derived.reduce((highest, item) => used.has(item.address) ? Math.max(highest, item.index) : highest, -1); + if (highestUsed < count - gapLimit || count >= 10_000) break; + count = Math.min(10_000, highestUsed + gapLimit + 1); + } + const used = new Set(snapshot.addresses.filter((item) => item.used).map((item) => item.address)); + const highestUsed = derived.reduce((highest, item) => used.has(item.address) ? Math.max(highest, item.index) : highest, -1); + state.addressIndex = highestUsed + 1; + const current = derived.find((item) => item.index === state.addressIndex); + if (!current) throw new Error("Wallet address discovery exceeded its recovery limit"); + state.addresses = [state.legacyAddress, ...derived.map((item) => item.address)]; + state.address = current.address; + const indexByAddress = new Map(derived.map((item) => [item.address, item.index])); + state.legacyUtxos = (snapshot.utxos || []).filter((utxo) => utxo.address === state.legacyAddress); + state.hybridUtxos = (snapshot.utxos || []).filter((utxo) => indexByAddress.has(utxo.address)).map((utxo) => ({ + ...utxo, + addressIndex: indexByAddress.get(utxo.address), + })); + state.hybridSpendable = snapshot.addresses.filter((item) => item.version === 1).reduce((sum, item) => sum + Number(item.spendable || 0), 0); + } else { + snapshot = await fetchSnapshot([state.legacyAddress]); + state.addresses = [state.legacyAddress]; + state.addressIndex = 0; + state.address = state.legacyAddress; + state.legacyUtxos = snapshot.utxos || []; + state.hybridUtxos = []; + state.hybridSpendable = 0; + } + const address = state.address; + if (previousAddress && previousAddress !== address) { state.transactions = []; state.recentTransactions = []; Object.assign(state.transactionPage, { offset: 0, total: 0, hasMore: true, loading: false, error: "" }); } state.address = address; - const encoded = encodeURIComponent(state.address); const previousTransactionCount = state.transactions.length; const transactionLimit = Math.min(100, Math.max(TRANSACTION_PAGE_SIZE, previousTransactionCount)); const refreshTransactions = !state.transactionPage.loading; const transactionRequest = refreshTransactions ? ++state.transactionRequest : null; - const [balance, utxos, transactions, recentTransactions] = await Promise.all([ - api(`/addresses/${encoded}/balance`), - api(`/addresses/${encoded}/utxos`), - refreshTransactions ? api(transactionPath(0, transactionLimit)) : Promise.resolve(null), - api(transactionPath(0, 5, ALL_TRANSACTION_FILTERS)), + const [transactions, recentTransactions] = await Promise.all([ + refreshTransactions ? fetchTransactions(0, transactionLimit) : Promise.resolve(null), + fetchTransactions(0, 5, ALL_TRANSACTION_FILTERS), ]); - state.balance = balance; - state.utxos = utxos.utxos || []; - state.recentTransactions = Array.isArray(recentTransactions?.items) ? recentTransactions.items : []; + state.balance = snapshot; + state.utxos = snapshot.utxos || []; + state.recentTransactions = normalizeTransactionItems(recentTransactions?.items); if (transactions && transactionRequest === state.transactionRequest) { + transactions.items = normalizeTransactionItems(transactions.items); applyTransactionPage(transactions, true, previousTransactionCount); } } -function transactionPath(offset, limit, filters = state.transactionFilters) { - const encoded = encodeURIComponent(state.address); - const params = new URLSearchParams({ - tx: String(filters.transfer), - mine: String(filters.mine), - burn: String(filters.burn), - reward: String(filters.reward), - offset: String(offset), - limit: String(limit), +function fetchSnapshot(addresses) { + return api("/wallets/snapshot", { method: "POST", body: JSON.stringify({ addresses }) }); +} + +function fetchTransactions(offset, limit, filters = state.transactionFilters) { + return api("/wallets/transactions", { + method: "POST", + body: JSON.stringify({ addresses: state.addresses, ...filters, offset, limit }), }); - return `/addresses/${encoded}/transactions?${params.toString()}`; +} + +function normalizeTransactionItems(items) { + return Array.isArray(items) ? items.map((row) => ({ + kind: row.kind === "migration" ? "transfer" : row.kind, + status: row.status, + block_height: row.blockHeight, + timestamp_ms: row.timestampMs, + direction: row.direction, + transaction: { + kind: row.kind, + inputs: row.inputs || [], + outputs: row.outputs || [], + change: row.change || [], + amount: row.amount || 0, + fee: row.fee || 0, + signature: row.signature, + }, + })) : []; } function transactionKey(item) { @@ -181,6 +265,7 @@ function transactionKey(item) { function networkAddress(publicKey) { if (!publicKey) return "Unknown"; + if (/^(t?iuna)1/i.test(publicKey)) return publicKey; try { return encodeAddress(hexToBytes(publicKey), state.status?.chain_id); } catch { @@ -229,8 +314,9 @@ async function loadTransactions({ replace = false } = {}) { if (state.view === "activity") renderApp(); try { const offset = replace ? 0 : state.transactionPage.offset; - const payload = await api(transactionPath(offset, TRANSACTION_PAGE_SIZE)); + const payload = await fetchTransactions(offset, TRANSACTION_PAGE_SIZE); if (request !== state.transactionRequest) return; + payload.items = normalizeTransactionItems(payload.items); applyTransactionPage(payload, replace); } catch (error) { if (request === state.transactionRequest) { @@ -281,15 +367,16 @@ function nav() { function transactionInfo(item) { const tx = item.transaction || {}; - const owner = state.wallet.publicKeyHex; + const owns = (address) => state.addresses.includes(address) || address === state.wallet.publicKeyHex; + if (tx.kind === "migration") return { title: "Migrated", incoming: true, amount: tx.amount || 0, symbol: "◇" }; if (item.kind === "reward") { - const amount = tx.outputs?.find((output) => output.address === owner)?.amount || 0; + const amount = tx.outputs?.filter((output) => owns(output.address)).reduce((sum, output) => sum + Number(output.amount || 0), 0) || 0; return { title: "Block reward", incoming: true, amount, symbol: "★" }; } if (tx.kind === "mine") return { title: "Mining reward", incoming: true, amount: 1_000_000, symbol: "✦" }; if (tx.kind === "burn") return { title: "Burn", incoming: false, amount: tx.amount || 0, symbol: "×" }; - const sent = tx.inputs?.some((input) => input.owner === owner); - const relevant = tx.outputs?.filter((output) => sent ? output.address !== owner : output.address === owner) || []; + const sent = tx.inputs?.some((input) => owns(input.owner)); + const relevant = tx.outputs?.filter((output) => sent ? !owns(output.address) : owns(output.address)) || []; return { title: sent ? "Sent" : "Received", incoming: !sent, amount: relevant.reduce((sum, output) => sum + Number(output.amount || 0), 0), symbol: sent ? "↗" : "↙" }; } @@ -369,17 +456,26 @@ function renderTransaction() { function renderHome() { const readonly = state.walletMeta?.type === "readonly"; - return `${topbar()}<section>${readonly ? '<div class="mode-badge">Watch-only · signing disabled</div>' : ""}<p class="eyebrow">Available balance</p><h1 class="balance">${formatIuna(state.balance?.spendable, 6)} <span>IUNA</span></h1><p class="subbalance">${formatIuna(state.balance?.confirmed, 6)} confirmed · block ${escapeHtml(state.balance?.height)}</p><div class="actions"><button class="button" data-view="send" ${readonly ? "disabled" : ""}>${icon("send")} Send</button><button class="button secondary" data-view="receive">${icon("receive")} Receive</button></div><div class="section-head"><h2>Recent activity</h2><button data-view="activity">View all</button></div><div class="panel">${activityList(5, false, state.recentTransactions)}</div></section>`; + const legacySpendable = state.legacyUtxos.reduce((sum, utxo) => sum + Number(utxo.output.amount || 0), 0); + const migration = !readonly && state.status?.transaction_v2_active && legacySpendable > 0 + ? `<div class="panel migration-card"><p class="eyebrow">Quantum-resistant wallet</p><h2>Migrate ${formatIuna(legacySpendable, 6)} IUNA</h2><p>Move the remaining legacy outputs into your rotating hybrid wallet.</p><button class="button" data-action="migrate">Migrate now</button></div>` + : ""; + return `${topbar()}<section>${readonly ? '<div class="mode-badge">Watch-only · signing disabled</div>' : ""}<p class="eyebrow">Available balance</p><h1 class="balance">${formatIuna(state.balance?.spendable, 6)} <span>IUNA</span></h1><p class="subbalance">${formatIuna(state.balance?.confirmed, 6)} confirmed · block ${escapeHtml(state.balance?.height)}</p><div class="actions"><button class="button" data-view="send" ${readonly ? "disabled" : ""}>${icon("send")} Send</button><button class="button secondary" data-view="receive">${icon("receive")} Receive</button></div>${migration}<div class="section-head"><h2>Recent activity</h2><button data-view="activity">View all</button></div><div class="panel">${activityList(5, false, state.recentTransactions)}</div></section>`; } function renderSend() { if (state.walletMeta?.type === "readonly") return `${topbar()}<p class="eyebrow">Watch-only</p><h1 class="view-title">Sending is disabled.</h1><p class="view-copy">This wallet contains no seed or private key, so it cannot sign transactions.</p><button class="button secondary" data-view="home" style="width:100%">Back to overview</button>`; const defaultFeeRate = state.status.default_fee_per_byte ?? 1; - return `${topbar()}<p class="eyebrow">Transaction</p><h1 class="view-title">Send IUNA</h1><p class="view-copy">The transaction is signed on this device.</p><form id="send-form" class="panel send-card"><div class="field"><label for="recipient">Recipient</label><input id="recipient" autocomplete="off" autocapitalize="none" spellcheck="false" placeholder="iuna1q…" required></div><div class="field"><label for="amount">Amount</label><div class="amount-wrap"><input id="amount" inputmode="decimal" placeholder="0.00" required><span>IUNA</span></div></div><div class="field"><label for="fee-rate">Fee rate (µIUNA per byte)</label><input id="fee-rate" name="fee-rate" type="number" inputmode="numeric" min="1" step="1" value="${escapeHtml(defaultFeeRate)}" required></div><div class="fee-line"><span>Available</span><strong>${formatIuna(state.balance?.spendable)} IUNA</strong></div><button class="button" type="submit">Review transaction</button></form>`; + const available = state.status?.transaction_v2_active ? state.hybridSpendable : state.balance?.spendable; + return `${topbar()}<p class="eyebrow">Transaction</p><h1 class="view-title">Send IUNA</h1><p class="view-copy">The transaction is signed on this device with your hybrid quantum-resistant key.</p><form id="send-form" class="panel send-card"><div class="field"><label for="recipient">Recipient</label><input id="recipient" autocomplete="off" autocapitalize="none" spellcheck="false" placeholder="iuna1p…" required></div><div class="field"><label for="amount">Amount</label><div class="amount-wrap"><input id="amount" inputmode="decimal" placeholder="0.00" required><span>IUNA</span></div></div><div class="field"><label for="fee-rate">Fee rate (µIUNA per byte)</label><input id="fee-rate" name="fee-rate" type="number" inputmode="numeric" min="1" step="1" value="${escapeHtml(defaultFeeRate)}" required></div><div class="fee-line"><span>Available</span><strong>${formatIuna(available)} IUNA</strong></div><button class="button" type="submit">Review transaction</button></form>`; } function renderReceive() { - return `${topbar()}<p class="eyebrow">Your address</p><h1 class="view-title">Receive IUNA</h1><p class="view-copy">Share this mainnet address with the sender.</p><div class="panel"><div class="receive-emblem">${icon("receive")}</div><p class="eyebrow">Receiving address</p><div class="address-box"><code>${escapeHtml(state.address)}</code><button class="icon-button" data-action="copy-address" aria-label="Copy address">${icon("copy")}</button></div><p class="security-note">Always verify the first and last characters when sharing an address.</p></div>`; + const readonly = state.walletMeta?.type === "readonly"; + const explanation = readonly + ? "This watch-only entry follows this address only; it cannot derive future rotating addresses." + : "This address rotates automatically after funds arrive. Previous addresses remain part of your wallet."; + return `${topbar()}<p class="eyebrow">Your address</p><h1 class="view-title">Receive IUNA</h1><p class="view-copy">Share this current mainnet address with the sender.</p><div class="panel"><div class="receive-emblem">${icon("receive")}</div><p class="eyebrow">${readonly ? "Watched address" : "Current receiving address"}</p><div class="address-box"><code>${escapeHtml(state.address)}</code><button class="icon-button" data-action="copy-address" aria-label="Copy address">${icon("copy")}</button></div><p class="security-note">${explanation}</p></div>`; } function renderActivity() { @@ -419,7 +515,12 @@ function renderConfirmation(transaction, fee, recipientAddress, amount) { const button = event.currentTarget; button.disabled = true; button.innerHTML = '<span class="spinner"></span> Sending…'; try { - const result = await api("/transactions", { method: "POST", body: JSON.stringify(transaction) }); + const isV2 = typeof transaction.envelope === "string"; + const result = await api(isV2 ? "/transactions-v2" : "/transactions", { + method: "POST", + body: JSON.stringify(isV2 ? { envelope: transaction.envelope } : transaction), + timeoutMs: 20_000, + }); toast(result.status === "accepted" ? "Transaction sent" : "Transaction was already known"); state.view = "home"; await fetchWalletData(); @@ -482,6 +583,28 @@ app.addEventListener("click", async (event) => { if (action === "seed-saved") renderPasswordSetup(); if (action === "copy-seed") { await navigator.clipboard.writeText(app.dataset.pendingSeed); toast("Seed copied — clear your clipboard after use"); } if (action === "copy-address") { await navigator.clipboard.writeText(state.address); toast("Address copied"); } + if (action === "migrate") { + button.disabled = true; + button.innerHTML = '<span class="spinner"></span> Preparing…'; + try { + await ensureQuantumCrypto(); + const built = JSON.parse(buildQuantumMigration(JSON.stringify({ + seed: state.wallet.seedPhrase, + chainId: state.status.chain_id, + genesisHash: state.status.genesis_hash, + destinationIndex: state.addressIndex, + feeRate: String(state.status.default_fee_per_byte || 1), + utxos: state.legacyUtxos, + }))); + const amount = state.legacyUtxos.reduce((sum, utxo) => sum + BigInt(utxo.output.amount), 0n) - BigInt(built.fee); + renderConfirmation(built, BigInt(built.fee), state.address, amount); + } catch (error) { + toast(error.message || String(error), true); + button.disabled = false; + button.textContent = "Migrate now"; + } + return; + } if (action === "retry") await openWallet(); if (action === "wallets") { window.clearInterval(state.timer); state.wallet = null; state.walletMeta = null; state.view = "home"; renderWalletPicker(); } if (action === "lock") { @@ -523,20 +646,36 @@ app.addEventListener("submit", async (event) => { const address = form["watch-address"].value.trim().toLowerCase(); const walletName = form["wallet-name"].value.trim(); if (!walletName) throw new Error("Enter a wallet name"); - const publicKeyHex = decodeAddress(address, "iuna"); - if (state.store.wallets.some((wallet) => wallet.publicKeyHex === publicKeyHex)) throw new Error("This wallet is already on this device"); - const meta = { id: walletId(publicKeyHex), name: walletName, type: "readonly", publicKeyHex }; + const decoded = decodeVersionedAddress(address, "iuna"); + const publicKeyHex = decoded.payloadHex; + if (state.store.wallets.some((wallet) => (wallet.address || "").toLowerCase() === address || (!wallet.address && wallet.publicKeyHex === publicKeyHex))) throw new Error("This wallet is already on this device"); + const meta = { id: `watch-${decoded.version}-${publicKeyHex}`, name: walletName, type: "readonly", publicKeyHex, address }; saveStore(upsertWallet(state.store, meta)); await openStoredWallet(meta); } else if (form.id === "send-form") { if (state.walletMeta?.type === "readonly") throw new Error("Watch-only wallets cannot sign transactions"); const amount = parseIuna(form.amount.value); const feeRate = parseFeeRate(form["fee-rate"].value); - const built = await buildSignedTransfer({ wallet: state.wallet, status: state.status, utxos: state.utxos, recipientAddress: form.recipient.value, amount, feeRate }); - renderConfirmation(built.transaction, built.fee, form.recipient.value.trim(), amount); + if (state.status.transaction_v2_active) { + await ensureQuantumCrypto(); + const built = JSON.parse(buildQuantumTransfer(JSON.stringify({ + seed: state.wallet.seedPhrase, + chainId: state.status.chain_id, + genesisHash: state.status.genesis_hash, + recipientAddress: form.recipient.value.trim(), + amount: amount.toString(), + feeRate: feeRate.toString(), + changeIndex: state.addressIndex, + utxos: state.hybridUtxos, + }))); + renderConfirmation(built, BigInt(built.fee), form.recipient.value.trim(), amount); + } else { + const built = await buildSignedTransfer({ wallet: state.wallet, status: state.status, utxos: state.utxos, recipientAddress: form.recipient.value, amount, feeRate }); + renderConfirmation(built.transaction, built.fee, form.recipient.value.trim(), amount); + } } } catch (error) { - toast(error.message, true); button.disabled = false; button.textContent = original; + toast(error?.message || String(error), true); button.disabled = false; button.textContent = original; } }); diff --git a/wallet/crypto-wasm/.gitignore b/wallet/crypto-wasm/.gitignore @@ -0,0 +1 @@ +/target diff --git a/wallet/crypto-wasm/Cargo.lock b/wallet/crypto-wasm/Cargo.lock @@ -0,0 +1,594 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cmov" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a" + +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + +[[package]] +name = "const-oid" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "cpufeatures" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566" +dependencies = [ + "libc", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "crypto-common" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "ctutils" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e" +dependencies = [ + "cmov", +] + +[[package]] +name = "curve25519-dalek" +version = "4.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "curve25519-dalek-derive", + "digest 0.10.7", + "fiat-crypto", + "rustc_version", + "subtle", + "zeroize", +] + +[[package]] +name = "curve25519-dalek-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid 0.9.6", + "zeroize", +] + +[[package]] +name = "der" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a878c850e9e421b20262e9b41f9c860e4785fa07541c266b62ff9d1ef998a80a" +dependencies = [ + "const-oid 0.10.2", + "zeroize", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common 0.1.7", +] + +[[package]] +name = "digest" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" +dependencies = [ + "crypto-common 0.2.2", +] + +[[package]] +name = "ed25519" +version = "2.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" +dependencies = [ + "pkcs8 0.10.2", + "signature 2.2.0", +] + +[[package]] +name = "ed25519-dalek" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9" +dependencies = [ + "curve25519-dalek", + "ed25519", + "serde", + "sha2", + "subtle", + "zeroize", +] + +[[package]] +name = "fiat-crypto" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "hybrid-array" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27f864f10dfb56725ce5ce5472bc52252c8f93a4ab86327122cebf62c5f59a17" +dependencies = [ + "ctutils", + "typenum", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "iuna-wallet-crypto" +version = "0.1.0" +dependencies = [ + "ed25519-dalek", + "ml-dsa", + "serde", + "serde_json", + "sha2", + "wasm-bindgen", +] + +[[package]] +name = "keccak" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8f198d1db720e4940b5a493201d199d9f24f568f8f746bd13706243a2f71598" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.1", +] + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "ml-dsa" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "add6b9d92e496f16f4526d68ff29da1483aba4b119baeab8bed3b9e3544a6f3d" +dependencies = [ + "crypto-common 0.2.2", + "ctutils", + "hybrid-array", + "module-lattice", + "pkcs8 0.11.0", + "shake", + "signature 3.0.0", +] + +[[package]] +name = "module-lattice" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c61b87c9683ab7cb1c6871d261ad5479b6b10ceb52c4352aaca3b5d35a8febe" +dependencies = [ + "ctutils", + "hybrid-array", + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der 0.7.10", + "spki 0.7.3", +] + +[[package]] +name = "pkcs8" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "451913da69c775a56034ea8d9003d27ee8948e12443eae7c038ba100a4f21cb7" +dependencies = [ + "der 0.8.2", + "spki 0.8.0", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom", +] + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest 0.10.7", +] + +[[package]] +name = "shake" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09057cb2149ad4cbd2da1e26b351f9a4c354219421229c69c3063e6f61947c4a" +dependencies = [ + "digest 0.11.3", + "keccak", + "sponge-cursor", +] + +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "rand_core", +] + +[[package]] +name = "signature" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28d567dcbaf0049cb8ac2608a76cd95ff9e4412e1899d389ee400918ca7537f5" +dependencies = [ + "digest 0.11.3", +] + +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der 0.7.10", +] + +[[package]] +name = "spki" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d9efca8738c78ee9484207732f728b1ef517bbb1833d6fc0879ca898a522f6f" +dependencies = [ + "base64ct", + "der 0.8.2", +] + +[[package]] +name = "sponge-cursor" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a0219bd7d979d58245a4f41f695e1ac9f8befdffadd7f61f1bae9e39abc6620" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12df2e0110f65b775f769bb17ef989067a1d931b2eb822bd4346631eeada89f9" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasm-bindgen" +version = "0.2.121" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49ace1d07c165b0864824eee619580c4689389afa9dc9ed3a4c75040d82e6790" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.121" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e68e6f4afd367a562002c05637acb8578ff2dea1943df76afb9e83d177c8578" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.121" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d95a9ec35c64b2a7cb35d3fead40c4238d0940c86d107136999567a4703259f2" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.119", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.121" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4e0100b01e9f0d03189a92b96772a1fb998639d981193d7dbab487302513441" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/wallet/crypto-wasm/Cargo.toml b/wallet/crypto-wasm/Cargo.toml @@ -0,0 +1,28 @@ +[package] +name = "iuna-wallet-crypto" +version = "0.1.0" +edition = "2024" +publish = false +license = "Apache-2.0" +description = "Deterministic hybrid wallet cryptography for the iuna browser wallet" +repository = "https://github.com/jhartog/iuna" + +[lib] +crate-type = ["cdylib"] + +[dependencies] +ed25519-dalek = "2.2.0" +ml-dsa = { version = "=0.1.1", default-features = false, features = ["alloc"] } +serde = { version = "1.0.228", features = ["derive"] } +serde_json = "1.0.150" +sha2 = "0.10.9" +wasm-bindgen = "=0.2.121" + +[profile.release] +opt-level = "s" +lto = true +codegen-units = 1 +strip = true + +[package.metadata.wasm-pack.profile.release] +wasm-opt = false diff --git a/wallet/crypto-wasm/src/lib.rs b/wallet/crypto-wasm/src/lib.rs @@ -0,0 +1,607 @@ +use ed25519_dalek::{Signer, SigningKey}; +use ml_dsa::{Keypair, MlDsa44, Seed, SigningKey as MlDsaSigningKey}; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use wasm_bindgen::prelude::*; + +const TX_TAG: &[u8] = b"IUNA-TX-V2"; +const ADDRESS_TAG: &[u8] = b"IUNA-ADDRESS-V1"; +const ML_SEED_DOMAIN: &str = "iuna-wallet-ml-dsa44-seed-v1"; +const ML_CHILD_DOMAIN: &str = "iuna-wallet-ml-dsa44-child-seed-v1"; +const ED_SEED_DOMAIN: &str = "iuna-wallet-seed"; +const BECH32M: u32 = 0x2bc8_30a3; +const CHARSET: &[u8; 32] = b"qpzry9x8gf2tvdw0s3jn54khce6mua7l"; +const HYBRID_PUBLIC_KEY_BYTES: usize = 1_344; +const HYBRID_SIGNATURE_BYTES: usize = 2_484; +const MAX_BLOCK_BYTES: usize = 1_000_000; + +#[derive(Clone, Copy)] +struct Address { + version: u8, + payload: [u8; 32], +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct DerivedAddress { + index: u32, + address: String, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct TransferRequest { + seed: String, + chain_id: String, + genesis_hash: String, + recipient_address: String, + amount: String, + fee_rate: String, + change_index: u32, + utxos: Vec<HybridUtxo>, +} + +#[derive(Deserialize, Clone)] +#[serde(rename_all = "camelCase")] +struct HybridUtxo { + address_index: u32, + outpoint: Outpoint, + output: Output, +} + +#[derive(Deserialize, Clone)] +struct Outpoint { + txid: String, + index: u32, +} + +#[derive(Deserialize, Clone)] +struct Output { + amount: u64, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct MigrationRequest { + seed: String, + chain_id: String, + genesis_hash: String, + destination_index: u32, + fee_rate: String, + utxos: Vec<LegacyUtxo>, +} + +#[derive(Deserialize, Clone)] +struct LegacyUtxo { + outpoint: Outpoint, + output: Output, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct BuiltTransaction { + envelope: String, + transaction_id: String, + fee: String, + input_count: usize, +} + +#[wasm_bindgen] +pub fn derive_external_addresses( + seed: &str, + count: u32, + network_id: &str, +) -> Result<String, JsValue> { + if count == 0 || count > 10_000 { + return Err(js_error("address count must be between 1 and 10000")); + } + let addresses = (0..count) + .map(|index| { + let (_, _, address) = hybrid_key(seed, index); + DerivedAddress { + index, + address: encode_address(address, network_id), + } + }) + .collect::<Vec<_>>(); + serde_json::to_string(&addresses).map_err(js_error) +} + +#[wasm_bindgen] +pub fn build_transfer(request_json: &str) -> Result<String, JsValue> { + let request: TransferRequest = serde_json::from_str(request_json).map_err(js_error)?; + let amount = parse_u64(&request.amount, "amount")?; + let fee_rate = parse_positive_u64(&request.fee_rate, "fee rate")?; + if amount == 0 { + return Err(js_error("amount must be greater than zero")); + } + let recipient = decode_address(&request.recipient_address, &request.chain_id)?; + if recipient.version != 1 { + return Err(js_error("hybrid transfers require an address-v1 recipient")); + } + let genesis_hash = decode_array::<32>(&request.genesis_hash, "genesis hash")?; + let mut available = request.utxos; + available.sort_by(|left, right| { + right + .output + .amount + .cmp(&left.output.amount) + .then_with(|| left.outpoint.txid.cmp(&right.outpoint.txid)) + .then_with(|| left.outpoint.index.cmp(&right.outpoint.index)) + }); + + let mut fee = 1_u64; + let (selected, total, outputs) = loop { + let required = amount + .checked_add(fee) + .ok_or_else(|| js_error("amount plus fee overflows"))?; + let mut total = 0_u64; + let mut selected = Vec::new(); + for utxo in &available { + total = total + .checked_add(utxo.output.amount) + .ok_or_else(|| js_error("input total overflows"))?; + selected.push(utxo.clone()); + if total >= required { + break; + } + } + if total < required { + return Err(js_error( + "insufficient hybrid funds for the amount and network fee", + )); + } + let mut outputs = vec![(recipient, amount)]; + let change = total - required; + if change > 0 { + let (_, _, change_address) = hybrid_key(&request.seed, request.change_index); + outputs.push((change_address, change)); + } + let size = transfer_encoded_size(&request.chain_id, selected.len(), outputs.len()); + if size > MAX_BLOCK_BYTES { + return Err(js_error( + "transaction exceeds the block budget; send a smaller amount or consolidate first", + )); + } + let required_fee = fee_rate + .checked_mul(size as u64) + .ok_or_else(|| js_error("fee overflows"))? + .max(1); + if fee >= required_fee { + break (selected, total, outputs); + } + fee = required_fee; + }; + if total < amount + fee { + return Err(js_error("insufficient hybrid funds after fee calculation")); + } + + let mut signing = encode_prefix(&request.chain_id, genesis_hash)?; + signing.push(1); + push_u32(&mut signing, selected.len())?; + for input in &selected { + signing.extend_from_slice(&decode_array::<32>( + &input.outpoint.txid, + "v2 transaction ID", + )?); + signing.extend_from_slice(&input.outpoint.index.to_be_bytes()); + let (_, _, owner) = hybrid_key(&request.seed, input.address_index); + encode_address_bytes(&mut signing, owner); + } + encode_outputs(&mut signing, &outputs)?; + signing.extend_from_slice(&fee.to_be_bytes()); + + let mut envelope = signing.clone(); + push_u32(&mut envelope, selected.len())?; + for input in &selected { + let (public_key, ml_seed, _) = hybrid_key(&request.seed, input.address_index); + let signature = hybrid_signature(&request.seed, ml_seed, &signing)?; + encode_authorization(&mut envelope, 2, &public_key, &signature)?; + } + built_json(envelope, fee, selected.len()) +} + +#[wasm_bindgen] +pub fn build_migration(request_json: &str) -> Result<String, JsValue> { + let request: MigrationRequest = serde_json::from_str(request_json).map_err(js_error)?; + let fee_rate = parse_positive_u64(&request.fee_rate, "fee rate")?; + if request.utxos.is_empty() { + return Err(js_error("no legacy outputs are available for migration")); + } + if request.utxos.len() > 1_000 { + return Err(js_error("a migration can contain at most 1000 inputs")); + } + let genesis_hash = decode_array::<32>(&request.genesis_hash, "genesis hash")?; + let total = request.utxos.iter().try_fold(0_u64, |sum, utxo| { + sum.checked_add(utxo.output.amount) + .ok_or_else(|| js_error("input total overflows")) + })?; + let encoded_size = migration_encoded_size(&request.chain_id, &request.utxos); + if encoded_size > MAX_BLOCK_BYTES { + return Err(js_error( + "migration exceeds the block budget; migrate fewer legacy outputs at a time", + )); + } + let fee = fee_rate + .checked_mul(encoded_size as u64) + .ok_or_else(|| js_error("fee overflows"))? + .max(1); + let migrated = total + .checked_sub(fee) + .filter(|amount| *amount > 0) + .ok_or_else(|| js_error("migration fee exceeds the available legacy balance"))?; + let ed_seed = derive_seed(ED_SEED_DOMAIN, &request.seed); + let ed_public = SigningKey::from_bytes(&ed_seed).verifying_key().to_bytes(); + let (_, _, destination) = hybrid_key(&request.seed, request.destination_index); + + let mut signing = encode_prefix(&request.chain_id, genesis_hash)?; + signing.push(4); + push_u32(&mut signing, request.utxos.len())?; + for input in &request.utxos { + let id = decode_hex(&input.outpoint.txid, "legacy transaction ID")?; + match id.len() { + 32 => signing.push(0), + 64 => signing.push(1), + _ => { + return Err(js_error( + "legacy transaction ID must contain 32 or 64 bytes", + )); + } + } + signing.extend_from_slice(&id); + signing.extend_from_slice(&input.outpoint.index.to_be_bytes()); + encode_address_bytes( + &mut signing, + Address { + version: 0, + payload: ed_public, + }, + ); + } + encode_outputs(&mut signing, &[(destination, migrated)])?; + signing.extend_from_slice(&fee.to_be_bytes()); + + let signature = SigningKey::from_bytes(&ed_seed).sign(&signing).to_bytes(); + let mut envelope = signing; + push_u32(&mut envelope, request.utxos.len())?; + for _ in &request.utxos { + encode_authorization(&mut envelope, 0, &ed_public, &signature)?; + } + built_json(envelope, fee, request.utxos.len()) +} + +fn hybrid_key(seed: &str, index: u32) -> ([u8; HYBRID_PUBLIC_KEY_BYTES], [u8; 32], Address) { + let ed_seed = derive_seed(ED_SEED_DOMAIN, seed); + let parent_ml_seed = derive_seed(ML_SEED_DOMAIN, seed); + let ml_seed = if index == 0 { + parent_ml_seed + } else { + derive_child_seed(parent_ml_seed, index) + }; + let ed_public = SigningKey::from_bytes(&ed_seed).verifying_key().to_bytes(); + let ml_public = ml_public_key(ml_seed); + let mut public_key = [0_u8; HYBRID_PUBLIC_KEY_BYTES]; + public_key[..32].copy_from_slice(&ed_public); + public_key[32..].copy_from_slice(&ml_public); + let mut hasher = Sha256::new(); + hasher.update(ADDRESS_TAG); + hasher.update([2]); + hasher.update(32_u32.to_be_bytes()); + hasher.update(ed_public); + hasher.update(1_312_u32.to_be_bytes()); + hasher.update(ml_public); + let address = Address { + version: 1, + payload: hasher.finalize().into(), + }; + (public_key, ml_seed, address) +} + +fn hybrid_signature( + seed: &str, + ml_seed: [u8; 32], + payload: &[u8], +) -> Result<[u8; HYBRID_SIGNATURE_BYTES], JsValue> { + let ed_seed = derive_seed(ED_SEED_DOMAIN, seed); + let ed = SigningKey::from_bytes(&ed_seed).sign(payload).to_bytes(); + let ml_key = MlDsaSigningKey::<MlDsa44>::from_seed(&Seed::from(ml_seed)); + let ml = ml_key + .expanded_key() + .sign_deterministic(payload, &[]) + .map_err(|_| js_error("failed to create ML-DSA-44 signature"))? + .encode(); + let mut signature = [0_u8; HYBRID_SIGNATURE_BYTES]; + signature[..64].copy_from_slice(&ed); + signature[64..].copy_from_slice(ml.as_slice()); + Ok(signature) +} + +fn ml_public_key(seed: [u8; 32]) -> [u8; 1_312] { + MlDsaSigningKey::<MlDsa44>::from_seed(&Seed::from(seed)) + .verifying_key() + .encode() + .as_slice() + .try_into() + .expect("fixed ML-DSA public key") +} + +fn derive_seed(domain: &str, seed: &str) -> [u8; 32] { + let mut hasher = Sha256::new(); + hasher.update(domain.as_bytes()); + hasher.update(b":"); + hasher.update(seed.as_bytes()); + hasher.finalize().into() +} + +fn derive_child_seed(parent: [u8; 32], index: u32) -> [u8; 32] { + let mut hasher = Sha256::new(); + hasher.update(ML_CHILD_DOMAIN.as_bytes()); + hasher.update(b":external:"); + hasher.update(index.to_be_bytes()); + hasher.update(b":"); + hasher.update(parent); + hasher.finalize().into() +} + +fn encode_prefix(chain_id: &str, genesis_hash: [u8; 32]) -> Result<Vec<u8>, JsValue> { + if chain_id.is_empty() || chain_id.len() > 64 || !chain_id.is_ascii() { + return Err(js_error("invalid transaction v2 chain ID")); + } + let mut bytes = Vec::new(); + bytes.extend_from_slice(TX_TAG); + bytes.extend_from_slice(&2_u16.to_be_bytes()); + push_bytes(&mut bytes, chain_id.as_bytes())?; + bytes.extend_from_slice(&genesis_hash); + Ok(bytes) +} + +fn encode_outputs(bytes: &mut Vec<u8>, outputs: &[(Address, u64)]) -> Result<(), JsValue> { + push_u32(bytes, outputs.len())?; + for (address, amount) in outputs { + if *amount == 0 { + return Err(js_error("transaction outputs must be greater than zero")); + } + encode_address_bytes(bytes, *address); + bytes.extend_from_slice(&amount.to_be_bytes()); + } + Ok(()) +} + +fn encode_address_bytes(bytes: &mut Vec<u8>, address: Address) { + bytes.push(address.version); + bytes.extend_from_slice(&address.payload); +} + +fn encode_authorization( + bytes: &mut Vec<u8>, + scheme: u8, + public_key: &[u8], + signature: &[u8], +) -> Result<(), JsValue> { + bytes.push(scheme); + push_bytes(bytes, public_key)?; + push_bytes(bytes, signature) +} + +fn transfer_encoded_size(chain_id: &str, inputs: usize, outputs: usize) -> usize { + TX_TAG.len() + + 2 + + 4 + + chain_id.len() + + 32 + + 1 + + 4 + + inputs * 69 + + 4 + + outputs * 41 + + 8 + + 4 + + inputs * (1 + 4 + HYBRID_PUBLIC_KEY_BYTES + 4 + HYBRID_SIGNATURE_BYTES) +} + +fn migration_encoded_size(chain_id: &str, inputs: &[LegacyUtxo]) -> usize { + let input_bytes = inputs + .iter() + .map(|input| 1 + input.outpoint.txid.len() / 2 + 4 + 33) + .sum::<usize>(); + TX_TAG.len() + + 2 + + 4 + + chain_id.len() + + 32 + + 1 + + 4 + + input_bytes + + 4 + + 41 + + 8 + + 4 + + inputs.len() * (1 + 4 + 32 + 4 + 64) +} + +fn built_json(envelope: Vec<u8>, fee: u64, input_count: usize) -> Result<String, JsValue> { + let transaction_id = hex(&Sha256::digest(&envelope)); + serde_json::to_string(&BuiltTransaction { + envelope: hex(&envelope), + transaction_id, + fee: fee.to_string(), + input_count, + }) + .map_err(js_error) +} + +fn encode_address(address: Address, network_id: &str) -> String { + let hrp = if network_id.contains("testnet") || network_id.contains("e2e") { + "tiuna" + } else { + "iuna" + }; + let mut data = vec![address.version]; + data.extend(convert_bits(&address.payload, 8, 5, true).expect("fixed payload converts")); + let mut values = hrp_expand(hrp); + values.extend_from_slice(&data); + values.extend_from_slice(&[0; 6]); + let checksum = polymod(&values) ^ BECH32M; + let encoded = data + .into_iter() + .chain((0..6).map(|index| ((checksum >> (5 * (5 - index))) & 31) as u8)) + .map(|value| CHARSET[value as usize] as char) + .collect::<String>(); + format!("{hrp}1{encoded}") +} + +fn decode_address(value: &str, network_id: &str) -> Result<Address, JsValue> { + let value = value.trim(); + if value.is_empty() || value.len() > 90 || !value.is_ascii() { + return Err(js_error("address length or encoding is invalid")); + } + let has_lower = value.bytes().any(|byte| byte.is_ascii_lowercase()); + let has_upper = value.bytes().any(|byte| byte.is_ascii_uppercase()); + if has_lower && has_upper { + return Err(js_error( + "address must not mix uppercase and lowercase characters", + )); + } + let expected = if network_id.contains("testnet") || network_id.contains("e2e") { + "tiuna" + } else { + "iuna" + }; + let normalized = value.to_ascii_lowercase(); + let separator = normalized + .rfind('1') + .ok_or_else(|| js_error("address is missing its separator"))?; + if &normalized[..separator] != expected { + return Err(js_error("address belongs to another network")); + } + let data = normalized[separator + 1..] + .bytes() + .map(|byte| { + CHARSET + .iter() + .position(|candidate| *candidate == byte) + .map(|index| index as u8) + .ok_or_else(|| js_error("address contains an invalid character")) + }) + .collect::<Result<Vec<_>, _>>()?; + let mut checksum_values = hrp_expand(expected); + checksum_values.extend_from_slice(&data); + if polymod(&checksum_values) != BECH32M || data.len() < 7 { + return Err(js_error("address checksum is invalid")); + } + let payload = &data[..data.len() - 6]; + let version = *payload + .first() + .ok_or_else(|| js_error("address payload is empty"))?; + if version > 1 { + return Err(js_error("unsupported address version")); + } + let bytes = convert_bits(&payload[1..], 5, 8, false)?; + if bytes.len() != 32 { + return Err(js_error("address payload must contain 32 bytes")); + } + Ok(Address { + version, + payload: bytes.try_into().expect("checked address length"), + }) +} + +fn hrp_expand(hrp: &str) -> Vec<u8> { + hrp.bytes() + .map(|byte| byte >> 5) + .chain(std::iter::once(0)) + .chain(hrp.bytes().map(|byte| byte & 31)) + .collect() +} + +fn polymod(values: &[u8]) -> u32 { + let generators = [0x3b6a57b2, 0x26508e6d, 0x1ea119fa, 0x3d4233dd, 0x2a1462b3]; + let mut checksum = 1_u32; + for value in values { + let top = checksum >> 25; + checksum = ((checksum & 0x1ff_ffff) << 5) ^ u32::from(*value); + for (index, generator) in generators.iter().enumerate() { + if (top >> index) & 1 == 1 { + checksum ^= generator; + } + } + } + checksum +} + +fn convert_bits(data: &[u8], from: u32, to: u32, pad: bool) -> Result<Vec<u8>, JsValue> { + let mut accumulator = 0_u32; + let mut bits = 0_u32; + let mut result = Vec::new(); + let max = (1_u32 << to) - 1; + for value in data { + if u32::from(*value) >> from != 0 { + return Err(js_error("invalid address data")); + } + accumulator = + ((accumulator << from) | u32::from(*value)) & ((1_u32 << (from + to - 1)) - 1); + bits += from; + while bits >= to { + bits -= to; + result.push(((accumulator >> bits) & max) as u8); + } + } + if pad && bits > 0 { + result.push(((accumulator << (to - bits)) & max) as u8); + } else if !pad && (bits >= from || ((accumulator << (to - bits)) & max) != 0) { + return Err(js_error("invalid address padding")); + } + Ok(result) +} + +fn push_u32(bytes: &mut Vec<u8>, value: usize) -> Result<(), JsValue> { + let value = u32::try_from(value).map_err(|_| js_error("value exceeds the wire limit"))?; + bytes.extend_from_slice(&value.to_be_bytes()); + Ok(()) +} + +fn push_bytes(bytes: &mut Vec<u8>, value: &[u8]) -> Result<(), JsValue> { + push_u32(bytes, value.len())?; + bytes.extend_from_slice(value); + Ok(()) +} + +fn parse_u64(value: &str, label: &str) -> Result<u64, JsValue> { + value + .parse::<u64>() + .map_err(|_| js_error(format!("{label} is invalid"))) +} + +fn parse_positive_u64(value: &str, label: &str) -> Result<u64, JsValue> { + parse_u64(value, label).and_then(|value| { + if value == 0 { + Err(js_error(format!("{label} must be greater than zero"))) + } else { + Ok(value) + } + }) +} + +fn decode_array<const N: usize>(value: &str, label: &str) -> Result<[u8; N], JsValue> { + decode_hex(value, label)? + .try_into() + .map_err(|_| js_error(format!("{label} must contain {N} bytes"))) +} + +fn decode_hex(value: &str, label: &str) -> Result<Vec<u8>, JsValue> { + if value.len() % 2 != 0 || !value.bytes().all(|byte| byte.is_ascii_hexdigit()) { + return Err(js_error(format!("{label} is not hexadecimal"))); + } + (0..value.len()) + .step_by(2) + .map(|index| u8::from_str_radix(&value[index..index + 2], 16).map_err(js_error)) + .collect() +} + +fn hex(bytes: &[u8]) -> String { + bytes.iter().map(|byte| format!("{byte:02x}")).collect() +} + +fn js_error(error: impl std::fmt::Display) -> JsValue { + JsValue::from_str(&error.to_string()) +} diff --git a/wallet/crypto/.gitignore b/wallet/crypto/.gitignore @@ -0,0 +1,7 @@ +* +!.gitignore +!package.json +!iuna_wallet_crypto.js +!iuna_wallet_crypto.d.ts +!iuna_wallet_crypto_bg.wasm +!iuna_wallet_crypto_bg.wasm.d.ts diff --git a/wallet/crypto/iuna_wallet_crypto.d.ts b/wallet/crypto/iuna_wallet_crypto.d.ts @@ -0,0 +1,43 @@ +/* tslint:disable */ +/* eslint-disable */ + +export function build_migration(request_json: string): string; + +export function build_transfer(request_json: string): string; + +export function derive_external_addresses(seed: string, count: number, network_id: string): string; + +export type InitInput = RequestInfo | URL | Response | BufferSource | WebAssembly.Module; + +export interface InitOutput { + readonly memory: WebAssembly.Memory; + readonly derive_external_addresses: (a: number, b: number, c: number, d: number, e: number, f: number) => void; + readonly build_transfer: (a: number, b: number, c: number) => void; + readonly build_migration: (a: number, b: number, c: number) => void; + readonly __wbindgen_export: (a: number, b: number) => number; + readonly __wbindgen_export2: (a: number, b: number, c: number, d: number) => number; + readonly __wbindgen_add_to_stack_pointer: (a: number) => number; + readonly __wbindgen_export3: (a: number, b: number, c: number) => void; +} + +export type SyncInitInput = BufferSource | WebAssembly.Module; + +/** + * Instantiates the given `module`, which can either be bytes or + * a precompiled `WebAssembly.Module`. + * + * @param {{ module: SyncInitInput }} module - Passing `SyncInitInput` directly is deprecated. + * + * @returns {InitOutput} + */ +export function initSync(module: { module: SyncInitInput } | SyncInitInput): InitOutput; + +/** + * If `module_or_path` is {RequestInfo} or {URL}, makes a request and + * for everything else, calls `WebAssembly.instantiate` directly. + * + * @param {{ module_or_path: InitInput | Promise<InitInput> }} module_or_path - Passing `InitInput` directly is deprecated. + * + * @returns {Promise<InitOutput>} + */ +export default function __wbg_init (module_or_path?: { module_or_path: InitInput | Promise<InitInput> } | InitInput | Promise<InitInput>): Promise<InitOutput>; diff --git a/wallet/crypto/iuna_wallet_crypto.js b/wallet/crypto/iuna_wallet_crypto.js @@ -0,0 +1,394 @@ +/* @ts-self-types="./iuna_wallet_crypto.d.ts" */ + +/** + * @param {string} request_json + * @returns {string} + */ +export function build_migration(request_json) { + let deferred3_0; + let deferred3_1; + try { + const retptr = wasm.__wbindgen_add_to_stack_pointer(-16); + const ptr0 = passStringToWasm0(request_json, wasm.__wbindgen_export, wasm.__wbindgen_export2); + const len0 = WASM_VECTOR_LEN; + wasm.build_migration(retptr, ptr0, len0); + var r0 = getDataViewMemory0().getInt32(retptr + 4 * 0, true); + var r1 = getDataViewMemory0().getInt32(retptr + 4 * 1, true); + var r2 = getDataViewMemory0().getInt32(retptr + 4 * 2, true); + var r3 = getDataViewMemory0().getInt32(retptr + 4 * 3, true); + var ptr2 = r0; + var len2 = r1; + if (r3) { + ptr2 = 0; len2 = 0; + throw takeObject(r2); + } + deferred3_0 = ptr2; + deferred3_1 = len2; + return getStringFromWasm0(ptr2, len2); + } finally { + wasm.__wbindgen_add_to_stack_pointer(16); + wasm.__wbindgen_export3(deferred3_0, deferred3_1, 1); + } +} + +/** + * @param {string} request_json + * @returns {string} + */ +export function build_transfer(request_json) { + let deferred3_0; + let deferred3_1; + try { + const retptr = wasm.__wbindgen_add_to_stack_pointer(-16); + const ptr0 = passStringToWasm0(request_json, wasm.__wbindgen_export, wasm.__wbindgen_export2); + const len0 = WASM_VECTOR_LEN; + wasm.build_transfer(retptr, ptr0, len0); + var r0 = getDataViewMemory0().getInt32(retptr + 4 * 0, true); + var r1 = getDataViewMemory0().getInt32(retptr + 4 * 1, true); + var r2 = getDataViewMemory0().getInt32(retptr + 4 * 2, true); + var r3 = getDataViewMemory0().getInt32(retptr + 4 * 3, true); + var ptr2 = r0; + var len2 = r1; + if (r3) { + ptr2 = 0; len2 = 0; + throw takeObject(r2); + } + deferred3_0 = ptr2; + deferred3_1 = len2; + return getStringFromWasm0(ptr2, len2); + } finally { + wasm.__wbindgen_add_to_stack_pointer(16); + wasm.__wbindgen_export3(deferred3_0, deferred3_1, 1); + } +} + +/** + * @param {string} seed + * @param {number} count + * @param {string} network_id + * @returns {string} + */ +export function derive_external_addresses(seed, count, network_id) { + let deferred4_0; + let deferred4_1; + try { + const retptr = wasm.__wbindgen_add_to_stack_pointer(-16); + const ptr0 = passStringToWasm0(seed, wasm.__wbindgen_export, wasm.__wbindgen_export2); + const len0 = WASM_VECTOR_LEN; + const ptr1 = passStringToWasm0(network_id, wasm.__wbindgen_export, wasm.__wbindgen_export2); + const len1 = WASM_VECTOR_LEN; + wasm.derive_external_addresses(retptr, ptr0, len0, count, ptr1, len1); + var r0 = getDataViewMemory0().getInt32(retptr + 4 * 0, true); + var r1 = getDataViewMemory0().getInt32(retptr + 4 * 1, true); + var r2 = getDataViewMemory0().getInt32(retptr + 4 * 2, true); + var r3 = getDataViewMemory0().getInt32(retptr + 4 * 3, true); + var ptr3 = r0; + var len3 = r1; + if (r3) { + ptr3 = 0; len3 = 0; + throw takeObject(r2); + } + deferred4_0 = ptr3; + deferred4_1 = len3; + return getStringFromWasm0(ptr3, len3); + } finally { + wasm.__wbindgen_add_to_stack_pointer(16); + wasm.__wbindgen_export3(deferred4_0, deferred4_1, 1); + } +} +function __wbg_get_imports() { + const import0 = { + __proto__: null, + __wbg___wbindgen_debug_string_edece8177ad01481: function(arg0, arg1) { + const ret = debugString(getObject(arg1)); + const ptr1 = passStringToWasm0(ret, wasm.__wbindgen_export, wasm.__wbindgen_export2); + const len1 = WASM_VECTOR_LEN; + getDataViewMemory0().setInt32(arg0 + 4 * 1, len1, true); + getDataViewMemory0().setInt32(arg0 + 4 * 0, ptr1, true); + }, + __wbindgen_cast_0000000000000001: function(arg0, arg1) { + // Cast intrinsic for `Ref(String) -> Externref`. + const ret = getStringFromWasm0(arg0, arg1); + return addHeapObject(ret); + }, + __wbindgen_object_drop_ref: function(arg0) { + takeObject(arg0); + }, + }; + return { + __proto__: null, + "./iuna_wallet_crypto_bg.js": import0, + }; +} + +function addHeapObject(obj) { + if (heap_next === heap.length) heap.push(heap.length + 1); + const idx = heap_next; + heap_next = heap[idx]; + + heap[idx] = obj; + return idx; +} + +function debugString(val) { + // primitive types + const type = typeof val; + if (type == 'number' || type == 'boolean' || val == null) { + return `${val}`; + } + if (type == 'string') { + return `"${val}"`; + } + if (type == 'symbol') { + const description = val.description; + if (description == null) { + return 'Symbol'; + } else { + return `Symbol(${description})`; + } + } + if (type == 'function') { + const name = val.name; + if (typeof name == 'string' && name.length > 0) { + return `Function(${name})`; + } else { + return 'Function'; + } + } + // objects + if (Array.isArray(val)) { + const length = val.length; + let debug = '['; + if (length > 0) { + debug += debugString(val[0]); + } + for(let i = 1; i < length; i++) { + debug += ', ' + debugString(val[i]); + } + debug += ']'; + return debug; + } + // Test for built-in + const builtInMatches = /\[object ([^\]]+)\]/.exec(toString.call(val)); + let className; + if (builtInMatches && builtInMatches.length > 1) { + className = builtInMatches[1]; + } else { + // Failed to match the standard '[object ClassName]' + return toString.call(val); + } + if (className == 'Object') { + // we're a user defined class or Object + // JSON.stringify avoids problems with cycles, and is generally much + // easier than looping through ownProperties of `val`. + try { + return 'Object(' + JSON.stringify(val) + ')'; + } catch (_) { + return 'Object'; + } + } + // errors + if (val instanceof Error) { + return `${val.name}: ${val.message}\n${val.stack}`; + } + // TODO we could test for more things here, like `Set`s and `Map`s. + return className; +} + +function dropObject(idx) { + if (idx < 1028) return; + heap[idx] = heap_next; + heap_next = idx; +} + +let cachedDataViewMemory0 = null; +function getDataViewMemory0() { + if (cachedDataViewMemory0 === null || cachedDataViewMemory0.buffer.detached === true || (cachedDataViewMemory0.buffer.detached === undefined && cachedDataViewMemory0.buffer !== wasm.memory.buffer)) { + cachedDataViewMemory0 = new DataView(wasm.memory.buffer); + } + return cachedDataViewMemory0; +} + +function getStringFromWasm0(ptr, len) { + return decodeText(ptr >>> 0, len); +} + +let cachedUint8ArrayMemory0 = null; +function getUint8ArrayMemory0() { + if (cachedUint8ArrayMemory0 === null || cachedUint8ArrayMemory0.byteLength === 0) { + cachedUint8ArrayMemory0 = new Uint8Array(wasm.memory.buffer); + } + return cachedUint8ArrayMemory0; +} + +function getObject(idx) { return heap[idx]; } + +let heap = new Array(1024).fill(undefined); +heap.push(undefined, null, true, false); + +let heap_next = heap.length; + +function passStringToWasm0(arg, malloc, realloc) { + if (realloc === undefined) { + const buf = cachedTextEncoder.encode(arg); + const ptr = malloc(buf.length, 1) >>> 0; + getUint8ArrayMemory0().subarray(ptr, ptr + buf.length).set(buf); + WASM_VECTOR_LEN = buf.length; + return ptr; + } + + let len = arg.length; + let ptr = malloc(len, 1) >>> 0; + + const mem = getUint8ArrayMemory0(); + + let offset = 0; + + for (; offset < len; offset++) { + const code = arg.charCodeAt(offset); + if (code > 0x7F) break; + mem[ptr + offset] = code; + } + if (offset !== len) { + if (offset !== 0) { + arg = arg.slice(offset); + } + ptr = realloc(ptr, len, len = offset + arg.length * 3, 1) >>> 0; + const view = getUint8ArrayMemory0().subarray(ptr + offset, ptr + len); + const ret = cachedTextEncoder.encodeInto(arg, view); + + offset += ret.written; + ptr = realloc(ptr, len, offset, 1) >>> 0; + } + + WASM_VECTOR_LEN = offset; + return ptr; +} + +function takeObject(idx) { + const ret = getObject(idx); + dropObject(idx); + return ret; +} + +let cachedTextDecoder = new TextDecoder('utf-8', { ignoreBOM: true, fatal: true }); +cachedTextDecoder.decode(); +const MAX_SAFARI_DECODE_BYTES = 2146435072; +let numBytesDecoded = 0; +function decodeText(ptr, len) { + numBytesDecoded += len; + if (numBytesDecoded >= MAX_SAFARI_DECODE_BYTES) { + cachedTextDecoder = new TextDecoder('utf-8', { ignoreBOM: true, fatal: true }); + cachedTextDecoder.decode(); + numBytesDecoded = len; + } + return cachedTextDecoder.decode(getUint8ArrayMemory0().subarray(ptr, ptr + len)); +} + +const cachedTextEncoder = new TextEncoder(); + +if (!('encodeInto' in cachedTextEncoder)) { + cachedTextEncoder.encodeInto = function (arg, view) { + const buf = cachedTextEncoder.encode(arg); + view.set(buf); + return { + read: arg.length, + written: buf.length + }; + }; +} + +let WASM_VECTOR_LEN = 0; + +let wasmModule, wasmInstance, wasm; +function __wbg_finalize_init(instance, module) { + wasmInstance = instance; + wasm = instance.exports; + wasmModule = module; + cachedDataViewMemory0 = null; + cachedUint8ArrayMemory0 = null; + return wasm; +} + +async function __wbg_load(module, imports) { + if (typeof Response === 'function' && module instanceof Response) { + if (typeof WebAssembly.instantiateStreaming === 'function') { + try { + return await WebAssembly.instantiateStreaming(module, imports); + } catch (e) { + const validResponse = module.ok && expectedResponseType(module.type); + + if (validResponse && module.headers.get('Content-Type') !== 'application/wasm') { + console.warn("`WebAssembly.instantiateStreaming` failed because your server does not serve Wasm with `application/wasm` MIME type. Falling back to `WebAssembly.instantiate` which is slower. Original error:\n", e); + + } else { throw e; } + } + } + + const bytes = await module.arrayBuffer(); + return await WebAssembly.instantiate(bytes, imports); + } else { + const instance = await WebAssembly.instantiate(module, imports); + + if (instance instanceof WebAssembly.Instance) { + return { instance, module }; + } else { + return instance; + } + } + + function expectedResponseType(type) { + switch (type) { + case 'basic': case 'cors': case 'default': return true; + } + return false; + } +} + +function initSync(module) { + if (wasm !== undefined) return wasm; + + + if (module !== undefined) { + if (Object.getPrototypeOf(module) === Object.prototype) { + ({module} = module) + } else { + console.warn('using deprecated parameters for `initSync()`; pass a single object instead') + } + } + + const imports = __wbg_get_imports(); + if (!(module instanceof WebAssembly.Module)) { + module = new WebAssembly.Module(module); + } + const instance = new WebAssembly.Instance(module, imports); + return __wbg_finalize_init(instance, module); +} + +async function __wbg_init(module_or_path) { + if (wasm !== undefined) return wasm; + + + if (module_or_path !== undefined) { + if (Object.getPrototypeOf(module_or_path) === Object.prototype) { + ({module_or_path} = module_or_path) + } else { + console.warn('using deprecated parameters for the initialization function; pass a single object instead') + } + } + + if (module_or_path === undefined) { + module_or_path = new URL('iuna_wallet_crypto_bg.wasm', import.meta.url); + } + const imports = __wbg_get_imports(); + + if (typeof module_or_path === 'string' || (typeof Request === 'function' && module_or_path instanceof Request) || (typeof URL === 'function' && module_or_path instanceof URL)) { + module_or_path = fetch(module_or_path); + } + + const { instance, module } = await __wbg_load(await module_or_path, imports); + + return __wbg_finalize_init(instance, module); +} + +export { initSync, __wbg_init as default }; diff --git a/wallet/crypto/iuna_wallet_crypto_bg.wasm b/wallet/crypto/iuna_wallet_crypto_bg.wasm Binary files differ. diff --git a/wallet/crypto/iuna_wallet_crypto_bg.wasm.d.ts b/wallet/crypto/iuna_wallet_crypto_bg.wasm.d.ts @@ -0,0 +1,10 @@ +/* tslint:disable */ +/* eslint-disable */ +export const memory: WebAssembly.Memory; +export const derive_external_addresses: (a: number, b: number, c: number, d: number, e: number, f: number) => void; +export const build_transfer: (a: number, b: number, c: number) => void; +export const build_migration: (a: number, b: number, c: number) => void; +export const __wbindgen_export: (a: number, b: number) => number; +export const __wbindgen_export2: (a: number, b: number, c: number, d: number) => number; +export const __wbindgen_add_to_stack_pointer: (a: number) => number; +export const __wbindgen_export3: (a: number, b: number, c: number) => void; diff --git a/wallet/crypto/package.json b/wallet/crypto/package.json @@ -0,0 +1,21 @@ +{ + "name": "iuna-wallet-crypto", + "description": "Deterministic hybrid wallet cryptography for the iuna browser wallet", + "version": "0.1.0", + "type": "module", + "license": "Apache-2.0", + "repository": { + "type": "git", + "url": "https://github.com/jhartog/iuna" + }, + "files": [ + "iuna_wallet_crypto_bg.wasm", + "iuna_wallet_crypto.js", + "iuna_wallet_crypto.d.ts" + ], + "module": "iuna_wallet_crypto.js", + "types": "iuna_wallet_crypto.d.ts", + "sideEffects": [ + "./snippets/*" + ] +} diff --git a/wallet/multi-wallet.css b/wallet/multi-wallet.css @@ -51,3 +51,7 @@ font-size: 11px; font-weight: 700; } +.migration-card { margin: 8px 0 26px; } +.migration-card h2 { margin: 0 0 8px; font: 600 20px/1.2 Georgia, serif; } +.migration-card p:not(.eyebrow) { margin: 0 0 14px; color: var(--muted); font-size: 13px; line-height: 1.5; } +.migration-card .button { width: 100%; } diff --git a/wallet/wallet-core.js b/wallet/wallet-core.js @@ -14,7 +14,7 @@ export function normalizeWalletStore(value, legacyWallet = null) { if (value?.version === 2 && Array.isArray(value.wallets)) { const wallets = value.wallets.filter((wallet) => wallet && wallet.id - && /^[0-9a-f]{64}$/.test(wallet.publicKeyHex) + && (/^[0-9a-f]{64}$/.test(wallet.publicKeyHex) || (wallet.type === "readonly" && typeof wallet.address === "string")) && ["signing", "readonly"].includes(wallet.type) && (wallet.type === "readonly" || wallet.record)); const activeId = wallets.some((wallet) => wallet.id === value.activeId) ? value.activeId : wallets[0]?.id || null; @@ -202,6 +202,12 @@ export function encodeAddress(publicKey, networkId = "iuna-mainnet-v1") { } export function decodeAddress(address, expectedHrp = "iuna") { + const decoded = decodeVersionedAddress(address, expectedHrp); + if (decoded.version !== 0) throw new Error("This operation requires a legacy address"); + return decoded.payloadHex; +} + +export function decodeVersionedAddress(address, expectedHrp = "iuna") { const normalized = address.trim().toLowerCase(); if (address !== address.toLowerCase() && address !== address.toUpperCase()) throw new Error("Address mixes uppercase and lowercase characters"); const separator = normalized.lastIndexOf("1"); @@ -213,10 +219,11 @@ export function decodeAddress(address, expectedHrp = "iuna") { }); if (polymod([...hrpExpand(expectedHrp), ...data]) !== BECH32M) throw new Error("Address checksum is invalid"); const payload = data.slice(0, -6); - if (payload.shift() !== 0) throw new Error("Unsupported address version"); + const version = payload.shift(); + if (![0, 1].includes(version)) throw new Error("Unsupported address version"); const key = Uint8Array.from(convertBits(payload, 5, 8, false)); if (key.length !== 32) throw new Error("Invalid address key"); - return bytesToHex(key); + return { version, payloadHex: bytesToHex(key) }; } function pushU32(target, value) { @@ -374,7 +381,7 @@ export async function api(path, options = {}) { } let body; try { body = await response.json(); } catch { body = {}; } - if (response.status === 413 && path === "/transactions" && requestOptions.method === "POST") { + if (response.status === 413 && ["/transactions", "/transactions-v2"].includes(path) && requestOptions.method === "POST") { throw new Error("Transaction is too large for the public endpoint. Try a smaller amount or consolidate this wallet’s UTXOs first."); } if (!response.ok) throw new Error(body.error || `Endpoint returned status ${response.status}`);