diff --git a/crates/starknet-devnet-core/src/error.rs b/crates/starknet-devnet-core/src/error.rs index 77e73d9f6..919a96916 100644 --- a/crates/starknet-devnet-core/src/error.rs +++ b/crates/starknet-devnet-core/src/error.rs @@ -227,6 +227,8 @@ pub enum ProvingError { NoVirtualProgramHashesAllowed, #[error("Block Id provided doesn't match existing block")] InvalidBlockId, + #[error("Transaction execution failed: {0}")] + TransactionExecutionFailed(String), #[error("Error: {0}")] Other(String), } diff --git a/crates/starknet-devnet-core/src/starknet/add_invoke_transaction.rs b/crates/starknet-devnet-core/src/starknet/add_invoke_transaction.rs index ca2c0a128..9b4fea013 100644 --- a/crates/starknet-devnet-core/src/starknet/add_invoke_transaction.rs +++ b/crates/starknet-devnet-core/src/starknet/add_invoke_transaction.rs @@ -26,16 +26,9 @@ pub fn add_invoke_transaction( }); } - let mut sn_api_transaction = + let sn_api_transaction = broadcasted_invoke_transaction.create_sn_api_invoke(&starknet.chain_id().to_felt())?; - if starknet.config.proof_mode == ProofMode::None { - if let starknet_api::transaction::InvokeTransaction::V3(tx_v3) = &mut sn_api_transaction.tx - { - tx_v3.proof_facts = Default::default(); - } - } - let transaction_hash = sn_api_transaction.tx_hash.0; let invoke_transaction = match broadcasted_invoke_transaction { diff --git a/crates/starknet-devnet-core/src/starknet/mod.rs b/crates/starknet-devnet-core/src/starknet/mod.rs index f98404299..c62d0d3b4 100644 --- a/crates/starknet-devnet-core/src/starknet/mod.rs +++ b/crates/starknet-devnet-core/src/starknet/mod.rs @@ -31,6 +31,7 @@ use starknet_types::emitted_event::EmittedEvent; use starknet_types::felt::{ BlockHash, ClassHash, ProofFacts, TransactionHash, felt_from_prefixed_hex, split_biguint, }; +use starknet_types::messaging::OrderedMessageToL1; use starknet_types::num_bigint::BigUint; use starknet_types::patricia_key::PatriciaKey; use starknet_types::proof::Proof; @@ -943,10 +944,10 @@ impl Starknet { } pub fn prove_transaction( - &self, + &mut self, block_id: CustomBlockId, invoke_transaction: BroadcastedInvokeTransaction, - ) -> DevnetResult<(Proof, ProofFacts)> { + ) -> DevnetResult<(Proof, ProofFacts, Vec)> { match self.config.proof_mode { ProofMode::Devnet => proofs::prove_transaction(self, block_id, invoke_transaction), _ => Err(Error::UnsupportedAction { diff --git a/crates/starknet-devnet-core/src/starknet/proofs.rs b/crates/starknet-devnet-core/src/starknet/proofs.rs index 4c0c692da..27a7752f5 100644 --- a/crates/starknet-devnet-core/src/starknet/proofs.rs +++ b/crates/starknet-devnet-core/src/starknet/proofs.rs @@ -4,7 +4,11 @@ use starknet_rs_core::types::Felt; use starknet_types::felt::ProofFacts; use starknet_types::proof::Proof; use starknet_types::rpc::block::BlockId; -use starknet_types::rpc::transactions::BroadcastedInvokeTransaction; +use starknet_types::rpc::messaging::OrderedMessageToL1; +use starknet_types::rpc::transactions::{ + BroadcastedInvokeTransaction, BroadcastedTransaction, ExecutionInvocation, SimulationFlag, + SimulationResult, TransactionTrace, +}; use starknet_types_core::hash::{Pedersen, StarkHash}; use tracing::debug; @@ -29,10 +33,26 @@ fn proof_to_felt(proof: &Proof) -> Option { Some(Felt::from_bytes_be(&bytes)) } -pub fn prove_transaction( +/// Compute a hash over the L2→L1 messages to bind them into the proof. +fn compute_messages_hash(messages: &[OrderedMessageToL1]) -> Felt { + if messages.is_empty() { + return Felt::ZERO; + } + let message_hashes: Vec = messages + .iter() + .map(|m| { + let h = m.message.hash(); + Felt::from_bytes_be_slice(h.as_bytes()) + }) + .collect(); + Pedersen::hash_array(&message_hashes) +} + +pub fn generate_proof( starknet: &Starknet, - block_id: BlockId, - broadcasted_invoke_transaction: BroadcastedInvokeTransaction, + block_id: &BlockId, + broadcasted_invoke_transaction: &BroadcastedInvokeTransaction, + messages_hash: Felt, ) -> DevnetResult<(Proof, ProofFacts)> { debug!("Generating devnet proof for invoke transaction at block_id: {block_id:?}"); @@ -43,7 +63,7 @@ pub fn prove_transaction( .allowed_virtual_os_program_hashes .first() .ok_or(ProvingError::NoVirtualProgramHashesAllowed)?; - let block = starknet.get_block(&block_id).map_err(|_| ProvingError::InvalidBlockId)?; + let block = starknet.get_block(block_id).map_err(|_| ProvingError::InvalidBlockId)?; let block_number_felt = Felt::from(block.block_number().0); let config_hash = OsChainInfo::from(block_context.chain_info()) .compute_virtual_os_config_hash() @@ -57,7 +77,12 @@ pub fn prove_transaction( debug!("Computed invoke transaction hash for proof generation: {tx_hash:#x}"); - let proof_felt = Pedersen::hash_array(&[tx_hash, DEVNET_PROOF_MAGIC.into()]); + let proof_felt = Pedersen::hash_array(&[ + block.block_hash(), + tx_hash, + DEVNET_PROOF_MAGIC.into(), + messages_hash, + ]); let proof = felt_to_proof(proof_felt); let last_field = Pedersen::hash_array(&[ @@ -68,6 +93,7 @@ pub fn prove_transaction( block_number_felt, block.block_hash(), config_hash, + messages_hash, proof_felt, ]); @@ -79,6 +105,7 @@ pub fn prove_transaction( block_number_felt, block.block_hash(), config_hash, + messages_hash, last_field, ]; @@ -92,9 +119,61 @@ pub fn prove_transaction( Ok((proof, proof_facts)) } +pub fn prove_transaction( + starknet: &mut Starknet, + block_id: BlockId, + broadcasted_invoke_transaction: BroadcastedInvokeTransaction, +) -> DevnetResult<(Proof, ProofFacts, Vec)> { + // Execute the transaction first to extract L2→L1 messages + let l2_to_l1_messages = + extract_l2_to_l1_messages(starknet, &block_id, broadcasted_invoke_transaction.clone())?; + + let messages_hash = compute_messages_hash(&l2_to_l1_messages); + + let (proof, proof_facts) = + generate_proof(starknet, &block_id, &broadcasted_invoke_transaction, messages_hash)?; + + Ok((proof, proof_facts, l2_to_l1_messages)) +} + +/// Executes the transaction and extracts L2→L1 messages from the trace. +fn extract_l2_to_l1_messages( + starknet: &mut Starknet, + block_id: &BlockId, + invoke_transaction: BroadcastedInvokeTransaction, +) -> DevnetResult> { + let tx = BroadcastedTransaction::Invoke(invoke_transaction); + let simulation_result = starknet + .simulate_transactions(block_id, &[tx], vec![SimulationFlag::SkipFeeCharge]) + .map_err(|e| ProvingError::TransactionExecutionFailed(e.to_string()))?; + + let simulated = match simulation_result { + SimulationResult::SimulatedTransactions(txs) => txs.into_iter().next(), + SimulationResult::SimulatedTransactionsWithInitialReads(r) => { + r.simulated_transactions.into_iter().next() + } + } + .ok_or_else(|| { + ProvingError::TransactionExecutionFailed("no transaction result returned".to_string()) + })?; + + if let TransactionTrace::Invoke(invoke_trace) = simulated.transaction_trace { + if let ExecutionInvocation::Succeeded(func) = invoke_trace.execute_invocation { + return Ok(func.all_messages()); + } + return Err(ProvingError::TransactionExecutionFailed( + "invoke execution did not succeed".to_string(), + ) + .into()); + } + + Err(ProvingError::TransactionExecutionFailed("unexpected transaction trace type".to_string()) + .into()) +} + pub fn verify_proof(proof: Proof, proof_facts: ProofFacts) -> bool { let mut input = proof_facts.clone(); - if input.len() != 8 { + if input.len() != 9 { debug!("Proof verification failed: invalid proof_facts length: {}", input.len()); return false; } @@ -118,7 +197,7 @@ pub fn verify_proof(proof: Proof, proof_facts: ProofFacts) -> bool { input.push(proof_felt); let is_valid = Pedersen::hash_array(&input) == last_field; if is_valid { - debug!("Proof verification succeeded "); + debug!("Proof verification succeeded"); } else { debug!("Proof verification failed: commitment mismatch"); } @@ -165,19 +244,20 @@ mod tests { } #[test] - fn test_prove_transaction_generates_valid_proof() { + fn test_generate_proof_produces_valid_output() { let starknet = create_test_starknet(); let tx = create_test_invoke_transaction(); - let (proof, proof_facts) = prove_transaction( + let (proof, proof_facts) = generate_proof( &starknet, - BlockId::Tag(starknet_types::rpc::block::BlockTag::Latest), - tx, + &BlockId::Tag(starknet_types::rpc::block::BlockTag::Latest), + &tx, + Felt::ZERO, ) .unwrap(); // Verify proof facts has correct length - assert_eq!(proof_facts.len(), 8, "proof_facts should have 8 elements"); + assert_eq!(proof_facts.len(), 9, "proof_facts should have 9 elements"); // Verify proof has correct length (32 u8 values) assert_eq!(proof.len(), 32, "proof should have 32 u8 values"); @@ -188,10 +268,11 @@ mod tests { let starknet = create_test_starknet(); let tx = create_test_invoke_transaction(); - let (proof, proof_facts) = prove_transaction( + let (proof, proof_facts) = generate_proof( &starknet, - BlockId::Tag(starknet_types::rpc::block::BlockTag::Latest), - tx, + &BlockId::Tag(starknet_types::rpc::block::BlockTag::Latest), + &tx, + Felt::ZERO, ) .unwrap(); @@ -203,10 +284,11 @@ mod tests { let starknet = create_test_starknet(); let tx = create_test_invoke_transaction(); - let (_proof, proof_facts) = prove_transaction( + let (_proof, proof_facts) = generate_proof( &starknet, - BlockId::Tag(starknet_types::rpc::block::BlockTag::Latest), - tx, + &BlockId::Tag(starknet_types::rpc::block::BlockTag::Latest), + &tx, + Felt::ZERO, ) .unwrap(); let wrong_proof = vec![0xDEu8; 32]; @@ -219,10 +301,11 @@ mod tests { let starknet = create_test_starknet(); let tx = create_test_invoke_transaction(); - let (proof, mut proof_facts) = prove_transaction( + let (proof, mut proof_facts) = generate_proof( &starknet, - BlockId::Tag(starknet_types::rpc::block::BlockTag::Latest), - tx, + &BlockId::Tag(starknet_types::rpc::block::BlockTag::Latest), + &tx, + Felt::ZERO, ) .unwrap(); @@ -269,10 +352,11 @@ mod tests { let starknet = create_test_starknet(); let tx = create_test_invoke_transaction(); - let (_proof, proof_facts) = prove_transaction( + let (_proof, proof_facts) = generate_proof( &starknet, - BlockId::Tag(starknet_types::rpc::block::BlockTag::Latest), - tx, + &BlockId::Tag(starknet_types::rpc::block::BlockTag::Latest), + &tx, + Felt::ZERO, ) .unwrap(); @@ -289,21 +373,23 @@ mod tests { } #[test] - fn test_prove_transaction_deterministic() { + fn test_generate_proof_deterministic() { let starknet = create_test_starknet(); let tx1 = create_test_invoke_transaction(); let tx2 = create_test_invoke_transaction(); - let (proof1, proof_facts1) = prove_transaction( + let (proof1, proof_facts1) = generate_proof( &starknet, - BlockId::Tag(starknet_types::rpc::block::BlockTag::Latest), - tx1, + &BlockId::Tag(starknet_types::rpc::block::BlockTag::Latest), + &tx1, + Felt::ZERO, ) .unwrap(); - let (proof2, proof_facts2) = prove_transaction( + let (proof2, proof_facts2) = generate_proof( &starknet, - BlockId::Tag(starknet_types::rpc::block::BlockTag::Latest), - tx2, + &BlockId::Tag(starknet_types::rpc::block::BlockTag::Latest), + &tx2, + Felt::ZERO, ) .unwrap(); @@ -313,7 +399,7 @@ mod tests { } #[test] - fn test_prove_transaction_different_for_different_transactions() { + fn test_generate_proof_different_for_different_transactions() { let starknet = create_test_starknet(); let tx1 = create_test_invoke_transaction(); let mut tx2 = create_test_invoke_transaction(); @@ -322,16 +408,18 @@ mod tests { let BroadcastedInvokeTransaction::V3(ref mut v3) = tx2; v3.common.nonce = Felt::ONE; - let (proof1, _) = prove_transaction( + let (proof1, _) = generate_proof( &starknet, - BlockId::Tag(starknet_types::rpc::block::BlockTag::Latest), - tx1, + &BlockId::Tag(starknet_types::rpc::block::BlockTag::Latest), + &tx1, + Felt::ZERO, ) .unwrap(); - let (proof2, _) = prove_transaction( + let (proof2, _) = generate_proof( &starknet, - BlockId::Tag(starknet_types::rpc::block::BlockTag::Latest), - tx2, + &BlockId::Tag(starknet_types::rpc::block::BlockTag::Latest), + &tx2, + Felt::ZERO, ) .unwrap(); @@ -339,29 +427,45 @@ mod tests { assert_ne!(proof1, proof2, "different transactions should produce different proofs"); } + #[test] + fn test_prove_transaction_fails_on_non_executable_tx() { + let mut starknet = create_test_starknet(); + let tx = create_test_invoke_transaction(); + + let result = prove_transaction( + &mut starknet, + BlockId::Tag(starknet_types::rpc::block::BlockTag::Latest), + tx, + ); + + assert!(result.is_err(), "prove_transaction should fail for non-executable transactions"); + } + #[test] fn test_proof_facts_structure() { let starknet = create_test_starknet(); let tx = create_test_invoke_transaction(); - let (proof, proof_facts) = prove_transaction( + let (proof, proof_facts) = generate_proof( &starknet, - BlockId::Tag(starknet_types::rpc::block::BlockTag::Latest), - tx, + &BlockId::Tag(starknet_types::rpc::block::BlockTag::Latest), + &tx, + Felt::ZERO, ) .unwrap(); // Verify proof_facts contains expected fields assert_eq!(proof_facts[0], PROOF_VERSION, "first field should be proof_version"); assert_eq!(proof_facts[1], VIRTUAL_SNOS, "second field should be variant_marker"); + assert_eq!(proof_facts[7], Felt::ZERO, "eighth field should be messages_hash"); // Last field should be hash of all previous fields plus proof_felt let proof_felt = proof_to_felt(&proof).expect("proof should convert to felt"); - let mut input = proof_facts[0..7].to_vec(); + let mut input = proof_facts[0..8].to_vec(); input.push(proof_felt); let expected_last_field = Pedersen::hash_array(&input); assert_eq!( - proof_facts[7], expected_last_field, + proof_facts[8], expected_last_field, "last field should be hash of previous fields plus proof" ); } diff --git a/crates/starknet-devnet-server/src/api/endpoints.rs b/crates/starknet-devnet-server/src/api/endpoints.rs index 5a92b8e11..f75d0d718 100644 --- a/crates/starknet-devnet-server/src/api/endpoints.rs +++ b/crates/starknet-devnet-server/src/api/endpoints.rs @@ -776,11 +776,15 @@ impl JsonRpcHandler { }); } }; - let starknet = self.api.starknet.lock().await; + let mut starknet = self.api.starknet.lock().await; match starknet.prove_transaction(block_id, invoke_transaction) { - Ok((proof, proof_facts)) => { - Ok(StarknetExtResponse::Proof(ProveTransactionResponse { proof, proof_facts }) - .into()) + Ok((proof, proof_facts, l2_to_l1_messages)) => { + Ok(StarknetExtResponse::Proof(ProveTransactionResponse { + proof, + proof_facts, + l2_to_l1_messages, + }) + .into()) } Err(e) => Err(ApiError::ProvingError { msg: e.to_string() }), } diff --git a/crates/starknet-devnet-server/src/api/models/mod.rs b/crates/starknet-devnet-server/src/api/models/mod.rs index 0a235f82d..d3709ee5c 100644 --- a/crates/starknet-devnet-server/src/api/models/mod.rs +++ b/crates/starknet-devnet-server/src/api/models/mod.rs @@ -19,7 +19,7 @@ use starknet_types::proof::Proof; use starknet_types::rpc::block::{ BlockId, StorageResponseFlag, SubscriptionBlockId, TransactionResponseFlag, }; -use starknet_types::rpc::messaging::{MessageToL1, MessageToL2}; +use starknet_types::rpc::messaging::{MessageToL1, MessageToL2, OrderedMessageToL1}; use starknet_types::rpc::transaction_receipt::FeeUnit; use starknet_types::rpc::transactions::{ BroadcastedDeclareTransaction, BroadcastedDeployAccountTransaction, @@ -282,6 +282,7 @@ pub struct ProveTransactionInput { pub struct ProveTransactionResponse { pub proof: Proof, pub proof_facts: ProofFacts, + pub l2_to_l1_messages: Vec, } #[derive(Debug, Serialize, Deserialize)] diff --git a/crates/starknet-devnet-types/src/rpc/transactions.rs b/crates/starknet-devnet-types/src/rpc/transactions.rs index 77cd2a300..033aa132c 100644 --- a/crates/starknet-devnet-types/src/rpc/transactions.rs +++ b/crates/starknet-devnet-types/src/rpc/transactions.rs @@ -1186,7 +1186,10 @@ impl FunctionInvocation { let mut messages: Vec = vec![]; for msg in call_info.execution.l2_to_l1_messages.iter() { - messages.push(OrderedMessageToL1::new(msg, call_info.call.caller_address.into())?); + // Keep sender semantics aligned with transaction receipts. + // `storage_address` identifies the contract that emitted the message, + // including delegate/library syscall flows. + messages.push(OrderedMessageToL1::new(msg, call_info.call.storage_address.into())?); } messages.sort_by_key(|msg| msg.order); @@ -1236,6 +1239,21 @@ impl FunctionInvocation { is_reverted: call_info.execution.failed, }) } + + /// Returns the direct L2→L1 messages from this invocation. + pub fn messages(&self) -> &[OrderedMessageToL1] { + &self.messages + } + + /// Recursively collects all L2→L1 messages from this invocation and nested calls. + pub fn all_messages(&self) -> Vec { + let mut result = self.messages.clone(); + for call in &self.calls { + result.extend(call.all_messages()); + } + result.sort_by_key(|msg| msg.order); + result + } } #[derive(Debug, Clone, Serialize, Deserialize)] diff --git a/crates/starknet-devnet/src/cli.rs b/crates/starknet-devnet/src/cli.rs index 54e05db1f..d8c2ed94c 100644 --- a/crates/starknet-devnet/src/cli.rs +++ b/crates/starknet-devnet/src/cli.rs @@ -190,7 +190,7 @@ pub(crate) struct Args { #[arg(help = "Specify how to calculate and verify transaction proofs. Possible values are: - \"full\" - proofs are generated and verified fully - \"devnet\" - devnet creates fake proof that it later verifies -- \"none\" - proof and proof_facts fields of transactions are ignored +- \"none\" - proof field of transactions is ignored ")] proof_mode: ProofMode, diff --git a/tests/integration/common/background_devnet.rs b/tests/integration/common/background_devnet.rs index 2d959894c..b066cbbe6 100644 --- a/tests/integration/common/background_devnet.rs +++ b/tests/integration/common/background_devnet.rs @@ -11,7 +11,7 @@ use serde_json::json; use starknet_rs_core::types::{ BlockId, BlockTag, BlockWithTxHashes, BlockWithTxs, BroadcastedInvokeTransaction, Felt, FunctionCall, MaybePreConfirmedBlockWithTxHashes, MaybePreConfirmedBlockWithTxs, - PreConfirmedBlockWithTxHashes, PreConfirmedBlockWithTxs, + OrderedMessage, PreConfirmedBlockWithTxHashes, PreConfirmedBlockWithTxs, }; use starknet_rs_core::utils::get_selector_from_name; use starknet_rs_providers::jsonrpc::HttpTransport; @@ -51,6 +51,7 @@ pub struct ProveTransactionResult { #[allow(unused)] pub proof: Vec, pub proof_facts: Vec, + pub l2_to_l1_messages: Vec, } lazy_static! { @@ -269,9 +270,10 @@ impl BackgroundDevnet { } } - pub async fn prove_transaction( + pub async fn prove_transaction_at_block( &self, transaction: BroadcastedInvokeTransaction, + block_id: BlockId, ) -> ProveTransactionResult { let mut transaction = serde_json::to_value(transaction) .expect("Failed to serialize transaction for proveTransaction"); @@ -285,7 +287,7 @@ impl BackgroundDevnet { .send_custom_rpc( "starknet_proveTransaction", json!({ - "block_id": "latest", + "block_id": serde_json::to_value(block_id).expect("Failed to serialize block_id"), "transaction": transaction }), ) @@ -330,7 +332,25 @@ impl BackgroundDevnet { .collect::, _>>() .expect("Invalid felt in proof_facts response"); - ProveTransactionResult { proof_base64, proof_facts_hex, proof, proof_facts } + let l2_to_l1_messages = result + .get("l2_to_l1_messages") + .map(|value| serde_json::from_value(value.clone()).expect("Invalid l2_to_l1_messages")) + .unwrap_or_default(); + + ProveTransactionResult { + proof_base64, + proof_facts_hex, + proof, + proof_facts, + l2_to_l1_messages, + } + } + + pub async fn prove_transaction( + &self, + transaction: BroadcastedInvokeTransaction, + ) -> ProveTransactionResult { + self.prove_transaction_at_block(transaction, BlockId::Tag(BlockTag::Latest)).await } pub fn clone_provider(&self) -> JsonRpcClient { diff --git a/tests/integration/common/utils.rs b/tests/integration/common/utils.rs index 8d3379986..8ca9db533 100644 --- a/tests/integration/common/utils.rs +++ b/tests/integration/common/utils.rs @@ -191,7 +191,7 @@ pub async fn create_proof_bearing_transaction( .prepared() .unwrap(); - let invoke_for_prove = prepared_for_prove.get_invoke_request(false, true).await.unwrap(); + let invoke_for_prove = prepared_for_prove.get_invoke_request(false, false).await.unwrap(); let prove_result = devnet.prove_transaction(invoke_for_prove).await; let proof = prove_result.proof_base64; diff --git a/tests/integration/get_transaction_by_hash.rs b/tests/integration/get_transaction_by_hash.rs index fac998607..6d921345f 100644 --- a/tests/integration/get_transaction_by_hash.rs +++ b/tests/integration/get_transaction_by_hash.rs @@ -157,8 +157,8 @@ async fn get_transaction_by_hash_response_flags_control_proof_facts() { .expect("proof_facts should be returned when IncludeProofFacts is requested"); assert_eq!( returned_proof_facts.len(), - 8, - "proof_facts should contain expected 8 elements" + 9, + "proof_facts should contain expected 9 elements" ); assert_eq!( returned_proof_facts, submitted_proof_facts, diff --git a/tests/integration/messaging.rs b/tests/integration/messaging.rs index 78ffb4284..349652b0c 100644 --- a/tests/integration/messaging.rs +++ b/tests/integration/messaging.rs @@ -40,7 +40,7 @@ use crate::common::utils::{ send_ctrl_c_signal_and_wait, }; -const DUMMY_L1_ADDRESS: Felt = +pub(crate) const DUMMY_L1_ADDRESS: Felt = Felt::from_hex_unchecked("0xc662c410c0ecf747543f5ba90660f6abebd9c8c4"); const MESSAGE_WITHDRAW_OPCODE: Felt = Felt::ZERO; @@ -66,7 +66,7 @@ async fn withdraw( } /// Increases the balance for the given user. -async fn increase_balance( +pub(crate) async fn increase_balance( account: A, contract_address: Felt, user: Felt, @@ -151,7 +151,7 @@ async fn deploy_l2_msg_contract( /// Sets up a `BackgroundDevnet` with the message l1-l2 contract deployed. /// Returns (devnet instance, account used for deployment, l1-l2 contract address). -async fn setup_devnet( +pub(crate) async fn setup_devnet( devnet_args: &[&str], ) -> Result< (BackgroundDevnet, Arc, LocalWallet>>, Felt), diff --git a/tests/integration/prove_transaction.rs b/tests/integration/prove_transaction.rs index d5270782c..826fd887f 100644 --- a/tests/integration/prove_transaction.rs +++ b/tests/integration/prove_transaction.rs @@ -1,11 +1,12 @@ use starknet_rs_accounts::{Account, ExecutionEncoding, SingleOwnerAccount}; -use starknet_rs_core::types::{BlockId, BlockTag, Call, Felt, StarknetError}; +use starknet_rs_core::types::{BlockId, BlockTag, Call, Felt, StarknetError, TransactionReceipt}; use starknet_rs_core::utils::get_selector_from_name; use starknet_rs_providers::{Provider, ProviderError}; use crate::common::background_devnet::BackgroundDevnet; use crate::common::constants::{self, STRK_ERC20_CONTRACT_ADDRESS}; use crate::common::utils::{assert_tx_succeeded_accepted, felt_to_u128}; +use crate::messaging::{DUMMY_L1_ADDRESS, increase_balance, setup_devnet}; /// Helper: build a simple transfer call fn transfer_call(recipient: Felt, amount: Felt) -> Call { @@ -55,11 +56,11 @@ async fn prove_transaction_endpoint_returns_proof_and_proof_facts() { .prepared() .unwrap(); - let invoke_for_prove = prepared.get_invoke_request(false, true).await.unwrap(); + let invoke_for_prove = prepared.get_invoke_request(false, false).await.unwrap(); let result = devnet.prove_transaction(invoke_for_prove).await; assert!(!result.proof_base64.is_empty(), "proof should be a non-empty base64 string"); - assert_eq!(result.proof_facts_hex.len(), 8, "proof_facts should have 8 elements"); + assert_eq!(result.proof_facts_hex.len(), 9, "proof_facts should have 9 elements"); } #[tokio::test] @@ -105,7 +106,7 @@ async fn invoke_with_valid_proof_is_accepted() { .prepared() .unwrap(); - let invoke_for_prove = prepared_for_prove.get_invoke_request(false, true).await.unwrap(); + let invoke_for_prove = prepared_for_prove.get_invoke_request(false, false).await.unwrap(); let prove_result = devnet.prove_transaction(invoke_for_prove).await; let proof = prove_result.proof_base64; let proof_facts = prove_result.proof_facts; @@ -194,7 +195,7 @@ async fn invoke_with_wrong_proof_is_rejected() { .prepared() .unwrap(); - let invoke_for_prove = prepared_for_prove.get_invoke_request(false, true).await.unwrap(); + let invoke_for_prove = prepared_for_prove.get_invoke_request(false, false).await.unwrap(); let prove_result = devnet.prove_transaction(invoke_for_prove).await; for _ in 0..11 { @@ -305,7 +306,7 @@ async fn invoke_with_proof_only_and_no_proof_facts_is_rejected() { .prepared() .unwrap(); - let invoke_for_prove = prepared_for_prove.get_invoke_request(false, true).await.unwrap(); + let invoke_for_prove = prepared_for_prove.get_invoke_request(false, false).await.unwrap(); let prove_result = devnet.prove_transaction(invoke_for_prove).await; for _ in 0..11 { @@ -385,7 +386,7 @@ async fn prove_transaction_is_deterministic() { .prepared() .unwrap(); - let invoke_for_prove = prepared.get_invoke_request(false, true).await.unwrap(); + let invoke_for_prove = prepared.get_invoke_request(false, false).await.unwrap(); let result1 = devnet.prove_transaction(invoke_for_prove.clone()).await; let result2 = devnet.prove_transaction(invoke_for_prove).await; @@ -397,7 +398,76 @@ async fn prove_transaction_is_deterministic() { } #[tokio::test] -async fn invoke_in_proof_mode_none_accepts_with_or_without_any_proofs() { +async fn prove_transaction_differs_on_different_block_ids() { + let devnet = BackgroundDevnet::spawn_forkable_devnet() + .await + .expect("Could not start Devnet with full state archive"); + + // First, create at least 15 blocks to ensure we have sufficient chain history + for _ in 0..15 { + devnet.create_block().await.unwrap(); + } + + let (signer, account_address) = devnet.get_first_predeployed_account().await; + let mut account = SingleOwnerAccount::new( + &devnet.json_rpc_client, + signer, + account_address, + constants::CHAIN_ID, + ExecutionEncoding::New, + ); + account.set_block_id(BlockId::Tag(BlockTag::Latest)); + + let calls = vec![transfer_call(Felt::ONE, Felt::from(1000u64))]; + let nonce = devnet + .json_rpc_client + .get_nonce(BlockId::Tag(BlockTag::Latest), account_address) + .await + .unwrap(); + + let block = devnet + .json_rpc_client + .get_block_with_tx_hashes(BlockId::Tag(BlockTag::Latest)) + .await + .unwrap(); + + let prepared = account + .execute_v3(calls) + .l1_gas(5_000_000) + .l1_data_gas(1_000_000) + .l2_gas(2_500_000_000) + .l1_gas_price(felt_to_u128(block.l1_gas_price().price_in_fri)) + .l1_data_gas_price(felt_to_u128(block.l1_data_gas_price().price_in_fri)) + .l2_gas_price(felt_to_u128(block.l2_gas_price().price_in_fri)) + .nonce(nonce) + .tip(0) + .prepared() + .unwrap(); + + let invoke_for_prove = prepared.get_invoke_request(false, false).await.unwrap(); + + // Prove at block 10 + let block_id_10 = BlockId::Number(10u64); + let result_at_block_10 = + devnet.prove_transaction_at_block(invoke_for_prove.clone(), block_id_10).await; + + // Prove the same transaction at block 15 (no blocks created between) + let block_id_15 = BlockId::Number(15u64); + let result_at_block_15 = devnet.prove_transaction_at_block(invoke_for_prove, block_id_15).await; + + // Proofs should differ because they include different block numbers and hashes + assert_ne!( + result_at_block_10.proof_base64, result_at_block_15.proof_base64, + "Proofs should differ for different block numbers (10 vs 15)" + ); + assert_ne!( + result_at_block_10.proof_facts_hex, result_at_block_15.proof_facts_hex, + "Proof facts should differ for different block numbers" + ); +} + +#[tokio::test] +async fn invoke_in_proof_mode_none_accepts_without_proof_or_with_wrong_proof() { let devnet_none = BackgroundDevnet::spawn_with_additional_args(&["--proof-mode", "none"]) .await .expect("Could not start Devnet in proof-mode none"); @@ -487,9 +557,24 @@ async fn invoke_in_proof_mode_none_accepts_with_or_without_any_proofs() { .tip(0) .prepared() .unwrap(); - let invoke_for_prove = prepared_for_prove.get_invoke_request(false, true).await.unwrap(); + let invoke_for_prove = prepared_for_prove.get_invoke_request(false, false).await.unwrap(); let prove_result = devnet_with_proofs.prove_transaction(invoke_for_prove).await; + // Ensure devnet_none has enough blocks for the block-hash retention buffer + for _ in 0..11 { + devnet_none.create_block().await.unwrap(); + } + + // Re-fetch gas prices after block creation (they may have changed) + let none_block = devnet_none + .json_rpc_client + .get_block_with_tx_hashes(BlockId::Tag(BlockTag::Latest)) + .await + .unwrap(); + let tx_l1_gas_price = felt_to_u128(none_block.l1_gas_price().price_in_fri); + let tx_l1_data_gas_price = felt_to_u128(none_block.l1_data_gas_price().price_in_fri); + let tx_l2_gas_price = felt_to_u128(none_block.l2_gas_price().price_in_fri); + let nonce_with_valid_proof = devnet_none .json_rpc_client .get_nonce(BlockId::Tag(BlockTag::Latest), none_account_address) @@ -499,6 +584,8 @@ async fn invoke_in_proof_mode_none_accepts_with_or_without_any_proofs() { .execute_v3(tx_calls.clone()) .nonce(nonce_with_valid_proof) .tip(0) + .proof(prove_result.proof_base64.clone()) + .proof_facts(prove_result.proof_facts.clone()) .estimate_fee() .await .unwrap(); @@ -529,15 +616,17 @@ async fn invoke_in_proof_mode_none_accepts_with_or_without_any_proofs() { .get_nonce(BlockId::Tag(BlockTag::Latest), none_account_address) .await .unwrap(); + let wrong_proof = + base64::Engine::encode(&base64::engine::general_purpose::STANDARD, vec![0u8; 8]); let fees_with_wrong_proof = none_account .execute_v3(tx_calls.clone()) .nonce(nonce_with_wrong_proof) .tip(0) + .proof(wrong_proof.clone()) + .proof_facts(prove_result.proof_facts.clone()) .estimate_fee() .await .unwrap(); - let wrong_proof = - base64::Engine::encode(&base64::engine::general_purpose::STANDARD, vec![0u8; 8]); let result_with_wrong_proof = none_account .execute_v3(tx_calls) .l1_gas(fees_with_wrong_proof.l1_gas_consumed) @@ -548,7 +637,7 @@ async fn invoke_in_proof_mode_none_accepts_with_or_without_any_proofs() { .l2_gas_price(tx_l2_gas_price) .nonce(nonce_with_wrong_proof) .proof(wrong_proof) - .proof_facts(vec![Felt::ONE; 8]) + .proof_facts(prove_result.proof_facts) .tip(0) .send() .await @@ -560,3 +649,322 @@ async fn invoke_in_proof_mode_none_accepts_with_or_without_any_proofs() { .await .unwrap(); } + +#[tokio::test] +async fn invoke_in_proof_mode_none_rejects_wrong_proof_facts() { + // In none mode, proof is ignored but proof_facts are preserved and validated. + // Sending invalid proof_facts should cause a transaction execution error. + let devnet_none = BackgroundDevnet::spawn_with_additional_args(&["--proof-mode", "none"]) + .await + .expect("Could not start Devnet in proof-mode none"); + + let (none_signer, none_account_address) = devnet_none.get_first_predeployed_account().await; + let mut none_account = SingleOwnerAccount::new( + &devnet_none.json_rpc_client, + none_signer, + none_account_address, + constants::CHAIN_ID, + ExecutionEncoding::New, + ); + none_account.set_block_id(BlockId::Tag(BlockTag::Latest)); + + // Need enough blocks for block-hash retention buffer + for _ in 0..11 { + devnet_none.create_block().await.unwrap(); + } + + let block = devnet_none + .json_rpc_client + .get_block_with_tx_hashes(BlockId::Tag(BlockTag::Latest)) + .await + .unwrap(); + + let tx_calls = vec![transfer_call(Felt::ONE, Felt::from(1000u64))]; + let nonce = devnet_none + .json_rpc_client + .get_nonce(BlockId::Tag(BlockTag::Latest), none_account_address) + .await + .unwrap(); + + let wrong_proof_facts = vec![Felt::ONE; 9]; + let error = none_account + .execute_v3(tx_calls) + .l1_gas(5_000_000) + .l1_data_gas(1_000_000) + .l2_gas(2_500_000_000) + .l1_gas_price(felt_to_u128(block.l1_gas_price().price_in_fri)) + .l1_data_gas_price(felt_to_u128(block.l1_data_gas_price().price_in_fri)) + .l2_gas_price(felt_to_u128(block.l2_gas_price().price_in_fri)) + .nonce(nonce) + .proof_facts(wrong_proof_facts) + .tip(0) + .send() + .await + .unwrap_err(); + + match error { + starknet_rs_accounts::AccountError::Provider(ProviderError::StarknetError( + StarknetError::ValidationFailure(msg), + )) => { + assert!( + msg.contains("ProofFacts parse error"), + "Expected ProofFacts parse error, got: {msg}" + ); + } + _ => panic!("Expected ValidationFailure for invalid proof_facts, got: {error:?}"), + } +} + +#[tokio::test] +async fn prove_transaction_returns_l2_to_l1_messages_for_withdraw() { + let (devnet, account, contract_address) = + setup_devnet(&["--account-class", "cairo1"]).await.unwrap(); + let account_address = account.address(); + + // increase_balance for user before withdraw + let user = Felt::ONE; + let amount = Felt::ONE; + increase_balance(account.clone(), contract_address, user, amount) + .await + .expect("increase_balance failed"); + + // Build a withdraw transaction (produces L2→L1 message) for prove_transaction + let withdraw_calls = vec![Call { + to: contract_address, + selector: get_selector_from_name("withdraw").unwrap(), + calldata: vec![user, amount, DUMMY_L1_ADDRESS], + }]; + + let nonce = devnet + .json_rpc_client + .get_nonce(BlockId::Tag(BlockTag::Latest), account_address) + .await + .unwrap(); + + let block = devnet + .json_rpc_client + .get_block_with_tx_hashes(BlockId::Tag(BlockTag::Latest)) + .await + .unwrap(); + + let prepared = account + .execute_v3(withdraw_calls.clone()) + .l1_gas(5_000_000) + .l1_data_gas(1_000_000) + .l2_gas(2_500_000_000) + .l1_gas_price(felt_to_u128(block.l1_gas_price().price_in_fri)) + .l1_data_gas_price(felt_to_u128(block.l1_data_gas_price().price_in_fri)) + .l2_gas_price(felt_to_u128(block.l2_gas_price().price_in_fri)) + .nonce(nonce) + .tip(0) + .prepared() + .unwrap(); + + let invoke_for_prove = prepared.get_invoke_request(false, false).await.unwrap(); + let result = devnet.prove_transaction(invoke_for_prove).await; + + // Proof should still be valid + assert!(!result.proof_base64.is_empty(), "proof should be non-empty"); + assert_eq!(result.proof_facts_hex.len(), 9, "proof_facts should have 9 elements"); + + // l2_to_l1_messages should contain the withdraw message + assert!( + !result.l2_to_l1_messages.is_empty(), + "l2_to_l1_messages should not be empty for a withdraw transaction" + ); + + let msg = &result.l2_to_l1_messages[0]; + assert_eq!(msg.from_address, contract_address, "message should be from the contract address"); + assert_eq!(msg.to_address, DUMMY_L1_ADDRESS, "message should be sent to the L1 address"); + assert!(!msg.payload.is_empty(), "message should have a payload"); + + // Execute the same transaction and ensure emitted message matches prove_transaction output. + let proof = result.proof_base64.clone(); + let proof_facts = result.proof_facts.clone(); + + for _ in 0..11 { + devnet.create_block().await.unwrap(); + } + + let latest_block = devnet + .json_rpc_client + .get_block_with_tx_hashes(BlockId::Tag(BlockTag::Latest)) + .await + .unwrap(); + let tx_l1_gas_price = felt_to_u128(latest_block.l1_gas_price().price_in_fri); + let tx_l1_data_gas_price = felt_to_u128(latest_block.l1_data_gas_price().price_in_fri); + let tx_l2_gas_price = felt_to_u128(latest_block.l2_gas_price().price_in_fri); + + let fees = account + .execute_v3(withdraw_calls.clone()) + .nonce(nonce) + .tip(0) + .proof(proof.clone()) + .proof_facts(proof_facts.clone()) + .estimate_fee() + .await + .unwrap(); + + let send_result = account + .execute_v3(withdraw_calls) + .l1_gas(fees.l1_gas_consumed) + .l1_data_gas(fees.l1_data_gas_consumed) + .l2_gas(fees.l2_gas_consumed) + .l1_gas_price(tx_l1_gas_price) + .l1_data_gas_price(tx_l1_data_gas_price) + .l2_gas_price(tx_l2_gas_price) + .nonce(nonce) + .proof(proof) + .proof_facts(proof_facts) + .tip(0) + .send() + .await + .unwrap(); + + assert_tx_succeeded_accepted(&send_result.transaction_hash, &devnet.json_rpc_client) + .await + .unwrap(); + + let receipt = devnet + .json_rpc_client + .get_transaction_receipt(send_result.transaction_hash) + .await + .unwrap() + .receipt; + + match receipt { + TransactionReceipt::Invoke(invoke_receipt) => { + assert_eq!(invoke_receipt.messages_sent.len(), result.l2_to_l1_messages.len()); + + let executed_msg = &invoke_receipt.messages_sent[0]; + assert_eq!(executed_msg.from_address, msg.from_address); + assert_eq!(executed_msg.to_address, msg.to_address); + assert_eq!(executed_msg.payload, msg.payload); + } + other => panic!("Expected invoke receipt, got: {other:?}"), + } +} + +#[tokio::test] +async fn prove_transaction_returns_empty_messages_for_simple_transfer() { + let devnet = BackgroundDevnet::spawn().await.expect("Could not start Devnet"); + let (signer, account_address) = devnet.get_first_predeployed_account().await; + let mut account = SingleOwnerAccount::new( + &devnet.json_rpc_client, + signer, + account_address, + constants::CHAIN_ID, + ExecutionEncoding::New, + ); + account.set_block_id(BlockId::Tag(BlockTag::Latest)); + + let calls = vec![transfer_call(Felt::ONE, Felt::from(1000u64))]; + let nonce = devnet + .json_rpc_client + .get_nonce(BlockId::Tag(BlockTag::Latest), account_address) + .await + .unwrap(); + + let block = devnet + .json_rpc_client + .get_block_with_tx_hashes(BlockId::Tag(BlockTag::Latest)) + .await + .unwrap(); + + let prepared = account + .execute_v3(calls) + .l1_gas(5_000_000) + .l1_data_gas(1_000_000) + .l2_gas(2_500_000_000) + .l1_gas_price(felt_to_u128(block.l1_gas_price().price_in_fri)) + .l1_data_gas_price(felt_to_u128(block.l1_data_gas_price().price_in_fri)) + .l2_gas_price(felt_to_u128(block.l2_gas_price().price_in_fri)) + .nonce(nonce) + .tip(0) + .prepared() + .unwrap(); + + let invoke_for_prove = prepared.get_invoke_request(false, false).await.unwrap(); + let result = devnet.prove_transaction(invoke_for_prove).await; + + assert!(!result.proof_base64.is_empty(), "proof should be non-empty"); + assert_eq!(result.proof_facts_hex.len(), 9, "proof_facts should have 9 elements"); + + // A simple transfer produces no L2→L1 messages + assert!( + result.l2_to_l1_messages.is_empty(), + "l2_to_l1_messages should be empty for a simple transfer, got: {:?}", + result.l2_to_l1_messages + ); +} + +#[tokio::test] +async fn prove_transaction_returns_error_on_execution_failure() { + let devnet = BackgroundDevnet::spawn().await.expect("Could not start Devnet"); + let (signer, account_address) = devnet.get_first_predeployed_account().await; + let mut account = SingleOwnerAccount::new( + &devnet.json_rpc_client, + signer, + account_address, + constants::CHAIN_ID, + ExecutionEncoding::New, + ); + account.set_block_id(BlockId::Tag(BlockTag::Latest)); + + // Call a non-existent contract to trigger execution failure + let calls = vec![Call { + to: Felt::from_hex_unchecked("0xdeadbeef"), + selector: get_selector_from_name("nonexistent_function").unwrap(), + calldata: vec![], + }]; + + let nonce = devnet + .json_rpc_client + .get_nonce(BlockId::Tag(BlockTag::Latest), account_address) + .await + .unwrap(); + + let block = devnet + .json_rpc_client + .get_block_with_tx_hashes(BlockId::Tag(BlockTag::Latest)) + .await + .unwrap(); + + let prepared = account + .execute_v3(calls) + .l1_gas(5_000_000) + .l1_data_gas(1_000_000) + .l2_gas(2_500_000_000) + .l1_gas_price(felt_to_u128(block.l1_gas_price().price_in_fri)) + .l1_data_gas_price(felt_to_u128(block.l1_data_gas_price().price_in_fri)) + .l2_gas_price(felt_to_u128(block.l2_gas_price().price_in_fri)) + .nonce(nonce) + .tip(0) + .prepared() + .unwrap(); + + let invoke_for_prove = prepared.get_invoke_request(false, false).await.unwrap(); + + let mut transaction = + serde_json::to_value(invoke_for_prove).expect("Failed to serialize transaction"); + if let Some(obj) = transaction.as_object_mut() { + obj.remove("proof"); + obj.remove("proof_facts"); + } + + let error = devnet + .send_custom_rpc( + "starknet_proveTransaction", + serde_json::json!({ + "block_id": "latest", + "transaction": transaction + }), + ) + .await + .expect_err("prove_transaction should fail for non-executable transaction"); + + assert!( + error.message.contains("Transaction execution failed"), + "Error should mention transaction execution failure, got: {error}" + ); +} diff --git a/website/docs/proofs.md b/website/docs/proofs.md index e6dfe6047..686d7eb67 100644 --- a/website/docs/proofs.md +++ b/website/docs/proofs.md @@ -18,7 +18,7 @@ Proof behavior is controlled by `--proof-mode` (or env var `PROOF_MODE`). | ------ | ------------------ | ------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------ | | Full | `full` | Not implemented yet | Rejects with unsupported action | | Devnet | `devnet` (default) | Returns a deterministic mock proof + proof facts | If both fields are present, verifies them; if one is missing or verification fails, rejects; if both are absent, accepts | -| None | `none` | Disabled / unsupported | Ignores incoming `proof` and `proof_facts` for invoke txs | +| None | `none` | Disabled / unsupported | Ignores incoming `proof` for invoke txs | ### Why this exists @@ -115,6 +115,14 @@ Example: "0x...", "0x...", "0x..." + ], + "l2_to_l1_messages": [ + { + "order": 0, + "from_address": "0x...", + "to_address": "0x...", + "payload": ["0x...", "0x..."] + } ] } } @@ -122,6 +130,8 @@ Example: `proof_facts` length is expected to be 8 in devnet mode. +`l2_to_l1_messages` contains L2→L1 messages extracted by simulating the transaction. If the simulation fails (e.g. insufficient balance), this array will be empty and the proof is still returned. + ## Mode-specific behavior details ### `devnet` mode (default) @@ -135,7 +145,7 @@ Example: ### `none` mode -- Proof fields on invoke transactions are ignored. +- Proof field on invoke transactions is ignored; `proof_facts` are checked. ### `full` mode