diff --git a/CHANGELOG.md b/CHANGELOG.md index e5421b2..5421f51 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,48 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.2.9] - 19 May 2026 + +### Dependencies + +| Package | Version | +| ------------------------- | ------- | +| @chainlink/contracts-ccip | 1.6.2 | +| @chainlink/contracts | 1.5.0 | + +### Fixed + +- `CCIPLocalSimulatorFork.switchChainAndRouteMessage` now pairs the destination OffRamp with the source OnRamp by reading `getSourceChainConfig` (v1.6+) and `getStaticConfig` (pre-v1.6), and falls back to trying other OffRamps with the same `sourceChainSelector` when execution fails or no deterministic match is found. +- `CCIPLocalSimulatorFork.switchChainAndRouteMessage` now correctly decodes `destTokenAddress` for v1.6 token transfers. The 32-byte `abi.encode(address)` value was previously truncated via a `bytes20` cast, yielding a garbage address and causing the destination OffRamp's `TokenAdminRegistry.getPool` lookup to revert. Decoding now handles both 32-byte ABI-encoded and 20-byte packed forms, matching production OffRamp behavior, and is shared with `receiver` decoding via a single internal `_decodeEVMAddress` helper. +- For v1.6 token transfers, `sourcePoolAddress` is now passed to `Internal.Any2EVMTokenTransfer` as `abi.encode(address)` (32-byte word), matching production OnRamp output and destination pool validation. The simulator previously used `abi.encodePacked(address)` (20 bytes), which caused compatible pools to revert with `InvalidSourcePoolAddress` during fork testing. + +## [0.2.9-beta.0] - 7 May 2026 + +### Dependencies + +| Package | Version | +| ------------------------- | ------- | +| @chainlink/contracts-ccip | 1.6.2 | +| @chainlink/contracts | 1.5.0 | + +### Fixed + +- `CCIPLocalSimulatorFork.switchChainAndRouteMessage` now correctly decodes `destTokenAddress` for v1.6 token transfers. The 32-byte `abi.encode(address)` value was previously truncated via a `bytes20` cast, yielding a garbage address and causing the destination OffRamp's `TokenAdminRegistry.getPool` lookup to revert. Decoding now handles both 32-byte ABI-encoded and 20-byte packed forms, matching production OffRamp behavior, and is shared with `receiver` decoding via a single internal `_decodeEVMAddress` helper. +- For v1.6 token transfers, `sourcePoolAddress` is now passed to `Internal.Any2EVMTokenTransfer` as `abi.encode(address)` (32-byte word), matching production OnRamp output and destination pool validation. The simulator previously used `abi.encodePacked(address)` (20 bytes), which caused compatible pools to revert with `InvalidSourcePoolAddress` during fork testing. + +## [0.2.9-beta] - 6 May 2026 + +### Dependencies + +| Package | Version | +| ------------------------- | ------- | +| @chainlink/contracts-ccip | 1.6.2 | +| @chainlink/contracts | 1.5.0 | + +### Fixed + +- `CCIPLocalSimulatorFork.switchChainAndRouteMessage` now pairs the destination OffRamp with the source OnRamp by reading `getSourceChainConfig` (v1.6+) and `getStaticConfig` (pre-v1.6), and falls back to trying other OffRamps with the same `sourceChainSelector` when execution fails or no deterministic match is found. + ## [0.2.8] - 5 May 2026 ### Dependencies @@ -637,3 +679,6 @@ and this project adheres to [0.2.7]: https://github.com/smartcontractkit/chainlink-local/releases/tag/v0.2.7 [0.2.8-beta]: https://github.com/smartcontractkit/chainlink-local/releases/tag/v0.2.8-beta [0.2.8]: https://github.com/smartcontractkit/chainlink-local/releases/tag/v0.2.8 +[0.2.9-beta]: https://github.com/smartcontractkit/chainlink-local/releases/tag/v0.2.9-beta +[0.2.9-beta.0]: https://github.com/smartcontractkit/chainlink-local/releases/tag/v0.2.9-beta.0 +[0.2.9]: https://github.com/smartcontractkit/chainlink-local/releases/tag/v0.2.9 diff --git a/package-lock.json b/package-lock.json index 326b4c5..22f874c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@chainlink/local", - "version": "0.2.8", + "version": "0.2.9", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "@chainlink/local", - "version": "0.2.8", + "version": "0.2.9", "license": "MIT", "dependencies": { "@chainlink/contracts": "1.5.0", @@ -20767,4 +20767,4 @@ "dev": true } } -} \ No newline at end of file +} diff --git a/package.json b/package.json index e23a963..4e3277c 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "@chainlink/local", "description": "Chainlink Local Simulator", "license": "MIT", - "version": "0.2.8", + "version": "0.2.9", "files": [ "src/**/*.sol", "!src/test/**/*", diff --git a/src/ccip/CCIPLocalSimulatorFork.sol b/src/ccip/CCIPLocalSimulatorFork.sol index 1df3e1b..5717ee4 100644 --- a/src/ccip/CCIPLocalSimulatorFork.sol +++ b/src/ccip/CCIPLocalSimulatorFork.sol @@ -76,6 +76,37 @@ interface IEVM2EVMOffRampPreV1dot6Fork { ) external; } +/// @title IEVM2EVMOffRampStaticConfigFork +/// @notice Minimal view surface for pre-v1.6 OffRamp static config (EVM2EVMOffRamp 1.5.x). +interface IEVM2EVMOffRampStaticConfigFork { + struct StaticConfig { + address commitStore; + uint64 chainSelector; + uint64 sourceChainSelector; + address onRamp; + address prevOffRamp; + address rmnProxy; + address tokenAdminRegistry; + } + + function getStaticConfig() external view returns (StaticConfig memory); +} + +/// @title IOffRampSourceConfigFork +/// @notice Minimal view surface for v1.6+ OffRamp per-source-chain config (OffRamp 1.6.x). +/// @dev `router` is ABI-compatible with `IRouter` in CCIP OffRamp `SourceChainConfig`. +interface IOffRampSourceConfigFork { + struct SourceChainConfig { + address router; + bool isEnabled; + uint64 minSeqNr; + bool isRMNVerificationDisabled; + bytes onRamp; + } + + function getSourceChainConfig(uint64 sourceChainSelector) external view returns (SourceChainConfig memory); +} + /// @title CCIPLocalSimulatorFork /// @notice Works with Foundry only contract CCIPLocalSimulatorFork is Test { @@ -89,6 +120,8 @@ contract CCIPLocalSimulatorFork is Test { error InvalidExtraArgsTag(); + error InvalidEVMAddressEncoding(bytes encodedAddress); + uint32 public constant DEFAULT_GAS_LIMIT = 200_000; /// @notice The immutable register instance @@ -228,28 +261,29 @@ contract CCIPLocalSimulatorFork is Test { IRouterFork(i_register.getNetworkDetails(block.chainid).routerAddress).getOffRamps(); uint256 offRampsLength = offRamps.length; - for (uint256 k = offRampsLength; k > 0; --k) { - if (offRamps[k - 1].sourceChainSelector == message.sourceChainSelector) { - vm.startPrank(offRamps[k - 1].offRamp); - uint256 numberOfTokens = message.tokenAmounts.length; - bytes[] memory offchainTokenData = new bytes[](numberOfTokens); - uint32[] memory tokenGasOverrides = new uint32[](numberOfTokens); - for (uint256 l; l < numberOfTokens; ++l) { - tokenGasOverrides[l] = uint32(message.gasLimit); + address matchedOffRamp = + _findOffRampForOnRamp(offRamps, message.sourceChainSelector, onRampContract); + bool routed = false; + if (matchedOffRamp != address(0)) { + routed = _executePreV1dot6(matchedOffRamp, message); + } + if (!routed) { + for (uint256 k = offRampsLength; k > 0; --k) { + if (offRamps[k - 1].sourceChainSelector != message.sourceChainSelector) { + continue; + } + if (matchedOffRamp != address(0) && offRamps[k - 1].offRamp == matchedOffRamp) { + continue; } - try IEVM2EVMOffRampPreV1dot6Fork(offRamps[k - 1].offRamp).executeSingleMessage( - message, offchainTokenData, tokenGasOverrides - ) { - vm.stopPrank(); - s_processedMessages[message.messageId] = true; - } catch (bytes memory err) { - vm.stopPrank(); - // Solidity does not support yet catching custom errors, so this is the best we can do for now - console2.logBytes(err); + if (_executePreV1dot6(offRamps[k - 1].offRamp, message)) { + routed = true; + break; } - break; } } + if (routed) { + s_processedMessages[message.messageId] = true; + } } } } @@ -260,8 +294,6 @@ contract CCIPLocalSimulatorFork is Test { Internal.EVM2AnyRampMessage memory message = abi.decode(entries[i].data, (Internal.EVM2AnyRampMessage)); if (!s_processedMessages[message.header.messageId]) { - s_processedMessages[message.header.messageId] = true; - for (uint256 j; j < forkIds.length; ++j) { vm.selectFork(forkIds[j]); uint64 destinationChainSelector = i_register.getNetworkDetails(block.chainid).chainSelector; @@ -277,49 +309,29 @@ contract CCIPLocalSimulatorFork is Test { IRouterFork(i_register.getNetworkDetails(block.chainid).routerAddress).getOffRamps(); uint256 offRampsLength = offRamps.length; - for (uint256 k = offRampsLength; k > 0; --k) { - if (offRamps[k - 1].sourceChainSelector == message.header.sourceChainSelector) { - vm.startPrank(offRamps[k - 1].offRamp); - uint256 gasLimit = _fromBytes(message.extraArgs).gasLimit; - uint256 numberOfTokens = message.tokenAmounts.length; - Internal.Any2EVMTokenTransfer[] memory tokenAmounts = - new Internal.Any2EVMTokenTransfer[](numberOfTokens); - for (uint256 l; l < numberOfTokens; ++l) { - tokenAmounts[l] = Internal.Any2EVMTokenTransfer({ - sourcePoolAddress: abi.encodePacked(message.tokenAmounts[l].sourcePoolAddress), - destTokenAddress: address( - uint160(bytes20(message.tokenAmounts[l].destTokenAddress)) - ), - destGasAmount: abi.decode(message.tokenAmounts[l].destExecData, (uint32)), - extraData: message.tokenAmounts[l].extraData, - amount: message.tokenAmounts[l].amount - }); + address matchedOffRamp = + _findOffRampForOnRamp(offRamps, message.header.sourceChainSelector, onRampContract); + bool routed = false; + if (matchedOffRamp != address(0)) { + routed = _executePostV1dot6(matchedOffRamp, message); + } + if (!routed) { + for (uint256 k = offRampsLength; k > 0; --k) { + if (offRamps[k - 1].sourceChainSelector != message.header.sourceChainSelector) { + continue; } - Internal.Any2EVMRampMessage memory any2EVMRampMessage = Internal.Any2EVMRampMessage({ - header: message.header, - sender: abi.encodePacked(message.sender), - data: message.data, - receiver: address(uint160(bytes20(message.receiver))), - gasLimit: gasLimit, - tokenAmounts: tokenAmounts - }); - bytes[] memory offchainTokenData = new bytes[](numberOfTokens); - uint32[] memory tokenGasOverrides = new uint32[](numberOfTokens); - for (uint256 l; l < numberOfTokens; ++l) { - tokenGasOverrides[l] = uint32(gasLimit); + if (matchedOffRamp != address(0) && offRamps[k - 1].offRamp == matchedOffRamp) { + continue; } - try IEVM2EVMOffRampFork(offRamps[k - 1].offRamp).executeSingleMessage( - any2EVMRampMessage, offchainTokenData, tokenGasOverrides - ) { - vm.stopPrank(); - } catch (bytes memory err) { - vm.stopPrank(); - // Solidity does not support yet catching custom errors, so this is the best we can do for now - console2.logBytes(err); + if (_executePostV1dot6(offRamps[k - 1].offRamp, message)) { + routed = true; + break; } - break; } } + if (routed) { + s_processedMessages[message.header.messageId] = true; + } } } } @@ -327,6 +339,121 @@ contract CCIPLocalSimulatorFork is Test { } } + /// @notice Returns the destination OffRamp whose lane is configured for `sourceOnRamp`, if discoverable. + function _findOffRampForOnRamp( + IRouterFork.OffRamp[] memory offRamps, + uint64 sourceChainSelector, + address sourceOnRamp + ) internal view returns (address offRampAddress) { + uint256 length = offRamps.length; + for (uint256 i = length; i > 0; --i) { + address candidateAddr = offRamps[i - 1].offRamp; + if (offRamps[i - 1].sourceChainSelector != sourceChainSelector) { + continue; + } + + try IOffRampSourceConfigFork(candidateAddr).getSourceChainConfig(sourceChainSelector) returns ( + IOffRampSourceConfigFork.SourceChainConfig memory cfg + ) { + if (cfg.isEnabled && cfg.onRamp.length == 32) { + address configuredOnRamp = abi.decode(cfg.onRamp, (address)); + if (configuredOnRamp == sourceOnRamp) { + return candidateAddr; + } + } + } catch {} + + try IEVM2EVMOffRampStaticConfigFork(candidateAddr).getStaticConfig() returns ( + IEVM2EVMOffRampStaticConfigFork.StaticConfig memory staticCfg + ) { + if (staticCfg.onRamp == sourceOnRamp && staticCfg.sourceChainSelector == sourceChainSelector) { + return candidateAddr; + } + } catch {} + } + + return address(0); + } + + function _executePreV1dot6(address offRamp, InternalPreV1dot6.EVM2EVMMessage memory message) + internal + returns (bool success) + { + uint256 numberOfTokens = message.tokenAmounts.length; + bytes[] memory offchainTokenData = new bytes[](numberOfTokens); + uint32[] memory tokenGasOverrides = new uint32[](numberOfTokens); + for (uint256 l; l < numberOfTokens; ++l) { + tokenGasOverrides[l] = uint32(message.gasLimit); + } + + vm.startPrank(offRamp); + try IEVM2EVMOffRampPreV1dot6Fork(offRamp).executeSingleMessage(message, offchainTokenData, tokenGasOverrides) { + vm.stopPrank(); + return true; + } catch (bytes memory err) { + vm.stopPrank(); + console2.logBytes(err); + return false; + } + } + + function _executePostV1dot6(address offRamp, Internal.EVM2AnyRampMessage memory message) + internal + returns (bool success) + { + uint256 gasLimit = _fromBytes(message.extraArgs).gasLimit; + uint256 numberOfTokens = message.tokenAmounts.length; + Internal.Any2EVMTokenTransfer[] memory tokenAmounts = new Internal.Any2EVMTokenTransfer[](numberOfTokens); + for (uint256 l; l < numberOfTokens; ++l) { + tokenAmounts[l] = Internal.Any2EVMTokenTransfer({ + sourcePoolAddress: abi.encode(message.tokenAmounts[l].sourcePoolAddress), + destTokenAddress: _decodeEVMAddress(message.tokenAmounts[l].destTokenAddress), + destGasAmount: abi.decode(message.tokenAmounts[l].destExecData, (uint32)), + extraData: message.tokenAmounts[l].extraData, + amount: message.tokenAmounts[l].amount + }); + } + Internal.Any2EVMRampMessage memory any2EVMRampMessage = Internal.Any2EVMRampMessage({ + header: message.header, + sender: abi.encodePacked(message.sender), + data: message.data, + receiver: _decodeEVMAddress(message.receiver), + gasLimit: gasLimit, + tokenAmounts: tokenAmounts + }); + bytes[] memory offchainTokenData = new bytes[](numberOfTokens); + uint32[] memory tokenGasOverrides = new uint32[](numberOfTokens); + for (uint256 l; l < numberOfTokens; ++l) { + tokenGasOverrides[l] = uint32(gasLimit); + } + + vm.startPrank(offRamp); + try IEVM2EVMOffRampFork(offRamp).executeSingleMessage(any2EVMRampMessage, offchainTokenData, tokenGasOverrides) + { + vm.stopPrank(); + return true; + } catch (bytes memory err) { + vm.stopPrank(); + console2.logBytes(err); + return false; + } + } + + /** + * @notice Decodes ABI-encoded EVM address bytes to an `address`. + * @dev Used for `Client.EVM2AnyMessage.receiver` and `Internal.EVM2AnyTokenTransfer.destTokenAddress`. + * CCIP uses `abi.encode(address)` (32 bytes). A legacy 20-byte packed encoding is also supported. + */ + function _decodeEVMAddress(bytes memory encodedAddress) internal pure returns (address) { + if (encodedAddress.length == 32) { + return abi.decode(encodedAddress, (address)); + } + if (encodedAddress.length == 20) { + return address(uint160(bytes20(encodedAddress))); + } + revert InvalidEVMAddressEncoding(encodedAddress); + } + /** * @notice Internal helper function to decode extraArgs bytes to GenericExtraArgsV2 struct. * Supports decoding of both GenericExtraArgsV2 and EVMExtraArgsV1 structs. diff --git a/test/e2e/ccip/EthSepoliaToArbSepoliaMultiOffRampFork.t.sol b/test/e2e/ccip/EthSepoliaToArbSepoliaMultiOffRampFork.t.sol new file mode 100644 index 0000000..f38960b --- /dev/null +++ b/test/e2e/ccip/EthSepoliaToArbSepoliaMultiOffRampFork.t.sol @@ -0,0 +1,134 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.19; + +import {Test} from "forge-std/Test.sol"; +import {Client} from "@chainlink/contracts-ccip/contracts/libraries/Client.sol"; +import {IRouterClient} from "@chainlink/contracts-ccip/contracts/interfaces/IRouterClient.sol"; +import {CCIPReceiver} from "@chainlink/contracts-ccip/contracts/applications/CCIPReceiver.sol"; +import {IERC20} from "../../../src/vendor/openzeppelin-solidity/v4.8.3/contracts/token/ERC20/IERC20.sol"; +import {BurnMintERC677Helper} from "@chainlink/local/src/ccip/CCIPLocalSimulator.sol"; +import {CCIPLocalSimulatorFork, IRouterFork} from "../../../src/ccip/CCIPLocalSimulatorFork.sol"; +import {Register} from "../../../src/ccip/Register.sol"; + +/// @dev Minimal receiver so the only “application” code is CCIP wiring; the test sends via the Router directly. +contract MinimalStringReceiver is CCIPReceiver { + string public lastPayload; + + constructor(address router) CCIPReceiver(router) {} + + function _ccipReceive(Client.Any2EVMMessage memory message) internal override { + lastPayload = abi.decode(message.data, (string)); + } +} + +/// @title Eth Sepolia → Arbitrum Sepolia (fork): multi-OffRamp routing smoke test +/// @notice Production Arb Sepolia routers often register several OffRamps for the same `sourceChainSelector` +/// (Ethereum Sepolia) across upgrades. This test requires that layout and sends a single CCIP message +/// by calling `IRouterClient.ccipSend` on the source router from the test contract, then routes via +/// `CCIPLocalSimulatorFork.switchChainAndRouteMessage`. +contract EthSepoliaToArbSepoliaMultiOffRampForkTest is Test { + /// @dev CCIP chain selector for Ethereum Sepolia (lane source). + uint64 internal constant ETH_SEPOLIA_CHAIN_SELECTOR = 16015286601757825753; + + CCIPLocalSimulatorFork internal ccipFork; + uint256 internal sepoliaFork; + uint256 internal arbSepoliaFork; + + Register.NetworkDetails internal sepoliaDetails; + Register.NetworkDetails internal arbDetails; + + IRouterClient internal sepoliaRouter; + MinimalStringReceiver internal receiver; + + function setUp() public { + string memory ethSepoliaRpc = vm.envString("ETHEREUM_SEPOLIA_RPC_URL"); + string memory arbSepoliaRpc = vm.envString("ARBITRUM_SEPOLIA_RPC_URL"); + + sepoliaFork = vm.createSelectFork(ethSepoliaRpc); + arbSepoliaFork = vm.createFork(arbSepoliaRpc); + + ccipFork = new CCIPLocalSimulatorFork(); + vm.makePersistent(address(ccipFork)); + + sepoliaDetails = ccipFork.getNetworkDetails(block.chainid); + sepoliaRouter = IRouterClient(sepoliaDetails.routerAddress); + + vm.selectFork(arbSepoliaFork); + arbDetails = ccipFork.getNetworkDetails(block.chainid); + + IRouterFork.OffRamp[] memory offRamps = IRouterFork(arbDetails.routerAddress).getOffRamps(); + uint256 matching; + for (uint256 i; i < offRamps.length; ++i) { + if (offRamps[i].sourceChainSelector == ETH_SEPOLIA_CHAIN_SELECTOR) { + ++matching; + } + } + assertGe( + matching, + 2, + "Arb Sepolia router must list 2+ OffRamps for Ethereum Sepolia (multi-OffRamp lane); CCIP config may have changed" + ); + + receiver = new MinimalStringReceiver(arbDetails.routerAddress); + + vm.selectFork(sepoliaFork); + ccipFork.requestLinkFromFaucet(address(this), 5 ether); + } + + function test_routerDirectSend_routesWhenMultipleOffRampsShareSourceSelector() public { + string memory payload = "fork-multi-offramp-smoke"; + + Client.EVM2AnyMessage memory message = Client.EVM2AnyMessage({ + receiver: abi.encode(address(receiver)), + data: abi.encode(payload), + tokenAmounts: new Client.EVMTokenAmount[](0), + extraArgs: Client._argsToBytes(Client.EVMExtraArgsV1({gasLimit: 300_000})), + feeToken: sepoliaDetails.linkAddress + }); + + vm.selectFork(sepoliaFork); + + uint256 fee = sepoliaRouter.getFee(arbDetails.chainSelector, message); + IERC20(sepoliaDetails.linkAddress).approve(address(sepoliaRouter), fee); + + sepoliaRouter.ccipSend(arbDetails.chainSelector, message); + + ccipFork.switchChainAndRouteMessage(arbSepoliaFork); + + vm.selectFork(arbSepoliaFork); + assertEq(receiver.lastPayload(), payload); + } + + /// @notice Regression: v1.6 token transfers must decode `destTokenAddress` as ABI-encoded address (32 bytes), not via `bytes20` truncation + function test_transferBnMFromRouter_payFeesInNative_multiOffRampLane() public { + address recipientAddr = makeAddr("recipient"); + + vm.selectFork(sepoliaFork); + + uint256 amountToSend = 100; + BurnMintERC677Helper ccipBnMSource = BurnMintERC677Helper(sepoliaDetails.ccipBnMAddress); + ccipBnMSource.drip(address(this)); + IERC20(sepoliaDetails.ccipBnMAddress).approve(address(sepoliaRouter), amountToSend); + + Client.EVMTokenAmount[] memory tokensToSendDetails = new Client.EVMTokenAmount[](1); + tokensToSendDetails[0] = + Client.EVMTokenAmount({token: sepoliaDetails.ccipBnMAddress, amount: amountToSend}); + + Client.EVM2AnyMessage memory message = Client.EVM2AnyMessage({ + receiver: abi.encode(recipientAddr), + data: abi.encode(""), + tokenAmounts: tokensToSendDetails, + extraArgs: Client._argsToBytes(Client.EVMExtraArgsV1({gasLimit: 0})), + feeToken: address(0) + }); + + uint256 fees = sepoliaRouter.getFee(arbDetails.chainSelector, message); + vm.deal(address(this), fees + 1); + sepoliaRouter.ccipSend{value: fees}(arbDetails.chainSelector, message); + + ccipFork.switchChainAndRouteMessage(arbSepoliaFork); + + vm.selectFork(arbSepoliaFork); + assertEq(IERC20(arbDetails.ccipBnMAddress).balanceOf(recipientAddr), amountToSend); + } +} diff --git a/test/unit/ccip/CCIPLocalSimulatorForkRouting.t.sol b/test/unit/ccip/CCIPLocalSimulatorForkRouting.t.sol new file mode 100644 index 0000000..3c1e432 --- /dev/null +++ b/test/unit/ccip/CCIPLocalSimulatorForkRouting.t.sol @@ -0,0 +1,191 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.19; + +import {Test} from "forge-std/Test.sol"; +import { + CCIPLocalSimulatorFork, + IRouterFork, + IOffRampSourceConfigFork, + IEVM2EVMOffRampStaticConfigFork +} from "../../../src/ccip/CCIPLocalSimulatorFork.sol"; + +/// @dev Exposes internal OffRamp resolution for unit testing. +contract CCIPLocalSimulatorForkHarness is CCIPLocalSimulatorFork { + function exposedDecodeReceiver(bytes memory encoded) external pure returns (address) { + return _decodeEVMAddress(encoded); + } + + function exposedFindOffRamp( + IRouterFork.OffRamp[] calldata offRamps, + uint64 sourceChainSelector, + address sourceOnRamp + ) external view returns (address) { + IRouterFork.OffRamp[] memory ramps = new IRouterFork.OffRamp[](offRamps.length); + for (uint256 i; i < offRamps.length; ++i) { + ramps[i] = offRamps[i]; + } + return _findOffRampForOnRamp(ramps, sourceChainSelector, sourceOnRamp); + } +} + +/// @dev v1.6-style OffRamp mock: `getSourceChainConfig` returns a fixed lane binding. +contract MockOffRampV16 { + address internal immutable i_router; + uint64 internal immutable i_sourceChainSelector; + address internal immutable i_onRamp; + bool internal immutable i_enabled; + + constructor(address router_, uint64 sourceChainSelector_, address onRamp_, bool enabled_) { + i_router = router_; + i_sourceChainSelector = sourceChainSelector_; + i_onRamp = onRamp_; + i_enabled = enabled_; + } + + function getSourceChainConfig(uint64 sourceChainSelector) + external + view + returns (IOffRampSourceConfigFork.SourceChainConfig memory cfg) + { + require(sourceChainSelector == i_sourceChainSelector, "bad selector"); + cfg.router = i_router; + cfg.isEnabled = i_enabled; + cfg.minSeqNr = 1; + cfg.isRMNVerificationDisabled = false; + cfg.onRamp = abi.encode(i_onRamp); + } +} + +/// @dev Pre-v1.6 EVM2EVMOffRamp-style mock. +contract MockOffRampPre16 { + uint64 internal immutable i_chainSelector; + uint64 internal immutable i_sourceChainSelector; + address internal immutable i_onRamp; + + constructor(uint64 chainSelector_, uint64 sourceChainSelector_, address onRamp_) { + i_chainSelector = chainSelector_; + i_sourceChainSelector = sourceChainSelector_; + i_onRamp = onRamp_; + } + + function getStaticConfig() external view returns (IEVM2EVMOffRampStaticConfigFork.StaticConfig memory c) { + c.commitStore = address(0x1); + c.chainSelector = i_chainSelector; + c.sourceChainSelector = i_sourceChainSelector; + c.onRamp = i_onRamp; + c.prevOffRamp = address(0); + c.rmnProxy = address(0x2); + c.tokenAdminRegistry = address(0x3); + } +} + +/// @dev Implements v1.6 getter with a wrong onRamp, then pre-v1.6 getter with the correct onRamp (defense in depth). +contract MockOffRampV16WrongThenPre16 is MockOffRampPre16 { + address internal immutable i_wrongOnRamp; + + constructor( + uint64 chainSelector_, + uint64 sourceChainSelector_, + address correctOnRamp_, + address wrongOnRamp_ + ) MockOffRampPre16(chainSelector_, sourceChainSelector_, correctOnRamp_) { + i_wrongOnRamp = wrongOnRamp_; + } + + function getSourceChainConfig(uint64 sourceChainSelector) + external + view + returns (IOffRampSourceConfigFork.SourceChainConfig memory cfg) + { + require(sourceChainSelector == i_sourceChainSelector, "bad selector"); + cfg.router = address(0x4); + cfg.isEnabled = true; + cfg.minSeqNr = 1; + cfg.isRMNVerificationDisabled = false; + cfg.onRamp = abi.encode(i_wrongOnRamp); + } +} + +/// @dev No introspection getters — `_findOffRampForOnRamp` skips these via try/catch. +contract MockOffRampForeign {} + +contract CCIPLocalSimulatorForkRoutingTest is Test { + CCIPLocalSimulatorForkHarness internal harness; + + uint64 internal constant SOURCE_SELECTOR = 16015286601757825753; + uint64 internal constant DEST_CHAIN_SELECTOR = 3478487238524512106; + + function setUp() public { + harness = new CCIPLocalSimulatorForkHarness(); + } + + function test_decodeReceiver_abiEncodedAddress() public { + address a = address(0x1234567890123456789012345678901234567890); + assertEq(harness.exposedDecodeReceiver(abi.encode(a)), a); + } + + function test_decodeReceiver_twentyByteRaw() public { + address a = address(0x1234567890123456789012345678901234567890); + assertEq(harness.exposedDecodeReceiver(abi.encodePacked(a)), a); + } + + function test_findOffRamp_returnsMatchingV16RegardlessOfRouterListOrder() public { + address sourceOnRamp = address(0xA11CE); + address routerAddr = address(0xBEEF); + + address good = address(new MockOffRampV16(routerAddr, SOURCE_SELECTOR, sourceOnRamp, true)); + address bad = address(new MockOffRampV16(routerAddr, SOURCE_SELECTOR, address(0xBAD), true)); + address foreign = address(new MockOffRampForeign()); + + IRouterFork.OffRamp[] memory ramps = new IRouterFork.OffRamp[](3); + ramps[0] = IRouterFork.OffRamp({sourceChainSelector: SOURCE_SELECTOR, offRamp: good}); + ramps[1] = IRouterFork.OffRamp({sourceChainSelector: SOURCE_SELECTOR, offRamp: bad}); + ramps[2] = IRouterFork.OffRamp({sourceChainSelector: SOURCE_SELECTOR, offRamp: foreign}); + + assertEq(harness.exposedFindOffRamp(ramps, SOURCE_SELECTOR, sourceOnRamp), good); + } + + function test_findOffRamp_returnsMatchingPreV16() public { + address sourceOnRamp = address(0xB0B); + address pre = address(new MockOffRampPre16(DEST_CHAIN_SELECTOR, SOURCE_SELECTOR, sourceOnRamp)); + + IRouterFork.OffRamp[] memory ramps = new IRouterFork.OffRamp[](1); + ramps[0] = IRouterFork.OffRamp({sourceChainSelector: SOURCE_SELECTOR, offRamp: pre}); + + assertEq(harness.exposedFindOffRamp(ramps, SOURCE_SELECTOR, sourceOnRamp), pre); + } + + function test_findOffRamp_returnsZeroWhenNoMatch() public { + address sourceOnRamp = address(0xC0C0); + address wrong = address(new MockOffRampV16(address(0x1), SOURCE_SELECTOR, address(0xDEAD), true)); + + IRouterFork.OffRamp[] memory ramps = new IRouterFork.OffRamp[](1); + ramps[0] = IRouterFork.OffRamp({sourceChainSelector: SOURCE_SELECTOR, offRamp: wrong}); + + assertEq(harness.exposedFindOffRamp(ramps, SOURCE_SELECTOR, sourceOnRamp), address(0)); + } + + function test_findOffRamp_fallsBackToStaticWhenV16OnRampWrong() public { + address sourceOnRamp = address(0xD00D); + address wrongOnRamp = address(0xBAD1); + address combo = address(new MockOffRampV16WrongThenPre16(DEST_CHAIN_SELECTOR, SOURCE_SELECTOR, sourceOnRamp, wrongOnRamp)); + + IRouterFork.OffRamp[] memory ramps = new IRouterFork.OffRamp[](1); + ramps[0] = IRouterFork.OffRamp({sourceChainSelector: SOURCE_SELECTOR, offRamp: combo}); + + assertEq(harness.exposedFindOffRamp(ramps, SOURCE_SELECTOR, sourceOnRamp), combo); + } + + function test_findOffRamp_ignoresDisabledV16Lane() public { + address sourceOnRamp = address(0xE11E); + address routerAddr = address(0xF00D); + address disabled = address(new MockOffRampV16(routerAddr, SOURCE_SELECTOR, sourceOnRamp, false)); + address enabled = address(new MockOffRampV16(routerAddr, SOURCE_SELECTOR, sourceOnRamp, true)); + + IRouterFork.OffRamp[] memory ramps = new IRouterFork.OffRamp[](2); + ramps[0] = IRouterFork.OffRamp({sourceChainSelector: SOURCE_SELECTOR, offRamp: disabled}); + ramps[1] = IRouterFork.OffRamp({sourceChainSelector: SOURCE_SELECTOR, offRamp: enabled}); + + assertEq(harness.exposedFindOffRamp(ramps, SOURCE_SELECTOR, sourceOnRamp), enabled); + } +}