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:
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}`);