Skip to content

Commit 39c5dbf

Browse files
authored
fix: fetch revert reason from RPC (#519)
1 parent d567441 commit 39c5dbf

3 files changed

Lines changed: 151 additions & 9 deletions

File tree

crates/core/generate-pie/src/types/block.rs

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -335,12 +335,24 @@ impl BlockData {
335335
let class_hashes_to_migrate = build_class_hashes_to_migrate(&processed_state_update, &blockifier_state_reader)?;
336336
apply_pre_migration_compiled_class_hashes(&mut initial_reads, &class_hashes_to_migrate);
337337

338+
// Revert reasons exactly as committed on-chain (keyed by tx hash). Used for the receipt
339+
// commitment so the recomputed block hash matches even when the sequencer's revert-reason
340+
// formatting differs from what re-execution would produce.
341+
let committed_revert_reasons: HashMap<Felt, String> = self
342+
.current_block_receipts
343+
.iter()
344+
.filter_map(|(tx_hash, receipt)| {
345+
receipt.execution_result().revert_reason().map(|reason| (*tx_hash, reason.to_string()))
346+
})
347+
.collect();
348+
338349
let block_hash_commitments = compute_block_hash_commitments(
339350
&starknet_api_txns,
340351
&txn_execution_infos,
341352
processed_state_update.thin_state_diff.clone(),
342353
self.current_block.l1_da_mode,
343354
&self.starknet_version,
355+
&committed_revert_reasons,
344356
)
345357
.await
346358
.map_err(|e| {

crates/core/generate-pie/src/utils/block_hash.rs

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
use std::collections::HashMap;
2+
13
use blockifier::transaction::objects::TransactionExecutionInfo;
24
use starknet::core::types::{L1DataAvailabilityMode as CoreL1DataAvailabilityMode, StateDiff as CoreStateDiff};
35
use starknet_api::block::StarknetVersion;
@@ -8,6 +10,7 @@ use starknet_api::core::{ClassHash, CompiledClassHash, ContractAddress, Nonce};
810
use starknet_api::executable_transaction::Transaction as ExecutableTransaction;
911
use starknet_api::state::{StorageKey, ThinStateDiff};
1012
use starknet_api::transaction::fields::TransactionSignature;
13+
use starknet_types_core::felt::Felt;
1114

1215
use crate::conversions::convert_l1_da_mode;
1316
use crate::error::BlockProcessingError;
@@ -26,6 +29,7 @@ pub(crate) fn tx_signature_for_hashing(tx: &ExecutableTransaction) -> Transactio
2629
fn build_transaction_hashing_data(
2730
transactions: &[ExecutableTransaction],
2831
tx_execution_infos: &[TransactionExecutionInfo],
32+
committed_revert_reasons: &HashMap<Felt, String>,
2933
) -> Result<Vec<TransactionHashingData>, BlockProcessingError> {
3034
if transactions.len() != tx_execution_infos.len() {
3135
return Err(BlockProcessingError::new_custom(format!(
@@ -39,7 +43,10 @@ fn build_transaction_hashing_data(
3943
.iter()
4044
.zip(tx_execution_infos.iter())
4145
.map(|(tx, tx_execution_info)| {
42-
let transaction_output = transaction_output_for_block_hash(tx_execution_info);
46+
// Prefer the revert reason committed on-chain (keyed by transaction hash) so the
47+
// recomputed receipt commitment matches the block hash regardless of sequencer version.
48+
let committed_revert_reason = committed_revert_reasons.get(&tx.tx_hash().0).map(String::as_str);
49+
let transaction_output = transaction_output_for_block_hash(tx_execution_info, committed_revert_reason);
4350

4451
TransactionHashingData {
4552
transaction_signature: tx_signature_for_hashing(tx),
@@ -163,8 +170,10 @@ pub async fn compute_block_hash_commitments(
163170
thin_state_diff: ThinStateDiff,
164171
l1_da_mode: CoreL1DataAvailabilityMode,
165172
starknet_version: &StarknetVersion,
173+
committed_revert_reasons: &HashMap<Felt, String>,
166174
) -> Result<BlockHeaderCommitments, BlockProcessingError> {
167-
let transaction_hashing_data = build_transaction_hashing_data(transactions, tx_execution_infos)?;
175+
let transaction_hashing_data =
176+
build_transaction_hashing_data(transactions, tx_execution_infos, committed_revert_reasons)?;
168177
let (commitments, _measurements) = calculate_block_commitments(
169178
&transaction_hashing_data,
170179
thin_state_diff,

crates/core/generate-pie/src/utils/revert_reason.rs

Lines changed: 128 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -5,14 +5,29 @@ use starknet_api::transaction::{RevertedTransactionExecutionStatus, TransactionE
55

66
/// Build the transaction output used for block-hash commitments.
77
///
8-
/// For nested revert summaries, Pathfinder receipts omit the intermediate VM traceback frames even
9-
/// though Blockifier's raw `Display` includes them. Receipt commitment hashing is sensitive to the
10-
/// exact revert-reason string, so we normalize only this receipt-hashing path to the canonical
11-
/// form.
12-
pub(crate) fn transaction_output_for_block_hash(execution_info: &TransactionExecutionInfo) -> TransactionOutputForHash {
8+
/// The receipt commitment hashes `starknet_keccak(revert_reason)` for reverted transactions, so the
9+
/// recomputed block hash only matches the chain if we use the *exact* revert-reason string the
10+
/// sequencer committed. Re-deriving that string from Blockifier's `Display` is brittle: the rule
11+
/// for which intermediate VM tracebacks are kept vs stripped has changed across sequencer versions,
12+
/// so for blocks produced by an older sequencer the re-derived string does not match what was
13+
/// committed.
14+
///
15+
/// Therefore, when the committed revert reason is available (it always is for synced blocks, via
16+
/// `get_block_with_receipts`), we use it verbatim. We only fall back to the best-effort
17+
/// Pathfinder-style normalization when no committed reason is supplied.
18+
pub(crate) fn transaction_output_for_block_hash(
19+
execution_info: &TransactionExecutionInfo,
20+
committed_revert_reason: Option<&str>,
21+
) -> TransactionOutputForHash {
1322
let mut output = execution_info.output_for_hashing();
1423

15-
if let Some(revert_reason) = format_revert_reason_for_block_hash(execution_info.revert_error.as_ref()) {
24+
if execution_info.revert_error.is_some() {
25+
let revert_reason = match committed_revert_reason {
26+
// Canonical: the exact string hashed into the on-chain receipt commitment.
27+
Some(reason) => reason.to_string(),
28+
// Fallback: heuristic normalization of the re-executed revert error.
29+
None => format_revert_reason_for_block_hash(execution_info.revert_error.as_ref()).unwrap_or_default(),
30+
};
1631
output.execution_status =
1732
TransactionExecutionStatus::Reverted(RevertedTransactionExecutionStatus { revert_reason });
1833
}
@@ -456,7 +471,7 @@ mod tests {
456471
let execution_info =
457472
TransactionExecutionInfo { revert_error: Some(constructor_revert_error()), ..Default::default() };
458473

459-
let output = transaction_output_for_block_hash(&execution_info);
474+
let output = transaction_output_for_block_hash(&execution_info, None);
460475

461476
match output.execution_status {
462477
TransactionExecutionStatus::Reverted(RevertedTransactionExecutionStatus { revert_reason }) => {
@@ -477,4 +492,110 @@ mod tests {
477492
Felt::from_hex("0x3d5c1a26ccc599f79dfe517f780f66b8bc318325bc77c9acfafb848de869de4").unwrap()
478493
);
479494
}
495+
496+
// --- CallContract -> CallContract -> "not deployed" revert (no Constructor frame) ---
497+
498+
// Blockifier's raw `Display` (what Pathfinder's `starknet_traceTransaction` returns, and what
499+
// SNOS re-execution produces): the inner VM traceback IS present.
500+
const CALL_CHAIN_TRACE_WITH_VM_TRACEBACK: &str = "Transaction execution has failed:\n0: Error in the called contract (contract address: 0x01fa85856d49323676bf3c6d81e19e444285f6f036ebeaa1770887d12b71b0de, class hash: 0x073414441639dcd11d1846f287650a00c60c416b9d3ba45d31c651672125b2c2, selector: 0x015d40a3d6ca2ac30f4031e42be28da9b056fef9bb7357ac5e85627ee876e5ad):\nError at pc=0:35988:\nCairo traceback (most recent call last):\nUnknown location (pc=0:330)\nUnknown location (pc=0:11695)\n\n1: Error in the called contract (contract address: 0x041a78e741e5af2fec34b695679bc6891742439f7afb8484ecd7766661ad02bf, class hash: 0x0000000000000000000000000000000000000000000000000000000000000000, selector: 0x01987cbd17808b9a23693d4de7e246a443cfe37e6e7fbaeabd7d7e6532b07c3d):\nRequested contract address 0x041a78e741e5af2fec34b695679bc6891742439f7afb8484ecd7766661ad02bf is not deployed.\n";
501+
502+
// The revert reason COMMITTED on-chain for the same tx (from `starknet_getTransactionReceipt`).
503+
// The inner VM traceback was STRIPPED by the (old) sequencer at block-production time; this exact
504+
// string fed the receipt commitment and therefore the stored block hash. SNOS must hash THIS,
505+
// not the re-execution trace.
506+
const CALL_CHAIN_COMMITTED_RECEIPT: &str = "Transaction execution has failed:\n0: Error in the called contract (contract address: 0x01fa85856d49323676bf3c6d81e19e444285f6f036ebeaa1770887d12b71b0de, class hash: 0x073414441639dcd11d1846f287650a00c60c416b9d3ba45d31c651672125b2c2, selector: 0x015d40a3d6ca2ac30f4031e42be28da9b056fef9bb7357ac5e85627ee876e5ad):\n1: Error in the called contract (contract address: 0x041a78e741e5af2fec34b695679bc6891742439f7afb8484ecd7766661ad02bf, class hash: 0x0000000000000000000000000000000000000000000000000000000000000000, selector: 0x01987cbd17808b9a23693d4de7e246a443cfe37e6e7fbaeabd7d7e6532b07c3d):\nRequested contract address 0x041a78e741e5af2fec34b695679bc6891742439f7afb8484ecd7766661ad02bf is not deployed.\n";
507+
508+
// Reconstructs the canonical blockifier error stack for the reverted tx:
509+
// CallContract(0) -> VM traceback -> CallContract(1, undeployed, class hash 0x0) -> "not deployed".
510+
// There is NO Constructor frame.
511+
fn call_contract_chain_not_deployed_revert_error() -> RevertError {
512+
let mut stack = ErrorStack { header: ErrorStackHeader::Execution, stack: Vec::new() };
513+
stack.push(ErrorStackSegment::EntryPoint(copy_entry_point_for_test(
514+
0,
515+
PreambleType::CallContract,
516+
contract_address!("0x01fa85856d49323676bf3c6d81e19e444285f6f036ebeaa1770887d12b71b0de"),
517+
class_hash!("0x073414441639dcd11d1846f287650a00c60c416b9d3ba45d31c651672125b2c2"),
518+
felt!("0x015d40a3d6ca2ac30f4031e42be28da9b056fef9bb7357ac5e85627ee876e5ad"),
519+
)));
520+
stack.push(ErrorStackSegment::Vm(VmExceptionFrame {
521+
pc: Relocatable::from((0, 35988)),
522+
error_attr_value: None,
523+
traceback: Some(
524+
"Cairo traceback (most recent call last):\nUnknown location (pc=0:330)\nUnknown location (pc=0:11695)\n"
525+
.to_string(),
526+
),
527+
}));
528+
stack.push(ErrorStackSegment::EntryPoint(copy_entry_point_for_test(
529+
1,
530+
PreambleType::CallContract,
531+
contract_address!("0x041a78e741e5af2fec34b695679bc6891742439f7afb8484ecd7766661ad02bf"),
532+
class_hash!("0x0"),
533+
felt!("0x01987cbd17808b9a23693d4de7e246a443cfe37e6e7fbaeabd7d7e6532b07c3d"),
534+
)));
535+
stack.push(ErrorStackSegment::StringFrame(
536+
"Requested contract address 0x041a78e741e5af2fec34b695679bc6891742439f7afb8484ecd7766661ad02bf is not \
537+
deployed.\n"
538+
.replace(" ", ""),
539+
));
540+
RevertError::Execution(stack)
541+
}
542+
543+
#[test]
544+
fn call_chain_reconstruction_matches_trace() {
545+
// Sanity check: our reconstructed error stack renders byte-for-byte like Pathfinder's trace.
546+
assert_eq!(call_contract_chain_not_deployed_revert_error().to_string(), CALL_CHAIN_TRACE_WITH_VM_TRACEBACK);
547+
}
548+
549+
// Demonstrates the bug: without the committed revert reason, SNOS re-derives the full trace
550+
// (no Constructor frame => heuristic keeps the VM traceback), which differs from what the
551+
// sequencer committed => different receipt-reason hash => block hash mismatch.
552+
#[test]
553+
fn rederived_reason_diverges_from_committed_receipt() {
554+
let execution_info = TransactionExecutionInfo {
555+
revert_error: Some(call_contract_chain_not_deployed_revert_error()),
556+
..Default::default()
557+
};
558+
559+
let output = transaction_output_for_block_hash(&execution_info, None);
560+
let rederived = match output.execution_status {
561+
TransactionExecutionStatus::Reverted(RevertedTransactionExecutionStatus { revert_reason }) => revert_reason,
562+
status => panic!("expected reverted, got {status:?}"),
563+
};
564+
565+
// The re-derived string keeps the VM traceback and equals the Pathfinder *trace*...
566+
assert_eq!(rederived, CALL_CHAIN_TRACE_WITH_VM_TRACEBACK);
567+
assert!(rederived.contains("Error at pc=0:35988:"));
568+
// ...but NOT the committed *receipt* string, so the receipt-reason hashes diverge.
569+
assert_ne!(rederived, CALL_CHAIN_COMMITTED_RECEIPT);
570+
assert_ne!(
571+
starknet_keccak_hash(rederived.as_bytes()),
572+
starknet_keccak_hash(CALL_CHAIN_COMMITTED_RECEIPT.as_bytes())
573+
);
574+
}
575+
576+
// Demonstrates the fix: when the committed revert reason is supplied (as it now is, from
577+
// `get_block_with_receipts`), SNOS hashes exactly the on-chain string => the receipt-reason
578+
// hash matches what the sequencer committed => block hash matches.
579+
#[test]
580+
fn committed_reason_reproduces_onchain_receipt() {
581+
let execution_info = TransactionExecutionInfo {
582+
revert_error: Some(call_contract_chain_not_deployed_revert_error()),
583+
..Default::default()
584+
};
585+
586+
let output = transaction_output_for_block_hash(&execution_info, Some(CALL_CHAIN_COMMITTED_RECEIPT));
587+
let used = match output.execution_status {
588+
TransactionExecutionStatus::Reverted(RevertedTransactionExecutionStatus { revert_reason }) => revert_reason,
589+
status => panic!("expected reverted, got {status:?}"),
590+
};
591+
592+
// SNOS now uses the committed string verbatim (VM traceback stripped, as the sequencer committed).
593+
assert_eq!(used, CALL_CHAIN_COMMITTED_RECEIPT);
594+
assert!(!used.contains("Error at pc=0:35988:"));
595+
// And the receipt-reason hash matches the on-chain commitment input.
596+
assert_eq!(
597+
starknet_keccak_hash(used.as_bytes()),
598+
starknet_keccak_hash(CALL_CHAIN_COMMITTED_RECEIPT.as_bytes())
599+
);
600+
}
480601
}

0 commit comments

Comments
 (0)