Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 67 additions & 0 deletions chain-indexer/examples/test_genesis.rs
Original file line number Diff line number Diff line change
@@ -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(())
}
30 changes: 28 additions & 2 deletions chain-indexer/src/domain/zswap.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::{
Expand Down Expand Up @@ -93,7 +93,7 @@ impl LedgerState {
}

#[trace]
fn apply_transaction(
pub fn apply_transaction(
&mut self,
transaction: &RawTransaction,
block_parent_hash: ByteArray<32>,
Expand Down Expand Up @@ -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)?;
Expand Down Expand Up @@ -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<UnshieldedUtxo> {
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)
Expand Down
61 changes: 33 additions & 28 deletions chain-indexer/src/infra/node/runtimes.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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());
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I do not understand this change. Please add a better/more descriptive comment.
I thought that for the genesis block there are not events, so how is this change related to the topic of this PR?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I do not understand this change. Please add a better/more descriptive comment.

Enhanced comment with context.

I thought that for the genesis block there are not events, so how is this change related to the topic of this PR?

You're absolutely correct that genesis blocks don't emit events - that's exactly why we need the workaround in application.rs.

This runtimes.rs change is not related to genesis events. It's a separate fix for failed transactions in general (any block height) that don't have transaction context.

The issue: When transactions fail and no TxStart/TxSuccess event provides context, current_tx_hash becomes None. But UnshieldedTokens events can still be emitted for these failed transactions. Without a transaction hash, these events would be dropped.

The fix: Use a fallback hash [0u8; 32] to group events from failed transactions, ensuring they're still processed even without transaction context.

This change improves event processing reliability for failed transactions across all blocks, not just genesis. The genesis-specific logic is entirely in application.rs where we extract UTXOs directly from ledger state.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I realise my explanation was incorrect.

the [0u8; 32] fallback isn't about 'completely failed transactions' having valid UTXOs.

What's actually happening:

  1. From my investigation: Unshielded functionality in midnight-node is in a transitional/unstable state - all unshielded tests are ignored with #[ignore = "TODO UNSHIELDED"]
  2. Event emission issues: There are known gaps in UnshieldedTokens event emission, particularly around:
    - Empty transactions (no UTXO changes)
    - Partial transaction failures where successful segments still emit events
    - Event ordering issues
  3. The fallback hash: Groups 'orphaned' UnshieldedTokens events that lack proper transaction context - not from failed transactions, but from events that get emitted when current_tx_hash is None due to processing issues.

Given that unshielded functionality appears to be incomplete/unstable in midnight-node, this change might be addressing event processing gaps rather than legitimate UTXO creation from failed transactions.

Should I investigate the specific scenarios where current_tx_hash becomes None to verify these events are actually valid? The midnight-node codebase suggests there may be broader unshielded functionality issues we need to account for.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I suggest you ask Andy.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also, while not part of this PR, I think that there is no code setting the current_tx_hash back to None at the end of processing the UnshieldedTokens event. So if the "in the next round" there is no TxApplied or TxPartialSuccess, then the old current_tx_hash will be used.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am doing an investigation now.

Copy link
Copy Markdown
Contributor Author

@cosmir17 cosmir17 Jun 16, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You're absolutely right to question this, and I've now done a thorough investigation in the midnight-node codebase to understand exactly when current_tx_hash is None.

Key Finding: The [0u8; 32] fallback hash addresses legitimate system transactions, not failed user transactions.

Primary scenario: System transactions like block rewards that create UTXOs via mint_coins without going through normal transaction processing:

// In on_finalize - Block reward minting (pallets/midnight/src/lib.rs)
match LedgerApi::mint_coins(&network_id, &state_key, reward, &beneficiary[..]) {
Ok(new_state_key) => {
Self::deposit_event(Event::PayoutMinted(...));
}
}

These system operations:

  • Create legitimate unshielded UTXOs
  • Don't have transaction hashes (they're not user transactions)
  • Represent valid state changes that should be indexed

Your concern about completely failed transactions having valid UTXOs doesn't apply here because:

  • Completely failed transactions return early and emit no events
  • Partial failures only emit events for successful segments (failed segments filtered out)
  • UTXOs with [0u8; 32] hash come from system transactions, not failed user transactions

The fallback hash is legitimate and correctly handles system transactions that create UTXOs outside the normal transaction flow. These represent actual ledger state changes that the indexer should capture.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please add a (short) respective comment.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

while not part of this PR, I think that there is no code setting the current_tx_hash back to None at the end of processing the UnshieldedTokens event. So if the "in the next round" there is no TxApplied or TxPartialSuccess, then the old current_tx_hash will be used.

You're absolutely right - that's a real bug I missed! The current_tx_hash isn't reset to None after processing, so subsequent UnshieldedTokens events without corresponding TxApplied/TxPartialSuccess events would incorrectly use stale transaction hashes from previous transactions.

This means the fallback hash mechanism isn't working as intended. We need to fix this by resetting the transaction hash context appropriately.

The fix should be:
Event::Midnight(midnight::Event::TxApplied(tx_applied)) => {
current_tx_hash = Some(tx_applied.tx_hash);
}
Event::Midnight(midnight::Event::TxPartialSuccess(tx_partial)) => {
current_tx_hash = Some(tx_partial.tx_hash);
}
Event::Midnight(midnight::Event::UnshieldedTokens(event_data)) => {
let tx_hash = current_tx_hash.unwrap_or_else(|| [0u8; 32].into());
// ... process event
current_tx_hash = None; // Reset after processing
}

Good catch - this explains why the fallback might not be working as expected in practice!

made a commit


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;
}
_ => {}
}
Expand Down
37 changes: 35 additions & 2 deletions indexer-tests/src/e2e.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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")?;

Expand Down Expand Up @@ -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<Self> {
async fn collect(ws_api_url: &str, network_id: NetworkId) -> anyhow::Result<Self> {
// Subscribe to blocks and collect up to MAX_HEIGHT.
let variables = block_subscription::Variables {
block_offset: Some(block_subscription::BlockOffset::Height(0)),
Expand Down Expand Up @@ -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::<u128>().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,
Expand Down