diff --git a/chain-indexer/examples/test_genesis.rs b/chain-indexer/examples/test_genesis.rs new file mode 100644 index 000000000..6237245e2 --- /dev/null +++ b/chain-indexer/examples/test_genesis.rs @@ -0,0 +1,67 @@ +use anyhow::Context; +use chain_indexer::{ + domain::Node, + infra::node::{Config, SubxtNode}, +}; +use futures::{StreamExt, TryStreamExt}; +use indexer_common::domain::{NetworkId, PROTOCOL_VERSION_000_013_000}; +use std::{pin::pin, time::Duration}; + +/// Simple test to verify connection to midnight-node and basic block retrieval. +/// Note: This test bypasses the full indexing pipeline and calls the node interface +/// directly via `node.finalized_blocks()`. As a result, it doesn't trigger the +/// genesis UTXO extraction that happens in the zswap transaction processing layer. +/// +/// For proper genesis UTXO extraction testing, use the e2e tests which go through +/// the complete indexing pipeline. +/// +/// Background: +/// - Genesis blocks don't emit UnshieldedTokens events due to Substrate PR #5463. +/// - Genesis UTXO extraction is integrated into zswap transaction processing. +/// - Full extraction only occurs when blocks are processed through the indexing pipeline. +#[tokio::main] +async fn main() -> anyhow::Result<()> { + // Note: logging is disabled for this simple test + + let config = Config { + url: "ws://localhost:9944".to_string(), + genesis_protocol_version: PROTOCOL_VERSION_000_013_000, + reconnect_max_delay: Duration::from_secs(1), + reconnect_max_attempts: 3, + }; + let mut node = SubxtNode::new(config).await.context("create SubxtNode")?; + + let blocks = node.finalized_blocks(None, NetworkId::Undeployed).take(3); + let mut blocks = pin!(blocks); + + while let Some(block) = blocks.try_next().await.context("get next block")? { + println!("## BLOCK: height={}, \thash={}", block.height, block.hash); + + // For genesis block, note that UTXO extraction doesn't happen in this test + if block.height == 0 { + println!("*** GENESIS BLOCK DETECTED ***"); + + let utxo_count = block + .transactions + .get(0) + .map(|t| t.created_unshielded_utxos.len()) + .unwrap_or(0); + + println!( + "*** UTXOs: {} (extraction requires full indexing pipeline) ***", + utxo_count + ); + } + + for transaction in &block.transactions { + println!( + " ## TRANSACTION: hash={}, created_utxos={}, spent_utxos={}", + transaction.hash, + transaction.created_unshielded_utxos.len(), + transaction.spent_unshielded_utxos.len() + ); + } + } + + Ok(()) +} diff --git a/chain-indexer/src/domain/zswap.rs b/chain-indexer/src/domain/zswap.rs index 561de6abc..00c283743 100644 --- a/chain-indexer/src/domain/zswap.rs +++ b/chain-indexer/src/domain/zswap.rs @@ -11,7 +11,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -use crate::domain::Transaction; +use crate::domain::{Transaction, UnshieldedUtxo}; use derive_more::derive::{Deref, From}; use fastrace::trace; use indexer_common::{ @@ -93,7 +93,7 @@ impl LedgerState { } #[trace] - fn apply_transaction( + pub fn apply_transaction( &mut self, transaction: &RawTransaction, block_parent_hash: ByteArray<32>, @@ -153,6 +153,13 @@ impl LedgerState { )?; let zswap = &self.zswap; + // Handle genesis block: extract any pre-funded unshielded UTXOs. + // Check if this is genesis block by examining parent hash. + if block_parent_hash == ByteArray([0; 32]) { + let utxos = extract_utxos_from_ledger_state(self); + transaction.created_unshielded_utxos.extend(utxos); + } + // Update end_index and contract zswap state if necessary. if zswap.first_free > start_index { update_contract_zswap_state(zswap, transaction, network_id)?; @@ -230,6 +237,25 @@ fn extract_merkle_tree_root( Ok(root.into()) } +/// Extract UTXOs from the midnight-ledger state and convert them to indexer format. +fn extract_utxos_from_ledger_state(ledger_state: &LedgerState) -> Vec { + let midnight_ledger_state = &ledger_state.0.0; + let utxo_state = &midnight_ledger_state.utxo; + + utxo_state + .utxos + .iter() + .map(|utxo| UnshieldedUtxo { + creating_transaction_id: 0, + output_index: utxo.output_no, + owner_address: utxo.owner.0.0.as_slice().into(), + token_type: utxo.type_.0.0.into(), + intent_hash: utxo.intent_hash.0.0.into(), + value: utxo.value, + }) + .collect() +} + /// Converts a block timestamp which is in milliseconds to a ledger timestamp. fn timestamp(block_timestamp: u64) -> Timestamp { Timestamp::from_secs(block_timestamp / 1000) diff --git a/chain-indexer/src/infra/node/runtimes.rs b/chain-indexer/src/infra/node/runtimes.rs index 1ae2b3bf6..a8a71e5f7 100644 --- a/chain-indexer/src/infra/node/runtimes.rs +++ b/chain-indexer/src/infra/node/runtimes.rs @@ -172,35 +172,40 @@ macro_rules! make_block_details { current_tx_hash = Some(tx_partial.tx_hash); } Event::Midnight(midnight::Event::UnshieldedTokens(event_data)) => { - // Use the most recent transaction hash - if let Some(tx_hash) = current_tx_hash { - if !event_data.created.is_empty() { - let abstracted_created = event_data.created - .into_iter() - .map(|utxo| UtxoInfo { - output_no: utxo.output_no, - address: utxo.address.into(), - token_type: utxo.token_type.into(), - intent_hash: utxo.intent_hash.into(), - value: utxo.value, - }) - .collect(); - created_unshielded_utxos_info.insert(tx_hash.into(), abstracted_created); - } - if !event_data.spent.is_empty() { - let abstracted_spent = event_data.spent - .into_iter() - .map(|utxo| UtxoInfo { - output_no: utxo.output_no, - address: utxo.address.into(), - token_type: utxo.token_type.into(), - intent_hash: utxo.intent_hash.into(), - value: utxo.value, - }) - .collect(); - spent_unshielded_utxos_info.insert(tx_hash.into(), abstracted_spent); - } + // Use transaction hash from preceding TxApplied/TxPartialSuccess events, + // or fallback hash [0u8; 32] for system transactions (block rewards, minting) + // that create UTXOs without transaction context. + let tx_hash = current_tx_hash.unwrap_or_else(|| [0u8; 32].into()); + + if !event_data.created.is_empty() { + let created = event_data.created + .into_iter() + .map(|utxo| UtxoInfo { + output_no: utxo.output_no, + address: utxo.address.into(), + token_type: utxo.token_type.into(), + intent_hash: utxo.intent_hash.into(), + value: utxo.value, + }) + .collect(); + created_unshielded_utxos_info.insert(tx_hash.into(), created); } + if !event_data.spent.is_empty() { + let spent = event_data.spent + .into_iter() + .map(|utxo| UtxoInfo { + output_no: utxo.output_no, + address: utxo.address.into(), + token_type: utxo.token_type.into(), + intent_hash: utxo.intent_hash.into(), + value: utxo.value, + }) + .collect(); + spent_unshielded_utxos_info.insert(tx_hash.into(), spent); + } + + // Reset transaction hash to prevent stale hash usage in subsequent events. + current_tx_hash = None; } _ => {} } diff --git a/indexer-tests/src/e2e.rs b/indexer-tests/src/e2e.rs index f2d80f27c..8b4d07fcf 100644 --- a/indexer-tests/src/e2e.rs +++ b/indexer-tests/src/e2e.rs @@ -75,7 +75,7 @@ pub async fn run(network_id: NetworkId, host: &str, port: u16, secure: bool) -> let api_client = Client::new(); // Collect Indexer data using the block subscription. - let indexer_data = IndexerData::collect(&ws_api_url) + let indexer_data = IndexerData::collect(&ws_api_url, network_id) .await .context("collect Indexer data")?; @@ -129,7 +129,7 @@ struct IndexerData { impl IndexerData { /// Not only collects the Indexer data needed for testing, but also validates it, e.g. that /// block heights start at zero and increment by one. - async fn collect(ws_api_url: &str) -> anyhow::Result { + async fn collect(ws_api_url: &str, network_id: NetworkId) -> anyhow::Result { // Subscribe to blocks and collect up to MAX_HEIGHT. let variables = block_subscription::Variables { block_offset: Some(block_subscription::BlockOffset::Height(0)), @@ -260,6 +260,39 @@ impl IndexerData { assert!(!unshielded_utxos.is_empty()); + // Test genesis UTXOs for non-MainNet networks. + // MainNet has no pre-funded accounts (clean genesis), while test/dev networks + // contain pre-funded UTXOs for testing purposes. This validation ensures the + // genesis UTXO extraction workaround works correctly on networks where it's needed. + if network_id != NetworkId::MainNet { + let genesis_block = blocks + .iter() + .find(|block| block.height == 0) + .context("genesis block not found")?; + + // Genesis block should have exactly one transaction. + assert_eq!(genesis_block.transactions.len(), 1); + + let genesis_transaction = &genesis_block.transactions[0]; + + // Genesis transaction should have created unshielded UTXOs. + assert!(!genesis_transaction.unshielded_created_outputs.is_empty()); + + // Verify genesis UTXOs have expected properties. + for utxo in &genesis_transaction.unshielded_created_outputs { + // Genesis UTXOs should have positive values. + assert!(utxo.value.parse::().unwrap_or(0) > 0); + + // Genesis UTXOs should have valid owner addresses (non-empty string). + assert!(!utxo.owner.0.is_empty()); + + // Genesis UTXOs should have valid token types. + // Token type validation: attempt to decode as 32-byte array. + // For native tokens, this is typically all zeros. + assert!(utxo.token_type.hex_decode::<[u8; 32]>().is_ok()); + } + } + Ok(Self { blocks, transactions,