iuna

iuna

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

commit e4dea7209a80305d4bc470e6882fc463d286ef14
parent b42254852ec60be7e9280b5622e6b0105c702c7a
Author: Joris Hartog <jorishartog@hotmail.com>
Date:   Tue, 22 Sep 2026 11:43:58 +0200

feat(wallet): retain confirmed v2 history

Diffstat:
MCHANGELOG.md | 5+++++
Mdocs/quantum-migration.md | 4++++
Msrc/adapters/http.rs | 3++-
Msrc/adapters/http/api.rs | 143+++++++++++++++++++++++++++++++++++++++++++++++++++++++++----------------------
Msrc/adapters/http/ui.rs | 191++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----------
Msrc/adapters/ui_data_store.rs | 297+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
Msrc/adapters/ui_index.rs | 51+++++++++++++++++++++++++++++++++++++++++++++++++--
7 files changed, 620 insertions(+), 74 deletions(-)

diff --git a/CHANGELOG.md b/CHANGELOG.md @@ -14,6 +14,11 @@ from the Git history and Conventional Commit titles by `deployment.sh`. - submit block-bounded wallet migration batches - send hybrid transaction-v2 transfers - merge pending transaction-v2 entries into the wallet and chain mempool views +- show confirmed transaction-v2 entries in chain blocks and wallet history + +### Fixed + +- rebuild confirmed transaction-v2 wallet history after chain reorganizations ### Documentation diff --git a/docs/quantum-migration.md b/docs/quantum-migration.md @@ -127,6 +127,10 @@ peers connected during a gradual relay upgrade. The management wallet can submit reviewed migration batches and ordinary hybrid transfers; address rotation and broader recovery rehearsal remain release blockers. +Pending and confirmed transaction-v2 entries are included in the management wallet history and +chain views. Confirmed history is materialized from the canonical chain snapshot, so a chain +reorganization atomically replaces entries from the abandoned branch. + The verification tests include a small audit corpus pinned to exact NIST ACVP-Server and C2SP Wycheproof commits and file hashes. It covers a valid NIST signature, Wycheproof's repeated-hint regression, and a valid signature at the ML-DSA-44 norm boundary. A dedicated fuzz target exercises diff --git a/src/adapters/http.rs b/src/adapters/http.rs @@ -63,7 +63,8 @@ use static_assets::{alpine_js, app_js, favicon, index}; use ui::{ add_pending_outputs, add_pending_v2_outputs, populate_wallet_reward_flow, transaction_v2_input_outpoints, ui_blocks_from_indexes, ui_transaction, ui_transaction_v2, - wallet_transaction_row, wallet_transaction_rows, wallet_transaction_v2_rows, + wallet_transaction_row, wallet_transaction_rows, wallet_transaction_v2_row, + wallet_transaction_v2_rows, }; use wallet::{ api_wallet_setup, estimate_burn_fee, estimate_mine_fee, estimate_transfer_fee, diff --git a/src/adapters/http/api.rs b/src/adapters/http/api.rs @@ -10,7 +10,7 @@ use axum::{ use crate::{ adapters::p2p::P2pMetrics, app::{NodeStatus, PeerInfo}, - domain::{AddressNetwork, OutPoint, Transaction, TxOutput}, + domain::{AddressNetwork, OutPoint, Transaction, TransactionV2, TxOutput, decode_hex}, ip_geolocation::IpGeolocation, }; @@ -26,7 +26,7 @@ use super::{ add_pending_outputs, add_pending_v2_outputs, metrics_response, network_health, populate_wallet_reward_flow, top_mine_proofs, transaction_v2_input_outpoints, ui_blocks_from_indexes, ui_transaction, ui_transaction_v2, wallet_transaction_row, - wallet_transaction_rows, wallet_transaction_v2_rows, + wallet_transaction_rows, wallet_transaction_v2_row, wallet_transaction_v2_rows, }; pub(super) async fn api_status(State(state): State<HttpState>) -> Json<NodeStatus> { @@ -43,14 +43,20 @@ pub(super) async fn api_blocks( .limit .unwrap_or(EXPLORER_PAGE_LIMIT) .min(EXPLORER_LIMIT); - let (tip_hash, blocks, storage_size_breakdowns) = { + let (tip_hash, blocks, storage_size_breakdowns, transaction_v2_domain, network) = { let node = state.node.lock().await; let blocks = match query.before_height { Some(before_height) => node.blocks_before(before_height, limit), None => node.recent_blocks(limit), }; let storage_size_breakdowns = node.block_storage_size_breakdowns(&blocks); - (node.chain_tip_hash(), blocks, storage_size_breakdowns) + ( + node.chain_tip_hash(), + blocks, + storage_size_breakdowns, + node.ledger().transaction_v2_domain().ok(), + AddressNetwork::from_profile_id(&node.ledger().launch_profile().profile_id), + ) }; let store = state.ui_data_store.clone(); let view = tokio::task::spawn_blocking(move || store.load_ui_chain_index(&tip_hash)) @@ -64,6 +70,8 @@ pub(super) async fn api_blocks( &view.outputs, &view.burn_leader_ranks_by_hash, &storage_size_breakdowns, + transaction_v2_domain.as_ref(), + network, )) } @@ -199,27 +207,47 @@ pub(super) async fn api_wallet_transactions( let kinds = wallet_transaction_filter_kinds(filters); let store = state.ui_data_store.clone(); let wallet_for_query = wallet.clone(); - let (confirmed_rows, confirmed_total) = if remaining_limit == 0 { - (Vec::new(), 0) - } else { - tokio::task::spawn_blocking(move || { - store.load_wallet_transactions( - &wallet_for_query, - &kinds, - confirmed_offset, - remaining_limit, - ) + let wallet_addresses_for_query = wallet_addresses.clone(); + let confirmed_fetch_limit = confirmed_offset.saturating_add(remaining_limit); + let ((confirmed_rows, confirmed_legacy_total), (confirmed_v2_rows, confirmed_v2_total)) = + tokio::task::spawn_blocking(move || -> Result<_> { + Ok(( + store.load_wallet_transactions( + &wallet_for_query, + &kinds, + 0, + confirmed_fetch_limit, + )?, + store.load_wallet_transactions_v2( + &wallet_addresses_for_query, + &kinds, + 0, + confirmed_fetch_limit, + )?, + )) }) .await .ok() .and_then(Result::ok) - .unwrap_or_default() - }; + .unwrap_or_default(); + let decoded_confirmed_v2 = domain.as_ref().map_or_else(Vec::new, |expected_domain| { + confirmed_v2_rows + .into_iter() + .filter_map(|row| { + let bytes = decode_hex(&row.envelope).ok()?; + let (decoded_domain, transaction) = TransactionV2::decode(&bytes).ok()?; + (decoded_domain == *expected_domain).then_some((row, transaction)) + }) + .collect::<Vec<_>>() + }); let mut confirmed_required_outputs = BTreeSet::new(); collect_transaction_input_outpoints( confirmed_rows.iter().map(|row| &row.transaction), &mut confirmed_required_outputs, ); + for (_, transaction) in &decoded_confirmed_v2 { + confirmed_required_outputs.extend(transaction_v2_input_outpoints(transaction)); + } let confirmed_outputs = load_outputs_for_outpoints(&state, confirmed_required_outputs) .await .unwrap_or_default(); @@ -234,30 +262,67 @@ pub(super) async fn api_wallet_transactions( }) .collect::<BTreeMap<_, _>>() }; - items.extend(confirmed_rows.into_iter().filter_map(|row| { - let is_reward = row.kind == "reward"; - let mut item = wallet_transaction_row( - &wallet, - &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), - }, - )?; - if is_reward { - item.kind = "reward"; - item.from = "fees".to_string(); - item.to = Some(wallet.clone()); - item.direction = "reward"; - if let Some(block) = reward_blocks.get(&row.block_height) { - populate_wallet_reward_flow(&mut item, block); + let mut confirmed_items = confirmed_rows + .into_iter() + .filter_map(|row| { + let sort_key = row.sort_key; + let is_reward = row.kind == "reward"; + let mut item = wallet_transaction_row( + &wallet, + &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), + }, + )?; + if is_reward { + item.kind = "reward"; + item.from = "fees".to_string(); + item.to = Some(wallet.clone()); + item.direction = "reward"; + if let Some(block) = reward_blocks.get(&row.block_height) { + populate_wallet_reward_flow(&mut item, block); + } } - } - Some(item) - })); + Some((sort_key, item)) + }) + .collect::<Vec<_>>(); + if let Some(domain) = domain.as_ref() { + confirmed_items.extend(decoded_confirmed_v2.into_iter().filter_map( + |(row, transaction)| { + let sort_key = row.sort_key; + let context = WalletTransactionContext { + status: "confirmed", + block_height: Some(row.block_height), + timestamp_ms: Some(row.timestamp_ms), + block_finalizer: Some(row.block_finalizer), + }; + wallet_transaction_v2_row( + &wallet_addresses, + &transaction, + &confirmed_outputs, + domain, + network, + &context, + ) + .ok() + .flatten() + .map(|item| (sort_key, item)) + }, + )); + } + confirmed_items.sort_by(|left, right| right.0.cmp(&left.0)); + items.extend( + confirmed_items + .into_iter() + .skip(confirmed_offset) + .take(remaining_limit) + .map(|(_, item)| item), + ); + let confirmed_total = confirmed_legacy_total.saturating_add(confirmed_v2_total); let total = pending_total + confirmed_total; let next_offset = offset + items.len(); Json(Page { diff --git a/src/adapters/http/ui.rs b/src/adapters/http/ui.rs @@ -5,7 +5,7 @@ use anyhow::Result; use crate::compact::CompactBlockSizeBreakdown; use crate::domain::{ AddressNetwork, Amount, Block, BurnLeaderRank, FinalizerMode, LegacyTransactionId, MINE_REWARD, - OutPoint, Transaction, TransactionV2, TransactionV2Domain, TxInput, TxOutput, + OutPoint, Transaction, TransactionV2, TransactionV2Domain, TxInput, TxOutput, decode_hex, encode_versioned_address, hex_encode, }; @@ -72,24 +72,38 @@ pub(super) fn wallet_transaction_v2_rows( domain: &TransactionV2Domain, network: AddressNetwork, ) -> Vec<WalletTransactionRow> { + let context = WalletTransactionContext { + status: "pending", + block_height: None, + timestamp_ms: None, + block_finalizer: None, + }; pending .iter() .rev() .filter(|transaction| filters.allows_v2(transaction)) .filter_map(|transaction| { - wallet_transaction_v2_row(wallet_addresses, transaction, outputs, domain, network) - .ok() - .flatten() + wallet_transaction_v2_row( + wallet_addresses, + transaction, + outputs, + domain, + network, + &context, + ) + .ok() + .flatten() }) .collect() } -fn wallet_transaction_v2_row( +pub(super) fn wallet_transaction_v2_row( wallet_addresses: &[String], transaction: &TransactionV2, outputs: &BTreeMap<OutPoint, TxOutput>, domain: &TransactionV2Domain, network: AddressNetwork, + context: &WalletTransactionContext, ) -> Result<Option<WalletTransactionRow>> { let presented = ui_transaction_v2(transaction, outputs, domain, network)?; let is_wallet_address = @@ -135,10 +149,10 @@ fn wallet_transaction_v2_row( outputs: presented.outputs, change: presented.change, signature: presented.signature, - status: "pending", - block_height: None, - timestamp_ms: None, - block_finalizer: None, + status: context.status, + block_height: context.block_height, + timestamp_ms: context.timestamp_ms, + block_finalizer: context.block_finalizer.clone(), direction, difficulty_bits: presented.difficulty_bits, proof_bits: presented.proof_bits, @@ -314,22 +328,50 @@ pub(super) fn ui_blocks_from_indexes( outputs: &BTreeMap<OutPoint, TxOutput>, burn_leader_ranks: &BTreeMap<String, Vec<BurnLeaderRank>>, storage_size_breakdowns: &BTreeMap<String, CompactBlockSizeBreakdown>, + transaction_v2_domain: Option<&TransactionV2Domain>, + network: AddressNetwork, ) -> Vec<UiBlock> { blocks .into_iter() .map(|block| { let storage_size = storage_size_breakdowns.get(&block.hash); - ui_block(block, outputs, burn_leader_ranks, storage_size) + ui_block_with_v2( + block, + outputs, + burn_leader_ranks, + storage_size, + transaction_v2_domain, + network, + ) }) .collect() } +#[cfg(test)] pub(super) fn ui_block( block: Block, outputs: &BTreeMap<OutPoint, TxOutput>, burn_leader_ranks: &BTreeMap<String, Vec<BurnLeaderRank>>, storage_size: Option<&CompactBlockSizeBreakdown>, ) -> UiBlock { + ui_block_with_v2( + block, + outputs, + burn_leader_ranks, + storage_size, + None, + AddressNetwork::Mainnet, + ) +} + +fn ui_block_with_v2( + block: Block, + outputs: &BTreeMap<OutPoint, TxOutput>, + burn_leader_ranks: &BTreeMap<String, Vec<BurnLeaderRank>>, + storage_size: Option<&CompactBlockSizeBreakdown>, + transaction_v2_domain: Option<&TransactionV2Domain>, + network: AddressNetwork, +) -> UiBlock { let ranks = burn_leader_ranks .get(&block.hash) .cloned() @@ -339,25 +381,46 @@ pub(super) fn ui_block( .transactions .iter() .fold(0_u64, |total, tx| total.saturating_add(tx.fee())); - let total_fees = public_fees; + let transactions_v2 = transaction_v2_domain + .map(|domain| decode_block_transactions_v2(&block, domain)) + .unwrap_or_default(); + let total_fees = transactions_v2 + .iter() + .fold(public_fees, |total, tx| total.saturating_add(tx.fee())); let transaction_bytes = storage_size .map(|size| size.transaction_bytes) .unwrap_or_else(|| { - block + let legacy_bytes = block .transactions .iter() .map(|tx| tx.serialized_size_bytes().unwrap_or_default()) - .sum() + .sum::<usize>(); + let v2_bytes = transaction_v2_domain.map_or(0, |domain| { + transactions_v2.iter().fold(0_usize, |total, transaction| { + total.saturating_add(transaction.encoded_size_bytes(domain).unwrap_or_default()) + }) + }); + legacy_bytes.saturating_add(v2_bytes) }); - let transaction_byte_breakdown = transaction_byte_breakdown(&block.transactions, storage_size); + let transaction_byte_breakdown = transaction_byte_breakdown( + &block.transactions, + &transactions_v2, + storage_size, + transaction_v2_domain, + ); let header_and_proof_bytes = storage_size .map(|size| size.header_and_proof_bytes) .unwrap_or_default(); - let transactions = block + let mut transactions = block .transactions .iter() .map(|tx| ui_transaction(tx, outputs)) .collect::<Vec<_>>(); + if let Some(domain) = transaction_v2_domain { + transactions.extend(transactions_v2.iter().filter_map(|transaction| { + ui_transaction_v2(transaction, outputs, domain, network).ok() + })); + } let burn_bundles: Vec<UiBurnBundle> = block .burn_bundle_section .expand(block.height, &block.prev_hash) @@ -398,7 +461,7 @@ pub(super) fn ui_block( finalizer_rank: block.finalizer_rank, reward: block.reward, total_fees, - lost_iuna: block_lost_iuna(&block.transactions, block.reward), + lost_iuna: block_lost_iuna(&block.transactions, &transactions_v2, block.reward), total_bytes, header_and_proof_bytes, transaction_bytes, @@ -418,6 +481,21 @@ pub(super) fn ui_block( } } +fn decode_block_transactions_v2( + block: &Block, + expected_domain: &TransactionV2Domain, +) -> Vec<TransactionV2> { + block + .transactions_v2 + .iter() + .filter_map(|envelope| { + let bytes = decode_hex(envelope).ok()?; + let (domain, transaction) = TransactionV2::decode(&bytes).ok()?; + (domain == *expected_domain).then_some(transaction) + }) + .collect() +} + fn burn_bundle_wallet_quorum(block: &Block) -> (usize, usize) { if block.finalizer_mode != FinalizerMode::Ticket { return (0, 0); @@ -430,7 +508,11 @@ fn burn_bundle_wallet_quorum(block: &Block) -> (usize, usize) { (committee_size, committee_size) } -fn block_lost_iuna(transactions: &[Transaction], reward: Amount) -> Amount { +fn block_lost_iuna( + transactions: &[Transaction], + transactions_v2: &[TransactionV2], + reward: Amount, +) -> Amount { let mut burned = 0_u64; let mut existing_supply_fees = 0_u64; let mut minted_finalizer_fees = 0_u64; @@ -448,6 +530,12 @@ fn block_lost_iuna(transactions: &[Transaction], reward: Amount) -> Amount { } } } + for transaction in transactions_v2 { + if let TransactionV2::Burn { amount, .. } = transaction { + burned = burned.saturating_add(*amount); + } + existing_supply_fees = existing_supply_fees.saturating_add(transaction.fee()); + } let returned_fees = reward .saturating_sub(minted_finalizer_fees) .min(existing_supply_fees); @@ -456,14 +544,28 @@ fn block_lost_iuna(transactions: &[Transaction], reward: Amount) -> Amount { fn transaction_byte_breakdown( transactions: &[Transaction], + transactions_v2: &[TransactionV2], storage_size: Option<&CompactBlockSizeBreakdown>, + transaction_v2_domain: Option<&TransactionV2Domain>, ) -> Vec<UiByteBreakdown> { if let Some(storage_size) = storage_size { - return byte_breakdown_rows( + let mut rows = byte_breakdown_rows( storage_size.transfer_bytes, storage_size.burn_bytes, storage_size.mine_bytes, ); + let legacy_bytes = storage_size + .transfer_bytes + .saturating_add(storage_size.burn_bytes) + .saturating_add(storage_size.mine_bytes); + let v2_bytes = storage_size.transaction_bytes.saturating_sub(legacy_bytes); + if v2_bytes > 0 { + rows.push(UiByteBreakdown { + label: "transaction v2", + bytes: v2_bytes, + }); + } + return rows; } let mut transfer_bytes = 0_usize; let mut burn_bytes = 0_usize; @@ -476,7 +578,19 @@ fn transaction_byte_breakdown( Transaction::Mine { .. } => mine_bytes = mine_bytes.saturating_add(bytes), } } - byte_breakdown_rows(transfer_bytes, burn_bytes, mine_bytes) + let mut rows = byte_breakdown_rows(transfer_bytes, burn_bytes, mine_bytes); + let transaction_v2_bytes = transaction_v2_domain.map_or(0, |domain| { + transactions_v2.iter().fold(0_usize, |total, transaction| { + total.saturating_add(transaction.encoded_size_bytes(domain).unwrap_or_default()) + }) + }); + if transaction_v2_bytes > 0 { + rows.push(UiByteBreakdown { + label: "transaction v2", + bytes: transaction_v2_bytes, + }); + } + rows } fn byte_breakdown_rows( @@ -905,12 +1019,12 @@ mod tests { use crate::domain::{ AddressNetwork, Amount, Block, BurnBundleSection, BurnBundleSignature, BurnLeaderRank, FinalizerMode, Ledger, MaskedBurn, OutPoint, Transaction, TxInput, TxOutput, Wallet, - encode_versioned_address, + encode_versioned_address, hex_encode, }; use super::{ - block_lost_iuna, populate_wallet_reward_flow, ui_block, ui_transaction_v2, - wallet_transaction_row, wallet_transaction_v2_rows, + block_lost_iuna, populate_wallet_reward_flow, ui_block, ui_blocks_from_indexes, + ui_transaction_v2, wallet_transaction_row, wallet_transaction_v2_rows, }; use crate::adapters::http::types::{WalletTransactionContext, WalletTransactionFilters}; @@ -990,6 +1104,31 @@ mod tests { } #[test] + fn confirmed_v2_migration_is_presented_in_block_transactions() { + let wallet = Wallet::from_seed("confirmed-v2-chain-ui-wallet"); + let ledger = Ledger::new(BTreeMap::from([(wallet.address().to_string(), 100_000)]), 1); + let transaction = ledger.build_v2_migration_batch(&wallet, 100).unwrap(); + let domain = ledger.transaction_v2_domain().unwrap(); + let mut block = ledger.chain().last().unwrap().clone(); + block.transactions_v2 = vec![hex_encode(transaction.encode(&domain).unwrap())]; + let blocks = ui_blocks_from_indexes( + vec![block], + &ledger.all_utxos().into_iter().collect(), + &BTreeMap::new(), + &BTreeMap::new(), + Some(&domain), + AddressNetwork::Mainnet, + ); + + assert_eq!(blocks.len(), 1); + assert_eq!(blocks[0].transactions.len(), 1); + assert_eq!(blocks[0].transactions[0].kind, "migration"); + assert_eq!(blocks[0].transactions[0].fee, 100); + assert_eq!(blocks[0].transactions[0].signature.len(), 64); + assert_eq!(blocks[0].total_fees, 100); + } + + #[test] fn wallet_reward_flow_contains_every_fee_and_payout() { let reward_projection = Transaction::Transfer { inputs: Vec::new(), @@ -1086,14 +1225,14 @@ mod tests { signature: "burn".to_string(), }; - assert_eq!(block_lost_iuna(&[burn], 3), 7); + assert_eq!(block_lost_iuna(&[burn], &[], 3), 7); } #[test] fn block_lost_iuna_counts_unreturned_existing_supply_fees() { let transfer = transfer("transfer-a", 5); - assert_eq!(block_lost_iuna(&[transfer], 2), 3); + assert_eq!(block_lost_iuna(&[transfer], &[], 2), 3); } #[test] @@ -1108,7 +1247,7 @@ mod tests { signature: "mine".to_string(), }; - assert_eq!(block_lost_iuna(&[mine], 1), 0); + assert_eq!(block_lost_iuna(&[mine], &[], 1), 0); } #[test] @@ -1125,7 +1264,7 @@ mod tests { }; let reward = mine.fee().saturating_add(2); - assert_eq!(block_lost_iuna(&[transfer, mine], reward), 3); + assert_eq!(block_lost_iuna(&[transfer, mine], &[], reward), 3); } #[test] diff --git a/src/adapters/ui_data_store.rs b/src/adapters/ui_data_store.rs @@ -13,9 +13,10 @@ use serde::Serialize; use crate::{ adapters::ui_index::{UiChainIndex, build_ui_chain_index}, domain::{ - Amount, Block, BurnLeaderRank, ChainSnapshot, Ledger, MINE_RETARGET_WINDOW_BLOCKS, - MINE_REWARD, OutPoint, Transaction, TxInput, TxOutput, retarget_mine_difficulty_bits, - reward_outputs_for_block, + AddressNetwork, Amount, Block, BurnLeaderRank, ChainSnapshot, Ledger, + MINE_RETARGET_WINDOW_BLOCKS, MINE_REWARD, OutPoint, Transaction, TransactionV2, + TransactionV2Domain, TxInput, TxOutput, decode_hex, encode_versioned_address, hex_encode, + retarget_mine_difficulty_bits, reward_outputs_for_block, }, }; @@ -111,6 +112,24 @@ ON ui_wallet_transactions(address, sort_key DESC); CREATE INDEX IF NOT EXISTS idx_ui_wallet_transactions_signature ON ui_wallet_transactions(signature); +CREATE TABLE IF NOT EXISTS ui_wallet_transactions_v2 ( + address TEXT NOT NULL, + sort_key INTEGER NOT NULL, + kind TEXT NOT NULL, + transaction_id TEXT NOT NULL, + block_height INTEGER NOT NULL, + timestamp_ms INTEGER NOT NULL, + block_finalizer TEXT NOT NULL, + envelope TEXT NOT NULL, + PRIMARY KEY (address, transaction_id) +); + +CREATE INDEX IF NOT EXISTS idx_ui_wallet_transactions_v2_address_kind_sort +ON ui_wallet_transactions_v2(address, kind, sort_key DESC); + +CREATE INDEX IF NOT EXISTS idx_ui_wallet_transactions_v2_transaction_id +ON ui_wallet_transactions_v2(transaction_id); + CREATE TABLE IF NOT EXISTS ui_burn_leader_ranks ( block_hash TEXT NOT NULL, rank INTEGER NOT NULL, @@ -136,12 +155,13 @@ DROP TABLE IF EXISTS ui_cache_meta; DROP TABLE IF EXISTS ui_output_index; DROP TABLE IF EXISTS ui_utxos; DROP TABLE IF EXISTS ui_wallet_transactions; +DROP TABLE IF EXISTS ui_wallet_transactions_v2; DROP TABLE IF EXISTS ui_revealed_transactions; DROP TABLE IF EXISTS ui_burn_leader_ranks; DROP TABLE IF EXISTS ui_burn_leader_rank_blocks; "#; -const UI_DATA_SCHEMA_VERSION: u32 = 1; +const UI_DATA_SCHEMA_VERSION: u32 = 2; const UI_CACHE_SCHEMA_VERSION: u32 = 5; const METRICS_CACHE_SCHEMA_VERSION: u32 = 1; @@ -184,6 +204,17 @@ pub struct WalletTransactionProjection { pub transaction: Transaction, } +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct WalletTransactionV2Projection { + pub sort_key: u64, + pub kind: String, + pub transaction_id: String, + pub block_height: u64, + pub timestamp_ms: u64, + pub block_finalizer: String, + pub envelope: String, +} + #[derive(Clone, Debug)] pub struct SqliteUiDataStore { path: PathBuf, @@ -286,6 +317,8 @@ impl SqliteUiDataStore { let ui_index = build_ui_chain_index(snapshot); let utxos = ledger.all_utxos(); let wallet_transactions = wallet_transactions_from_snapshot(snapshot); + let wallet_transactions_v2 = + wallet_transactions_v2_from_snapshot(snapshot, &ledger.transaction_v2_domain()?); let leaderboards = build_ui_leaderboards(&utxos, &wallet_transactions)?; self.with_connection_mut(|connection| { @@ -295,6 +328,7 @@ impl SqliteUiDataStore { replace_ui_chain_index(&transaction, &ui_index, updated_at_ms)?; replace_ui_utxos(&transaction, &utxos)?; replace_ui_wallet_transactions(&transaction, &wallet_transactions)?; + replace_ui_wallet_transactions_v2(&transaction, &wallet_transactions_v2)?; replace_ui_leaderboards(&transaction, &leaderboards)?; transaction .commit() @@ -460,6 +494,18 @@ LIMIT ?1 }) } + pub fn load_wallet_transactions_v2( + &self, + addresses: &[String], + kinds: &[&str], + offset: usize, + limit: usize, + ) -> Result<(Vec<WalletTransactionV2Projection>, usize)> { + self.with_connection(|connection| { + load_wallet_transactions_v2(connection, addresses, kinds, offset, limit) + }) + } + pub fn load_wallet_transaction_by_signature( &self, signature: &str, @@ -1018,6 +1064,43 @@ INSERT INTO ui_wallet_transactions ( Ok(()) } +fn replace_ui_wallet_transactions_v2( + transaction: &rusqlite::Transaction<'_>, + rows: &[(String, WalletTransactionV2Projection)], +) -> Result<()> { + transaction + .execute("DELETE FROM ui_wallet_transactions_v2", []) + .context("failed to clear old UI wallet transaction v2 index")?; + for (address, row) in rows { + transaction + .execute( + r#" +INSERT INTO ui_wallet_transactions_v2 ( + address, sort_key, kind, transaction_id, block_height, timestamp_ms, block_finalizer, + envelope +) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8) +"#, + params![ + address, + row.sort_key, + row.kind, + row.transaction_id, + row.block_height, + row.timestamp_ms, + row.block_finalizer, + row.envelope, + ], + ) + .with_context(|| { + format!( + "failed to persist UI wallet transaction v2 {} for {}", + row.transaction_id, address + ) + })?; + } + Ok(()) +} + fn replace_ui_leaderboards( transaction: &rusqlite::Transaction<'_>, rows: &[(String, UiLeaderboardEntry)], @@ -1058,6 +1141,9 @@ fn clear_ui_chain_index_in_transaction(transaction: &rusqlite::Transaction<'_>) .execute("DELETE FROM ui_wallet_transactions", []) .context("failed to clear old UI wallet transaction index")?; transaction + .execute("DELETE FROM ui_wallet_transactions_v2", []) + .context("failed to clear old UI wallet transaction v2 index")?; + transaction .execute("DELETE FROM ui_leaderboards", []) .context("failed to clear old UI leaderboards")?; transaction @@ -1247,6 +1333,71 @@ LIMIT ? OFFSET ? Ok((rows, total)) } +fn load_wallet_transactions_v2( + connection: &Connection, + addresses: &[String], + kinds: &[&str], + offset: usize, + limit: usize, +) -> Result<(Vec<WalletTransactionV2Projection>, usize)> { + if addresses.is_empty() || kinds.is_empty() { + return Ok((Vec::new(), 0)); + } + let address_placeholders = std::iter::repeat_n("?", addresses.len()) + .collect::<Vec<_>>() + .join(", "); + let kind_placeholders = std::iter::repeat_n("?", kinds.len()) + .collect::<Vec<_>>() + .join(", "); + let predicate = + format!("address IN ({address_placeholders}) AND kind IN ({kind_placeholders})"); + let mut filter_params = Vec::<Value>::with_capacity(addresses.len() + kinds.len()); + filter_params.extend(addresses.iter().cloned().map(Value::Text)); + filter_params.extend(kinds.iter().map(|kind| Value::Text((*kind).to_string()))); + + let count_sql = format!( + "SELECT COUNT(DISTINCT transaction_id) FROM ui_wallet_transactions_v2 WHERE {predicate}" + ); + let total = connection + .query_row(&count_sql, params_from_iter(filter_params.iter()), |row| { + row.get::<_, u64>(0) + }) + .context("failed to count UI wallet transactions v2")? as usize; + + let query_sql = format!( + r#" +SELECT DISTINCT sort_key, kind, block_height, timestamp_ms, block_finalizer, envelope, + transaction_id +FROM ui_wallet_transactions_v2 +WHERE {predicate} +ORDER BY sort_key DESC, transaction_id ASC +LIMIT ? OFFSET ? +"# + ); + let mut query_params = filter_params; + query_params.push(Value::Integer(limit as i64)); + query_params.push(Value::Integer(offset as i64)); + let mut statement = connection + .prepare(&query_sql) + .context("failed to prepare UI wallet transactions v2 query")?; + let rows = statement + .query_map(params_from_iter(query_params.iter()), |row| { + Ok(WalletTransactionV2Projection { + sort_key: row.get(0)?, + kind: row.get(1)?, + block_height: row.get(2)?, + timestamp_ms: row.get(3)?, + block_finalizer: row.get(4)?, + envelope: row.get(5)?, + transaction_id: row.get(6)?, + }) + }) + .context("failed to load UI wallet transactions v2")? + .collect::<std::result::Result<Vec<_>, _>>() + .context("failed to read UI wallet transaction v2 rows")?; + Ok((rows, total)) +} + fn wallet_transaction_kinds_cover_all(kinds: &[&str]) -> bool { ["transfer", "mine", "burn", "reward"] .into_iter() @@ -1452,6 +1603,89 @@ fn wallet_transactions_from_snapshot( rows } +fn wallet_transactions_v2_from_snapshot( + snapshot: &ChainSnapshot, + expected_domain: &TransactionV2Domain, +) -> Vec<(String, WalletTransactionV2Projection)> { + let network = AddressNetwork::from_profile_id(&snapshot.launch_profile.profile_id); + let mut rows = Vec::new(); + for block in &snapshot.blocks { + for (index, envelope) in block.transactions_v2.iter().rev().enumerate() { + let Some(transaction) = decode_projected_transaction_v2(envelope, expected_domain) + else { + continue; + }; + let Ok(transaction_id) = transaction.transaction_id(expected_domain).map(hex_encode) + else { + continue; + }; + let projection = WalletTransactionV2Projection { + sort_key: (block.height as u128 * 10_000 + 5_000 + index as u128) + .min(u128::from(u64::MAX)) as u64, + kind: transaction_v2_filter_kind(&transaction).to_string(), + transaction_id, + block_height: block.height, + timestamp_ms: block.timestamp_ms, + block_finalizer: block.miner.clone(), + envelope: envelope.clone(), + }; + for address in wallet_transaction_v2_addresses(&transaction, network) { + rows.push((address, projection.clone())); + } + } + } + rows +} + +fn decode_projected_transaction_v2( + envelope: &str, + expected_domain: &TransactionV2Domain, +) -> Option<TransactionV2> { + let bytes = decode_hex(envelope).ok()?; + let (domain, transaction) = TransactionV2::decode(&bytes).ok()?; + (domain == *expected_domain).then_some(transaction) +} + +fn wallet_transaction_v2_addresses( + transaction: &TransactionV2, + network: AddressNetwork, +) -> BTreeSet<String> { + let mut addresses = BTreeSet::new(); + let mut insert = |address| { + if let Ok(address) = encode_versioned_address(address, network) { + addresses.insert(address); + } + }; + match transaction { + TransactionV2::Migration { + inputs, outputs, .. + } => { + inputs.iter().for_each(|input| insert(input.owner)); + outputs.iter().for_each(|output| insert(output.address)); + } + TransactionV2::Transfer { + inputs, outputs, .. + } => { + inputs.iter().for_each(|input| insert(input.owner)); + outputs.iter().for_each(|output| insert(output.address)); + } + TransactionV2::Burn { inputs, change, .. } => { + inputs.iter().for_each(|input| insert(input.owner)); + change.iter().for_each(|output| insert(output.address)); + } + TransactionV2::Mine { recipient, .. } => insert(*recipient), + } + addresses +} + +fn transaction_v2_filter_kind(transaction: &TransactionV2) -> &'static str { + match transaction { + TransactionV2::Migration { .. } | TransactionV2::Transfer { .. } => "transfer", + TransactionV2::Burn { .. } => "burn", + TransactionV2::Mine { .. } => "mine", + } +} + fn push_wallet_transaction_projection( rows: &mut Vec<(String, WalletTransactionProjection)>, transaction: &Transaction, @@ -1696,9 +1930,11 @@ mod tests { use rusqlite::Connection; use tempfile::tempdir; - use crate::domain::{ChainSnapshot, GenesisBurn, Ledger, Wallet}; + use crate::domain::{AddressNetwork, ChainSnapshot, GenesisBurn, Ledger, Wallet, hex_encode}; - use super::SqliteUiDataStore; + use super::{ + SqliteUiDataStore, replace_ui_wallet_transactions_v2, wallet_transactions_v2_from_snapshot, + }; fn test_snapshot(seed: &str) -> ChainSnapshot { let wallet = Wallet::from_seed(seed); @@ -1750,6 +1986,55 @@ mod tests { } #[test] + fn confirmed_v2_wallet_projection_is_replaced_on_reorg() { + let dir = tempdir().unwrap(); + let store = SqliteUiDataStore::open(dir.path().join("ui_data.sqlite3")).unwrap(); + let wallet = Wallet::from_seed("confirmed-v2-wallet-history"); + let ledger = Ledger::new(BTreeMap::from([(wallet.address().to_string(), 100_000)]), 1); + let domain = ledger.transaction_v2_domain().unwrap(); + let transaction = ledger.build_v2_migration_batch(&wallet, 100).unwrap(); + let mut snapshot = ledger.snapshot(); + snapshot.blocks[0].transactions_v2 = vec![hex_encode(transaction.encode(&domain).unwrap())]; + let rows = wallet_transactions_v2_from_snapshot(&snapshot, &domain); + let network = AddressNetwork::from_profile_id(&snapshot.launch_profile.profile_id); + let hybrid_address = wallet.hybrid_address(network); + + assert!(rows.iter().any(|(address, _)| address == &hybrid_address)); + store + .with_connection_mut(|connection| { + let transaction = connection.transaction()?; + replace_ui_wallet_transactions_v2(&transaction, &rows)?; + transaction.commit()?; + Ok(()) + }) + .unwrap(); + let (projected, total) = store + .load_wallet_transactions_v2( + std::slice::from_ref(&hybrid_address), + &["transfer"], + 0, + 10, + ) + .unwrap(); + assert_eq!(total, 1); + assert_eq!(projected[0].transaction_id.len(), 64); + assert_eq!(projected[0].block_height, 0); + + store + .with_connection_mut(|connection| { + let transaction = connection.transaction()?; + replace_ui_wallet_transactions_v2(&transaction, &[])?; + transaction.commit()?; + Ok(()) + }) + .unwrap(); + let (_, total) = store + .load_wallet_transactions_v2(&[hybrid_address], &["transfer"], 0, 10) + .unwrap(); + assert_eq!(total, 0); + } + + #[test] fn opening_legacy_ui_schema_rebuilds_the_derived_cache() { let dir = tempdir().unwrap(); let path = dir.path().join("ui_data.sqlite3"); diff --git a/src/adapters/ui_index.rs b/src/adapters/ui_index.rs @@ -1,8 +1,9 @@ use std::collections::BTreeMap; use crate::domain::{ - Block, BurnLeaderRank, ChainSnapshot, Ledger, OutPoint, Transaction, TxOutput, - genesis_allocation_outpoint, reward_outputs_for_block, + AddressNetwork, Block, BurnLeaderRank, ChainSnapshot, Ledger, OutPoint, Transaction, + TransactionV2, TxOutput, decode_hex, encode_versioned_address, genesis_allocation_outpoint, + hex_encode, reward_outputs_for_block, }; #[derive(Clone, Debug, Default, Eq, PartialEq)] @@ -48,6 +49,7 @@ pub(crate) fn burn_leader_ranks_for_blocks( fn known_chain_output_index(snapshot: &ChainSnapshot) -> BTreeMap<OutPoint, TxOutput> { let mut outputs = BTreeMap::new(); + let network = AddressNetwork::from_profile_id(&snapshot.launch_profile.profile_id); let mut running_ledger = snapshot.blocks.first().cloned().and_then(|genesis| { Ledger::from_preverified_snapshot(ChainSnapshot { genesis_allocations: snapshot.genesis_allocations.clone(), @@ -73,6 +75,15 @@ fn known_chain_output_index(snapshot: &ChainSnapshot) -> BTreeMap<OutPoint, TxOu for transaction in &block.transactions { index_transaction_outputs(&mut outputs, transaction); } + for envelope in &block.transactions_v2 { + let Ok(bytes) = decode_hex(envelope) else { + continue; + }; + let Ok((domain, transaction)) = TransactionV2::decode(&bytes) else { + continue; + }; + index_transaction_v2_outputs(&mut outputs, &transaction, &domain, network); + } let reward_committee = if block.height == 0 { Vec::new() } else { @@ -93,6 +104,42 @@ fn known_chain_output_index(snapshot: &ChainSnapshot) -> BTreeMap<OutPoint, TxOu outputs } +fn index_transaction_v2_outputs( + outputs: &mut BTreeMap<OutPoint, TxOutput>, + transaction: &TransactionV2, + domain: &crate::domain::TransactionV2Domain, + network: AddressNetwork, +) { + let transaction_outputs = match transaction { + TransactionV2::Migration { outputs, .. } | TransactionV2::Transfer { outputs, .. } => { + outputs.as_slice() + } + TransactionV2::Burn { change, .. } => change.as_slice(), + TransactionV2::Mine { .. } => return, + }; + let Ok(transaction_id) = transaction.transaction_id(domain).map(hex_encode) else { + return; + }; + for (index, output) in transaction_outputs.iter().enumerate() { + let Ok(address) = encode_versioned_address(output.address, network) else { + continue; + }; + let Ok(index) = u32::try_from(index) else { + continue; + }; + outputs.insert( + OutPoint { + txid: transaction_id.clone(), + index, + }, + TxOutput { + address, + amount: output.amount, + }, + ); + } +} + fn index_transaction_outputs( outputs: &mut BTreeMap<OutPoint, TxOutput>, transaction: &Transaction,