Skip to content

Correct v1.6 sender encoding in CCIPLocalSimulatorFork - #65

Draft
Nalon wants to merge 2 commits into
developfrom
fix/packed-message
Draft

Nalon wants to merge 2 commits into
developfrom
fix/packed-message

Conversation

@Nalon

@Nalon Nalon commented Sep 17, 2026

Copy link
Copy Markdown
Collaborator

Summary

CCIPLocalSimulatorFork._executePostV1dot6 built Internal.Any2EVMRampMessage.sender with abi.encodePacked(message.sender), producing 20 bytes. Production v1.6 lanes with an EVM source chain deliver it as a 32-byte ABI word.

Both documented ways of consuming Client.Any2EVMMessage.sender broke, and they broke differently:

  • abi.decode(message.sender, (address)) reverts, so _ccipReceive reverts and the message is never delivered.
  • keccak256(message.sender) == keccak256(abi.encode(trustedRemote)) returns false, so the receiver runs without error and silently takes its untrusted-sender branch — the quieter and more misleading failure.

This completes the 20-vs-32-byte corrections shipped in 0.2.9 for receiver, destTokenAddress and sourcePoolAddress. sender was the fourth address-shaped field in the same conversion block, and the only one whose symptom surfaces in application code rather than as a revert inside CCIP — which is why it was missed.

Root cause

The source and destination structs are mirror images:

struct EVM2AnyRampMessage { address sender;  bytes   receiver; ... }  // what the OnRamp emits
struct Any2EVMRampMessage { bytes   sender;  address receiver; ... }  // what the OffRamp executes

So the conversion must encode one field and decode the other, inverted, in the same struct literal. The decode direction was already correct via _decodeEVMAddress; the encode direction used the wrong primitive.

Pre-v1.6 lanes were never exposed: EVM2EVMMessage.sender is a typed address, so the destination OffRamp performs the encoding, not the simulator. The simulator only inherited this responsibility at v1.6, when CCIP moved sender encoding offchain.

The fix

One line in src/ccip/CCIPLocalSimulatorFork.sol:

-    sender: abi.encodePacked(message.sender),
+    sender: abi.encode(message.sender),

Consistent with production: Chainlink's own onchain conversion in OnRampOverSuperchainInterop.sol uses abi.encode(message.sender); Client.Any2EVMMessage.sender is documented "abi.decode(sender) if coming from an EVM chain"; and the 2.0 MessageV1 codec likewise documents sender as "abi encoded for EVM chains" while receiver is raw 20 bytes. The convention is stable across versions.

Verification

Unit regression in test/unit/ccip/CCIPLocalSimulatorForkRouting.t.sol drives _executePostV1dot6 against a recording OffRamp mock and pins both consumption patterns:

assertEq(recordedSender.length, 32);
assertEq(abi.decode(recordedSender, (address)), sender);
assertEq(keccak256(recordedSender), keccak256(abi.encode(sender)));

Non-vacuous: fails 20 != 32 with the fix reverted. Full non-fork suite green — 57 passed, 0 failed.

Additionally verified against live CCIP contracts during review, not committed:

  • v1.6 lane (Eth Sepolia → Base Sepolia, OnRamp 1.6.0) — a CCIPReceiver calling abi.decode(message.sender, (address)) fails 0x0…0 != <sender> without the fix and passes with it. A second receiver doing the raw trusted-remote comparison showed received() == true / senderIsTrusted() == false without the fix, confirming the silent-untrusted-branch failure mode rather than a revert.
  • Token handlingCCIPv1_5LockReleasePoolFork and CCIPv1_5ForkBurnMintPoolFork run on that same v1.6 lane and pass with and without the fix. No regression; they are non-discriminating for this bug because they transfer to an EOA and never decode sender.
  • Pre-v1.6 path (Eth Sepolia → Avalanche Fuji, EVM2EVMOnRamp 1.5.0) — passes with and without the fix at identical gas, i.e. byte-for-byte identical execution, confirming _executePreV1dot6 never reaches the changed line.

Changelog

Added under ## [Unreleased]### Fixed. The heading should be converted to the target version with a date at release time.

Context

Fixes #62

Thank you to @Yurii3721 for raising the issue and to @kurogami-ibrahim79 for their proposed solution in #63.

fix: Use abi.encode for v1.6 message sender in CCIPLocalSimulatorFork

`_executePostV1dot6` built `Internal.Any2EVMRampMessage.sender` with
`abi.encodePacked(address)` (20 bytes). Production v1.6 lanes with an EVM
source chain deliver it as `abi.encode(address)` (32-byte word), which is
what `Client.Any2EVMMessage.sender` documents ("abi.decode(sender) if
coming from an EVM chain") and what the OffRamp passes straight through to
the receiver.

The 20-byte form broke receivers two ways during fork testing: an
`abi.decode(message.sender, (address))` reverted, and a raw-bytes
comparison against `abi.encode(trustedRemote)` fell through to the
untrusted-sender branch.

This completes the 20-vs-32-byte encoding corrections made in 0.2.9 for
`receiver` and `destTokenAddress`, and restores parity with local mode,
where `MockCCIPRouter` already uses `abi.encode(msg.sender)`.

Adds a unit regression test that drives `_executePostV1dot6` against a
recording OffRamp mock and asserts the delivered `sender` is 32 bytes and
decodes back to the original address. Verified non-vacuous: it fails with
`20 != 32` without the fix.

Closes #62
Receivers consume `Client.Any2EVMMessage.sender` in two ways, and they fail
differently when it arrives as 20 packed bytes instead of a 32-byte ABI word:

- `abi.decode(sender, (address))` reverts, so the message is never delivered.
- `keccak256(sender) == keccak256(abi.encode(trustedRemote))` returns false,
  so the receiver runs without error and silently takes its untrusted-sender
  branch

The existing assertions covered only the first. This pins the raw-bytes
comparison as well, so both documented consumption patterns are checked
against the bytes `_executePostV1dot6` actually produces.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant