From e76d8ef9685c81d9edd75ca0d12c707af9a37f38 Mon Sep 17 00:00:00 2001 From: faisal-link Date: Thu, 5 Nov 2026 19:31:53 +0400 Subject: [PATCH 1/2] add a balance check post TP call in token transfer flow --- contracts/offramp/src/lib.rs | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/contracts/offramp/src/lib.rs b/contracts/offramp/src/lib.rs index d383d42a..26600763 100644 --- a/contracts/offramp/src/lib.rs +++ b/contracts/offramp/src/lib.rs @@ -1324,6 +1324,11 @@ impl OffRampContract { // transaction (message stays `Untouched`, retryable by resubmission) — // outcome-equivalent retryability, at the cost of no on-chain FAILURE // record/event for pool errors. + // + // Verify actual delivery: snapshot the receiver balance before calling the + // pool so a malicious/faulty pool cannot claim success without delivering. + let pre_balance = + soroban_sdk::token::Client::new(env, &dest_token).balance(&receiver_address); let release_result = pool_client.release_or_mint( &env.current_contract_address(), &ReleaseOrMintIn { @@ -1338,10 +1343,23 @@ impl OffRampContract { &requested_finality, ); + // Post-call: measure the delivered amount and require it matches the + // pool-reported amount. A mismatch traps, rolling back the whole + // transaction (message stays `Untouched`, retryable by resubmission). + let post_balance = + soroban_sdk::token::Client::new(env, &dest_token).balance(&receiver_address); + if post_balance < pre_balance { + env.panic_with_error(CCIPError::TokenHandlingError); + } + let delivered = post_balance - pre_balance; + if delivered != release_result.destination_amount { + env.panic_with_error(CCIPError::TokenHandlingError); + } + let mut amounts = Vec::new(env); amounts.push_back(TokenAmount { token: dest_token, - amount: release_result.destination_amount, + amount: delivered, }); Ok(amounts) } From 30d653d0f3e6ed950ba1ecc01b89cab0e47e5625 Mon Sep 17 00:00:00 2001 From: faisal-link Date: Thu, 5 Nov 2026 19:32:17 +0400 Subject: [PATCH 2/2] add corresponding test for balance check in tt flow --- contracts/offramp/src/test.rs | 165 +++++++++++++++++++++++++++++++--- 1 file changed, 154 insertions(+), 11 deletions(-) diff --git a/contracts/offramp/src/test.rs b/contracts/offramp/src/test.rs index 0b1b7d37..2ec5bbbd 100644 --- a/contracts/offramp/src/test.rs +++ b/contracts/offramp/src/test.rs @@ -347,6 +347,18 @@ fn offramp_address_field_from_contract(env: &Env, contract: &Address) -> Bytes { xdr.slice(start..xdr.len()) } +/// Register a Stellar Asset Contract whose admin is the mock pool, returning the token +/// address plus its 32-byte wire-format `dest_token_address`. The pool mints the released +/// amount to the receiver in `release_or_mint`, which the offramp's post-release +/// balance-delta check (NONEVM-6645) requires — a fake token address would trap the check. +fn setup_dest_token(env: &Env, pool: &Address) -> (Address, Bytes) { + let token = env + .register_stellar_asset_contract_v2(pool.clone()) + .address(); + let wire = offramp_address_field_from_contract(env, &token); + (token, wire) +} + fn sample_onramp_bytes(env: &Env) -> Bytes { Bytes::from_array(env, &[1u8; 32]) } @@ -736,7 +748,9 @@ impl MockTokenAdminRegistry { } /// Mock TokenPool: records the receiver passed to `release_or_mint` so the test can assert -/// the H-2 fallback resolved an empty `token_receiver` to `message.receiver`. +/// the H-2 fallback resolved an empty `token_receiver` to `message.receiver`. It also MINTS +/// the released amount to the receiver (the pool is the SAC admin via `setup_dest_token`), +/// so the offramp's post-release balance-delta check (NONEVM-6645) observes the delivery. #[contract] pub struct MockTokenPool; @@ -806,6 +820,15 @@ impl MockTokenPool { .set(&Symbol::new(&env, "traprel"), &trap); } + /// Deliver `amount - skim` tokens while REPORTING the full `amount` — the lever for the + /// pool-delivery-verification regression test (NONEVM-6645): a malicious/faulty pool + /// claiming success without actually delivering must trap the whole execute tx. + pub fn set_skim_release(env: Env, skim: i128) { + env.storage() + .instance() + .set(&Symbol::new(&env, "skimrel"), &skim); + } + pub fn release_or_mint( env: Env, _caller: Address, @@ -820,6 +843,20 @@ impl MockTokenPool { if trap { env.panic_with_error(CCIPError::TokenHandlingError); } + // Actually deliver the tokens (mint/burn-pool semantics: the mock pool is the + // SAC admin in these tests) so the offramp's post-release balance-delta check + // (NONEVM-6645) observes the delivery. A nonzero `skimrel` under-delivers while + // still reporting the full amount — the check must trap on the mismatch. + let skim: i128 = env + .storage() + .instance() + .get(&Symbol::new(&env, "skimrel")) + .unwrap_or(0); + let deliver = input.amount - skim; + if deliver > 0 { + soroban_sdk::token::StellarAssetClient::new(&env, &input.local_token) + .mint(&input.receiver, &deliver); + } // Count invocations so the L-10 regression tests can assert exactly one // COMMITTED release across a failure+retry cycle (a release inside a tx that // traps is rolled back together with the rest of that tx's writes). @@ -986,6 +1023,10 @@ fn test_execute_empty_token_receiver_falls_back_to_message_receiver() { let receiver_contract = env.register(MockVerifier, ()); let receiver_field = offramp_address_field_from_contract(&env, &receiver_contract); + // Real SAC token administered by the mock pool, so the offramp's post-release + // balance-delta check observes the pool's mint to the receiver (NONEVM-6645). + let dest_token_wire = setup_dest_token(&env, &pool_id).1; + // Token transfer with an EMPTY `token_receiver` (the H-2 condition). let mut amount_bytes = [0u8; 32]; amount_bytes[31] = 100; @@ -994,7 +1035,7 @@ fn test_execute_empty_token_receiver_falls_back_to_message_receiver() { amount: BytesN::from_array(&env, &amount_bytes), source_pool_address: Bytes::from_array(&env, &[0x11u8; 20]), source_token_address: Bytes::from_array(&env, &[0x22u8; 20]), - dest_token_address: Bytes::from_array(&env, &[0xBBu8; 32]), + dest_token_address: dest_token_wire, token_receiver: Bytes::new(&env), extra_data: Bytes::new(&env), }; @@ -1082,6 +1123,9 @@ fn test_execute_token_only_zero_message_receiver_skips_receiver_call_with_receiv // Token-only transfer to a separate token receiver (a throwaway // contract); the MESSAGE receiver is all zeros (the tcapi token-only // convention). + // Real SAC token administered by the mock pool (NONEVM-6645 delivery check). + let dest_token_wire = setup_dest_token(&env, &pool_id).1; + let token_receiver_contract = env.register(MockVerifier, ()); let token_receiver = offramp_address_field_from_contract(&env, &token_receiver_contract); let mut amount_bytes = [0u8; 32]; @@ -1091,7 +1135,7 @@ fn test_execute_token_only_zero_message_receiver_skips_receiver_call_with_receiv amount: BytesN::from_array(&env, &amount_bytes), source_pool_address: Bytes::from_array(&env, &[0x11u8; 20]), source_token_address: Bytes::from_array(&env, &[0x22u8; 20]), - dest_token_address: Bytes::from_array(&env, &[0xBBu8; 32]), + dest_token_address: dest_token_wire, token_receiver: token_receiver.clone(), extra_data: Bytes::new(&env), }; @@ -1352,6 +1396,9 @@ fn test_execute_token_message_receiver_failure_traps_and_rolls_back_release() { // committed tx, so it survives the whole-tx rollback of the trapping execute below. receiver_client.set_should_fail(&true); + // Real SAC token administered by the mock pool (NONEVM-6645 delivery check). + let dest_token_wire = setup_dest_token(&env, &pool_client.address).1; + let mut amount_bytes = [0u8; 32]; amount_bytes[31] = 100; let token_transfer = CcipTokenTransferV1 { @@ -1359,7 +1406,7 @@ fn test_execute_token_message_receiver_failure_traps_and_rolls_back_release() { amount: BytesN::from_array(&env, &amount_bytes), source_pool_address: Bytes::from_array(&env, &[0x11u8; 20]), source_token_address: Bytes::from_array(&env, &[0x22u8; 20]), - dest_token_address: Bytes::from_array(&env, &[0xBBu8; 32]), + dest_token_address: dest_token_wire, token_receiver: Bytes::new(&env), // empty ⇒ fallback to message.receiver extra_data: Bytes::new(&env), }; @@ -1490,6 +1537,9 @@ fn test_execute_pool_release_trap_reverts_whole_tx() { pool_client.set_trap_release(&true); + // Real SAC token administered by the mock pool (NONEVM-6645 delivery check). + let dest_token_wire = setup_dest_token(&env, &pool_client.address).1; + let mut amount_bytes = [0u8; 32]; amount_bytes[31] = 100; let token_transfer = CcipTokenTransferV1 { @@ -1497,7 +1547,7 @@ fn test_execute_pool_release_trap_reverts_whole_tx() { amount: BytesN::from_array(&env, &amount_bytes), source_pool_address: Bytes::from_array(&env, &[0x11u8; 20]), source_token_address: Bytes::from_array(&env, &[0x22u8; 20]), - dest_token_address: Bytes::from_array(&env, &[0xBBu8; 32]), + dest_token_address: dest_token_wire, token_receiver: Bytes::new(&env), // empty ⇒ fallback to message.receiver extra_data: Bytes::new(&env), }; @@ -1546,6 +1596,81 @@ fn test_execute_pool_release_trap_reverts_whole_tx() { assert_eq!(receiver_client.receive_count(), 1); } +/// NONEVM-6645 / pool-delivery verification: a pool that reports full delivery but +/// actually moves FEWER tokens must NOT finalize `Success` — the offramp snapshots the +/// receiver's balance around `release_or_mint` and traps on mismatch, reverting the whole +/// execute tx (message stays `Untouched`, retriable). Resubmitting after the pool is +/// fixed delivers exactly once. +#[test] +fn test_execute_pool_underdelivery_traps_and_is_retriable() { + let (env, client, receiver_client, pool_client, vvr_id, receiver_contract) = setup_l10_lane(); + + // The pool claims the full amount but mints 1 unit short. + pool_client.set_skim_release(&1); + + let (dest_token, dest_token_wire) = setup_dest_token(&env, &pool_client.address); + + let mut amount_bytes = [0u8; 32]; + amount_bytes[31] = 100; + let token_transfer = CcipTokenTransferV1 { + version: MESSAGE_V1_VERSION, + amount: BytesN::from_array(&env, &amount_bytes), + source_pool_address: Bytes::from_array(&env, &[0x11u8; 20]), + source_token_address: Bytes::from_array(&env, &[0x22u8; 20]), + dest_token_address: dest_token_wire, + token_receiver: Bytes::new(&env), // empty ⇒ fallback to message.receiver + extra_data: Bytes::new(&env), + }; + + let msg = l10_message( + &env, + &client.address, + &receiver_contract, + token_transfer.to_bytes(&env).unwrap(), + Bytes::from_array(&env, b"payload"), + ); + let encoded = msg.to_bytes(&env).unwrap(); + let message_id = CcipMessageV1::compute_message_id_from_bytes(&env, &encoded); + + let ccvs = vec![&env, vvr_id.clone()]; + let verifier_results = vec![&env, Bytes::new(&env)]; + + // Delivered (99) != reported (100) ⇒ the whole tx traps; nothing commits. + let res = client.try_execute(&encoded, &ccvs, &verifier_results, &0u32); + assert!( + res.is_err(), + "a pool that under-delivers must trap the whole execute tx (no false Success): {:?}", + res + ); + assert_eq!( + client.get_execution_state(&message_id), + MessageExecutionState::Untouched, + "no Success without actual token delivery" + ); + assert_eq!(pool_client.release_count(), 0); + assert_eq!(receiver_client.receive_count(), 0); + + // Fix the pool (separate committed tx) and resubmit: full delivery, exactly once. + pool_client.set_skim_release(&0); + let res = client.try_execute(&encoded, &ccvs, &verifier_results, &0u32); + assert!( + res.is_ok(), + "resubmission after the pool is fixed must succeed: {:?}", + res + ); + assert_eq!( + client.get_execution_state(&message_id), + MessageExecutionState::Success + ); + assert_eq!(pool_client.release_count(), 1); + assert_eq!(receiver_client.receive_count(), 1); + assert_eq!( + soroban_sdk::token::Client::new(&env, &dest_token).balance(&receiver_contract), + 100, + "the receiver's balance delta equals the reported destination amount" + ); +} + // ============================================================ // try_* conversion: typed callee errors record per-message Failure // (EVM `OffRamp.execute`'s `_callWithGasBuffer` catch-all parity) @@ -1562,6 +1687,9 @@ fn test_execute_pool_required_ccvs_typed_error_records_failure() { pool_client.set_fail_required_ccvs(&true); + // Real SAC token administered by the mock pool (NONEVM-6645 delivery check). + let dest_token_wire = setup_dest_token(&env, &pool_client.address).1; + let mut amount_bytes = [0u8; 32]; amount_bytes[31] = 100; let token_transfer = CcipTokenTransferV1 { @@ -1569,7 +1697,7 @@ fn test_execute_pool_required_ccvs_typed_error_records_failure() { amount: BytesN::from_array(&env, &amount_bytes), source_pool_address: Bytes::from_array(&env, &[0x11u8; 20]), source_token_address: Bytes::from_array(&env, &[0x22u8; 20]), - dest_token_address: Bytes::from_array(&env, &[0xBBu8; 32]), + dest_token_address: dest_token_wire, token_receiver: Bytes::new(&env), // empty ⇒ fallback to message.receiver extra_data: Bytes::new(&env), }; @@ -1650,6 +1778,9 @@ fn test_execute_resolver_typed_error_records_failure() { MockVvrClient::new(&env, &vvr_id).set_fail_inbound(&true); + // Real SAC token administered by the mock pool (NONEVM-6645 delivery check). + let dest_token_wire = setup_dest_token(&env, &pool_client.address).1; + let mut amount_bytes = [0u8; 32]; amount_bytes[31] = 100; let token_transfer = CcipTokenTransferV1 { @@ -1657,7 +1788,7 @@ fn test_execute_resolver_typed_error_records_failure() { amount: BytesN::from_array(&env, &amount_bytes), source_pool_address: Bytes::from_array(&env, &[0x11u8; 20]), source_token_address: Bytes::from_array(&env, &[0x22u8; 20]), - dest_token_address: Bytes::from_array(&env, &[0xBBu8; 32]), + dest_token_address: dest_token_wire, token_receiver: Bytes::new(&env), extra_data: Bytes::new(&env), }; @@ -1732,6 +1863,9 @@ fn test_execute_token_only_skips_default_ccv_floor() { // The pool mandates its own CCV and does NOT fold lane defaults. pool_client.set_required_ccvs(&vec![&env, pool_vvr_id.clone()], &false); + // Real SAC token administered by the mock pool (NONEVM-6645 delivery check). + let dest_token_wire = setup_dest_token(&env, &pool_id).1; + let static_config = StaticConfig { chain_selector: EXEC_TEST_DEST_CHAIN, rmn_proxy: rmn_proxy_id, @@ -1765,7 +1899,7 @@ fn test_execute_token_only_skips_default_ccv_floor() { amount: BytesN::from_array(&env, &amount_bytes), source_pool_address: Bytes::from_array(&env, &[0x11u8; 20]), source_token_address: Bytes::from_array(&env, &[0x22u8; 20]), - dest_token_address: Bytes::from_array(&env, &[0xBBu8; 32]), + dest_token_address: dest_token_wire, token_receiver: receiver_field.clone(), extra_data: Bytes::new(&env), }; @@ -1840,6 +1974,9 @@ fn test_execute_token_only_still_requires_pool_ccvs() { pool_client.set_required_ccvs(&vec![&env, pool_vvr_id.clone()], &false); + // Real SAC token administered by the mock pool (NONEVM-6645 delivery check). + let dest_token_wire = setup_dest_token(&env, &pool_id).1; + let static_config = StaticConfig { chain_selector: EXEC_TEST_DEST_CHAIN, rmn_proxy: rmn_proxy_id, @@ -1871,7 +2008,7 @@ fn test_execute_token_only_still_requires_pool_ccvs() { amount: BytesN::from_array(&env, &amount_bytes), source_pool_address: Bytes::from_array(&env, &[0x11u8; 20]), source_token_address: Bytes::from_array(&env, &[0x22u8; 20]), - dest_token_address: Bytes::from_array(&env, &[0xBBu8; 32]), + dest_token_address: dest_token_wire, token_receiver: receiver_field.clone(), extra_data: Bytes::new(&env), }; @@ -1954,6 +2091,9 @@ fn test_execute_token_only_empty_pool_falls_back_to_defaults() { // into the lane defaults instead. pool_client.set_required_ccvs(&Vec::new(&env), &false); + // Real SAC token administered by the mock pool (NONEVM-6645 delivery check). + let dest_token_wire = setup_dest_token(&env, &pool_id).1; + let static_config = StaticConfig { chain_selector: EXEC_TEST_DEST_CHAIN, rmn_proxy: rmn_proxy_id, @@ -1985,7 +2125,7 @@ fn test_execute_token_only_empty_pool_falls_back_to_defaults() { amount: BytesN::from_array(&env, &amount_bytes), source_pool_address: Bytes::from_array(&env, &[0x11u8; 20]), source_token_address: Bytes::from_array(&env, &[0x22u8; 20]), - dest_token_address: Bytes::from_array(&env, &[0xBBu8; 32]), + dest_token_address: dest_token_wire, token_receiver: receiver_field.clone(), extra_data: Bytes::new(&env), }; @@ -2139,6 +2279,9 @@ fn test_execute_drops_reverting_extra_ccv() { // The pool mandates its own CCV and does NOT fold in lane defaults. pool_client.set_required_ccvs(&vec![&env, pool_vvr_id.clone()], &false); + // Real SAC token administered by the mock pool (NONEVM-6645 delivery check). + let dest_token_wire = setup_dest_token(&env, &pool_id).1; + let static_config = StaticConfig { chain_selector: EXEC_TEST_DEST_CHAIN, rmn_proxy: rmn_proxy_id, @@ -2176,7 +2319,7 @@ fn test_execute_drops_reverting_extra_ccv() { amount: BytesN::from_array(&env, &amount_bytes), source_pool_address: Bytes::from_array(&env, &[0x11u8; 20]), source_token_address: Bytes::from_array(&env, &[0x22u8; 20]), - dest_token_address: Bytes::from_array(&env, &[0xBBu8; 32]), + dest_token_address: dest_token_wire, token_receiver: receiver_field.clone(), extra_data: Bytes::new(&env), };