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:
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,