@@ -29,6 +29,11 @@ import {EscrowBase} from "../src/EscrowBase.sol";
2929// - Obfuscated fund() selector 0x49364cd4 is present in deployed dispatcher
3030// - Original selector 0xa65e2cfd is absent (obfuscation replaced all selectors)
3131//
32+ // Cross-TX verification (successful vs failed):
33+ // WORKING TX 0x8bff4e21...: CREATE → 0x7720..., approve → 0x7720..., fund → 0x7720... (all same)
34+ // FAILING TX 0xedf03465...: CREATE → 0x7e97..., approve → 0xD69B..., fund → 0xD69B... (mismatch)
35+ // When the batch targets the correct address, it works. When wrong, silent no-op.
36+ //
3237// Hypotheses tested:
3338// H1 – Bytecode dispatch dead end → RULED OUT
3439// H2 – msg.sender context in batch → PLAUSIBLE (general risk)
@@ -130,8 +135,7 @@ contract EscrowFactory {
130135 // Constructor auto-funds when both amounts > 0.
131136 // transferFrom inside fund() pulls from msg.sender of fund(), which is
132137 // address(this) during constructor execution.
133- EscrowERC20 escrow =
134- new EscrowERC20 (tokenContract, recipient, expectedAmount, rewardAmount, paymentAmount);
138+ EscrowERC20 escrow = new EscrowERC20 (tokenContract, recipient, expectedAmount, rewardAmount, paymentAmount);
135139 return address (escrow);
136140 }
137141
@@ -201,6 +205,15 @@ contract BatchFundTest is Test {
201205 uint256 constant PAYMENT = 500e18 ;
202206 uint256 constant EXPECTED_AMOUNT = 1000e18 ;
203207
208+ // Real values from the failing Tempo TX:
209+ // 0xedf034653df8ebd016a471bb4c19a1a71b01b39694720ffdde148790b4ac94ae
210+ uint256 constant TEMPO_AMOUNT = 100000000 ;
211+ uint256 constant TEMPO_REWARD = 723471 ;
212+ address constant TEMPO_TOKEN = 0x20C0000000000000000000000000000000000000 ;
213+ address constant TEMPO_ESCROW = 0x7e9798a62b42D97fb05b9e092a9A2117FA3fB995 ;
214+ address constant TEMPO_WRONG_TARGET = 0xD69B8fC5D21819A713fDE3e051C97e1Cb09BD2Aa ;
215+ address constant TEMPO_SENDER = 0xA79045285379f02ad505D7338523843D3A73BBaD ;
216+
204217 function setUp () public {
205218 deployer = makeAddr ("deployer " );
206219 executor = makeAddr ("executor " );
@@ -328,8 +341,7 @@ contract BatchFundTest is Test {
328341 vm.prank (address (factory));
329342 token.approve (futureEscrow, REWARD + PAYMENT);
330343
331- address escrowAddr =
332- factory.deployUnfundedThenFund (address (token), recipient, EXPECTED_AMOUNT, REWARD, PAYMENT);
344+ address escrowAddr = factory.deployUnfundedThenFund (address (token), recipient, EXPECTED_AMOUNT, REWARD, PAYMENT);
333345
334346 EscrowERC20 escrow = EscrowERC20 (escrowAddr);
335347 assertTrue (escrow.funded (), "should be funded after factory deploy-then-fund " );
@@ -342,8 +354,7 @@ contract BatchFundTest is Test {
342354 EscrowFactory factory = new EscrowFactory ();
343355
344356 // Deploy unfunded (0,0) via factory
345- address escrowAddr =
346- factory.deployAndAutoFund (address (token), recipient, EXPECTED_AMOUNT, 0 , 0 );
357+ address escrowAddr = factory.deployAndAutoFund (address (token), recipient, EXPECTED_AMOUNT, 0 , 0 );
347358
348359 EscrowERC20 escrow = EscrowERC20 (escrowAddr);
349360 assertFalse (escrow.funded (), "should start unfunded " );
@@ -371,9 +382,7 @@ contract BatchFundTest is Test {
371382
372383 LenientBatcher.Call[] memory calls = new LenientBatcher.Call [](1 );
373384 calls[0 ] = LenientBatcher.Call ({
374- target: address (escrow),
375- data: abi.encodeWithSelector (EscrowERC20.fund.selector , REWARD, PAYMENT),
376- value: 0
385+ target: address (escrow), data: abi.encodeWithSelector (EscrowERC20.fund.selector , REWARD, PAYMENT), value: 0
377386 });
378387
379388 // Batcher swallows the revert — does not propagate
@@ -398,9 +407,7 @@ contract BatchFundTest is Test {
398407
399408 LenientBatcher.Call[] memory calls = new LenientBatcher.Call [](1 );
400409 calls[0 ] = LenientBatcher.Call ({
401- target: address (escrow),
402- data: abi.encodeWithSelector (EscrowERC20.fund.selector , REWARD, PAYMENT),
403- value: 0
410+ target: address (escrow), data: abi.encodeWithSelector (EscrowERC20.fund.selector , REWARD, PAYMENT), value: 0
404411 });
405412
406413 // Batcher is msg.sender, not deployer → OnlyDeployer revert → swallowed
@@ -427,9 +434,7 @@ contract BatchFundTest is Test {
427434 // Call 1: fund() — this would succeed if msg.sender matches
428435 // But msg.sender here is the batcher, not deployer → reverts with OnlyDeployer
429436 calls[0 ] = StrictBatcher.Call ({
430- target: address (escrow),
431- data: abi.encodeWithSelector (EscrowERC20.fund.selector , REWARD, PAYMENT),
432- value: 0
437+ target: address (escrow), data: abi.encodeWithSelector (EscrowERC20.fund.selector , REWARD, PAYMENT), value: 0
433438 });
434439 // Call 2: some other call that reverts
435440 calls[1 ] = StrictBatcher.Call ({target: address (0 ), data: hex "" , value: 0 });
@@ -454,9 +459,7 @@ contract BatchFundTest is Test {
454459
455460 StrictBatcher.Call[] memory calls = new StrictBatcher.Call [](1 );
456461 calls[0 ] = StrictBatcher.Call ({
457- target: address (escrow),
458- data: abi.encodeWithSelector (EscrowERC20.fund.selector , REWARD, PAYMENT),
459- value: 0
462+ target: address (escrow), data: abi.encodeWithSelector (EscrowERC20.fund.selector , REWARD, PAYMENT), value: 0
460463 });
461464
462465 // deployer calls batcher, batcher calls escrow.fund()
@@ -672,9 +675,7 @@ contract BatchFundTest is Test {
672675
673676 StrictBatcher.Call[] memory calls = new StrictBatcher.Call [](1 );
674677 calls[0 ] = StrictBatcher.Call ({
675- target: address (escrow),
676- data: abi.encodeWithSelector (EscrowERC20.fund.selector , REWARD, PAYMENT),
677- value: 0
678+ target: address (escrow), data: abi.encodeWithSelector (EscrowERC20.fund.selector , REWARD, PAYMENT), value: 0
678679 });
679680
680681 // Even though deployer is tx.origin, msg.sender in fund() is batcher
@@ -700,9 +701,7 @@ contract BatchFundTest is Test {
700701
701702 LenientBatcher.Call[] memory calls = new LenientBatcher.Call [](1 );
702703 calls[0 ] = LenientBatcher.Call ({
703- target: address (escrow),
704- data: abi.encodeWithSelector (EscrowERC20.fund.selector , REWARD, PAYMENT),
705- value: 0
704+ target: address (escrow), data: abi.encodeWithSelector (EscrowERC20.fund.selector , REWARD, PAYMENT), value: 0
706705 });
707706
708707 // Deployer sends batch → batcher calls fund() → OnlyDeployer revert → SWALLOWED
@@ -721,23 +720,21 @@ contract BatchFundTest is Test {
721720 /// Simulate the exact Tempo token address (0x20c0...0000) as a precompile
722721 /// that has no code → external call returns empty data → ABI decode failure.
723722 function testTempoTokenAddressNoCode () public {
724- address tempoToken = address (0x20C0000000000000000000000000000000000000 );
725- // Don't etch any code → address has no code
723+ // Don't etch any code → TEMPO_TOKEN has no code in test env
726724
727725 vm.startPrank (deployer);
728726 vm.expectRevert (); // call to address with no code → empty returndata → ABI decode fail
729- new EscrowERC20 (tempoToken , recipient, EXPECTED_AMOUNT, REWARD, PAYMENT );
727+ new EscrowERC20 (TEMPO_TOKEN , recipient, TEMPO_AMOUNT, TEMPO_REWARD, TEMPO_AMOUNT );
730728 vm.stopPrank ();
731729 }
732730
733731 /// Simulate Tempo token with NoOp behavior etched at the real address.
734732 function testTempoTokenAsNoOp () public {
735- address tempoToken = address (0x20C0000000000000000000000000000000000000 );
736733 NoOpToken noOp = new NoOpToken ();
737- vm.etch (tempoToken , address (noOp).code);
734+ vm.etch (TEMPO_TOKEN , address (noOp).code);
738735
739736 vm.startPrank (deployer);
740- EscrowERC20 escrow = new EscrowERC20 (tempoToken , recipient, EXPECTED_AMOUNT, REWARD, PAYMENT );
737+ EscrowERC20 escrow = new EscrowERC20 (TEMPO_TOKEN , recipient, TEMPO_AMOUNT, TEMPO_REWARD, TEMPO_AMOUNT );
741738 vm.stopPrank ();
742739
743740 // fund() "succeeded" — constructor auto-funded
@@ -758,111 +755,111 @@ contract BatchFundTest is Test {
758755
759756 /// EVM CALL to an address with no code succeeds with empty returndata.
760757 /// This is the fundamental EVM behavior that enables the silent failure.
758+ /// Uses the real wrong target from the failing TX.
761759 function testCallToEmptyAddressSucceeds () public {
762- address empty = makeAddr ("empty " );
763- assertEq (empty.code.length , 0 , "should have no code " );
760+ assertEq (TEMPO_WRONG_TARGET.code.length , 0 , "wrong target should have no code " );
764761
765- // Any calldata, any selector — returns success with empty data
766- bytes memory fundCalldata = abi.encodeWithSelector (0x49364cd4 , uint256 ( 723471 ), uint256 ( 100000000 ) );
767- (bool ok , bytes memory ret ) = empty .call (fundCalldata);
762+ // Exact calldata from the failing TX: obfuscated fund(723471, 100000000)
763+ bytes memory fundCalldata = abi.encodeWithSelector (0x49364cd4 , TEMPO_REWARD, TEMPO_AMOUNT );
764+ (bool ok , bytes memory ret ) = TEMPO_WRONG_TARGET .call (fundCalldata);
768765
769766 assertTrue (ok, "call to empty address should succeed " );
770767 assertEq (ret.length , 0 , "return data should be empty " );
771768 }
772769
773- /// Reproduce the exact Tempo batch failure:
774- /// 1. Deploy escrow at address A
775- /// 2. Approve tokens to address B (wrong, no code)
776- /// 3. Call fund() on address B (wrong, no code) → silent success
777- /// 4. Escrow at A remains unfunded
770+ /// Reproduce the exact Tempo batch failure with real on-chain values:
771+ /// TX: 0xedf034653df8ebd016a471bb4c19a1a71b01b39694720ffdde148790b4ac94ae
772+ /// Token: 0x20C0000000000000000000000000000000000000 (PathUSD)
773+ /// Escrow: 0x7e9798a62b42d97fb05b9e092a9a2117fa3fb995 (deployed correctly)
774+ /// Wrong: 0xd69b8fc5d21819a713fde3e051c97e1cb09bd2aa (no code, batch target)
775+ /// Amount: 100000000, Reward: 723471
776+ ///
777+ /// Steps in the batch:
778+ /// 1. CREATE escrow → lands at 0x7e97... (correct)
779+ /// 2. approve(0xd69b..., amount) on PathUSD → succeeds (wrong spender)
780+ /// 3. fund(723471, 100000000) on 0xd69b... → silent no-op (no code)
781+ /// 4. Escrow at 0x7e97... remains unfunded
778782 function testExactTempoFailure_WrongAddressBatch () public {
779- address tempoToken = address ( 0x20C0000000000000000000000000000000000000 );
783+ // Etch a NoOp token at the real PathUSD address
780784 NoOpToken noOp = new NoOpToken ();
781- vm.etch (tempoToken , address (noOp).code);
785+ vm.etch (TEMPO_TOKEN , address (noOp).code);
782786
783787 // Step 1: Deploy escrow (unfunded — constructor gets 0,0)
784788 vm.startPrank (deployer);
785- EscrowERC20 escrow = new EscrowERC20 (tempoToken , recipient, EXPECTED_AMOUNT , 0 , 0 );
789+ EscrowERC20 escrow = new EscrowERC20 (TEMPO_TOKEN , recipient, TEMPO_AMOUNT , 0 , 0 );
786790 vm.stopPrank ();
787791
788- address wrongTarget = makeAddr ("wrongTarget " ); // simulates 0xd69b...
789-
790792 assertFalse (escrow.funded (), "escrow should start unfunded " );
791- assertEq (wrongTarget .code.length , 0 , "wrong target has no code " );
793+ assertEq (TEMPO_WRONG_TARGET .code.length , 0 , "0xd69b... has no code on-chain " );
792794
793- // Step 2: Approve tokens to WRONG address (not the escrow)
795+ // Step 2: Approve tokens to WRONG address (0xd69b... instead of escrow)
794796 vm.prank (deployer);
795- (bool ok1 ,) = tempoToken .call (
796- abi.encodeWithSignature ("approve(address,uint256) " , wrongTarget, REWARD + PAYMENT )
797+ (bool ok1 ,) = TEMPO_TOKEN .call (
798+ abi.encodeWithSignature ("approve(address,uint256) " , TEMPO_WRONG_TARGET, TEMPO_REWARD + TEMPO_AMOUNT )
797799 );
798800 assertTrue (ok1, "approve should succeed " );
799801
800- // Step 3: Call fund() on WRONG address (no code → silent success)
801- bytes memory fundCall = abi.encodeWithSelector (0x49364cd4 , REWARD, PAYMENT );
802+ // Step 3: Call obfuscated fund() on WRONG address (no code → silent success)
803+ bytes memory fundCall = abi.encodeWithSelector (0x49364cd4 , TEMPO_REWARD, TEMPO_AMOUNT );
802804 vm.prank (deployer);
803- (bool ok2 , bytes memory ret ) = wrongTarget .call (fundCall);
805+ (bool ok2 , bytes memory ret ) = TEMPO_WRONG_TARGET .call (fundCall);
804806
805- assertTrue (ok2, "call to empty address succeeds (THIS IS THE BUG) " );
807+ assertTrue (ok2, "call to 0xd69b... succeeds (THIS IS THE BUG) " );
806808 assertEq (ret.length , 0 , "empty return - no fund() logic executed " );
807809
808810 // Step 4: Escrow is STILL unfunded — the batch "succeeded" but did nothing
809- assertFalse (escrow.funded (), "CONFIRMED: escrow still unfunded after batch " );
810-
811- // Meanwhile, the correct call WOULD have worked (if sent to escrow):
812- // The escrow has the fund() code, it would execute the transfer logic.
813- // But the batch never called it.
811+ assertFalse (escrow.funded (), "CONFIRMED: escrow still unfunded after batch to wrong address " );
814812 }
815813
816814 /// Prove the obfuscated fund() works when called at the correct address.
817- /// Uses the actual escrow's runtime bytecode etched locally .
815+ /// Uses the real Tempo token and amounts .
818816 function testObfuscatedFundWorksAtCorrectAddress () public {
819- address tempoToken = address (0x20C0000000000000000000000000000000000000 );
820817 NoOpToken noOp = new NoOpToken ();
821- vm.etch (tempoToken , address (noOp).code);
818+ vm.etch (TEMPO_TOKEN , address (noOp).code);
822819
823820 // Deploy a real escrow (unfunded)
824821 vm.prank (deployer);
825- EscrowERC20 escrow = new EscrowERC20 (tempoToken , recipient, EXPECTED_AMOUNT , 0 , 0 );
822+ EscrowERC20 escrow = new EscrowERC20 (TEMPO_TOKEN , recipient, TEMPO_AMOUNT , 0 , 0 );
826823
827824 // Call fund() with the ORIGINAL selector on the unobfuscated contract
828825 vm.prank (deployer);
829- escrow.fund (REWARD, PAYMENT );
826+ escrow.fund (TEMPO_REWARD, TEMPO_AMOUNT );
830827
831- // fund() works on the real escrow
832- assertTrue (escrow.funded (), "fund() works when called at correct address " );
828+ // fund() works when called at the correct address
829+ assertTrue (escrow.funded (), "fund() works at correct address with real amounts " );
830+ assertEq (escrow.currentRewardAmount (), TEMPO_REWARD);
831+ assertEq (escrow.currentPaymentAmount (), TEMPO_AMOUNT);
833832 }
834833
835- /// Demonstrate that address mismatch in a strict batch causes silent
836- /// funding failure when the wrong address has no code .
834+ /// Demonstrate the exact failure in a strict batch context:
835+ /// approve + fund both target TEMPO_WRONG_TARGET instead of the escrow .
837836 function testAddressMismatchInStrictBatch () public {
838837 vm.startPrank (deployer);
839- EscrowERC20 escrow = new EscrowERC20 (address (token), recipient, EXPECTED_AMOUNT , 0 , 0 );
838+ EscrowERC20 escrow = new EscrowERC20 (address (token), recipient, TEMPO_AMOUNT , 0 , 0 );
840839 vm.stopPrank ();
841840
842- address wrongAddr = makeAddr ("computedWrongAddress " );
843-
844841 StrictBatcher batcher = new StrictBatcher ();
845842 StrictBatcher.Call[] memory calls = new StrictBatcher.Call [](2 );
846843
847- // Approve to wrong address
844+ // Approve to wrong address (0xd69b...)
848845 calls[0 ] = StrictBatcher.Call ({
849846 target: address (token),
850- data: abi.encodeWithSignature ("approve(address,uint256) " , wrongAddr, REWARD + PAYMENT ),
847+ data: abi.encodeWithSignature ("approve(address,uint256) " , TEMPO_WRONG_TARGET, TEMPO_REWARD + TEMPO_AMOUNT ),
851848 value: 0
852849 });
853850
854851 // fund() on wrong address (no code → returns success)
855852 calls[1 ] = StrictBatcher.Call ({
856- target: wrongAddr ,
857- data: abi.encodeWithSelector (EscrowERC20.fund.selector , REWARD, PAYMENT ),
853+ target: TEMPO_WRONG_TARGET ,
854+ data: abi.encodeWithSelector (EscrowERC20.fund.selector , TEMPO_REWARD, TEMPO_AMOUNT ),
858855 value: 0
859856 });
860857
861- // The batch SUCCEEDS because both calls return success
858+ // The batch SUCCEEDS because both calls return success (empty address)
862859 vm.prank (deployer);
863860 batcher.execute (calls);
864861
865862 // But the escrow is not funded
866- assertFalse (escrow.funded (), "escrow unfunded - batch hit wrong address " );
863+ assertFalse (escrow.funded (), "escrow unfunded - batch hit 0xd69b... instead of escrow " );
867864 }
868865}
0 commit comments