Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@ By doing near mainnet testing, developers can quickly check sender authenticatio
| Stargate | ✅ | |
| Across | ✅ | |
| CCIP | ✅ | ✅ |
| a.DI | ✅ | |
| Arbitrum (native) | ✅ | |
## Getting Started

### Installation
Expand Down Expand Up @@ -83,6 +85,35 @@ ccipHelper.help(CcipHelper.HelpArgs({

`helpWithEstimates(...)` additionally emits `ccipFeePaid`, `ccipFeeToken`, (and `ccipFeeValueJuels` for 1.6) decoded from the source emission. The helper invokes the receiver via `Router.routeMessage` from a prank as the resolved OffRamp, and credits destination tokens to the receiver via `deal()`. It does **not** exercise `TokenPool.releaseOrMint`, rate limits, RMN curse checks, or USDC CCTP attestations. CCIP 1.5 messages with non-empty `tokenAmounts` revert.

a.DI (Aave Delivery Infrastructure — composes `CcipHelper`, `LayerZeroV2Helper`, `HyperlaneHelper`, and `ArbitrumNativeHelper`):

```solidity
// Eth → Arb (Arbitrum native bridge)
adiHelper.helpEthToArb(AdiHelper.EthToArbArgs({
l2ForkId: ARB_FORK_ID,
l1Inbox: ARB_INBOX,
l1Bridge: ARB_BRIDGE,
expectedL1CCC: L1_CCC,
logs: logs
}));

// Arb → Eth (multi-bridge consensus — set address(0) on AMBs you want to skip)
adiHelper.helpMultiBridge(AdiHelper.MultiBridgeArgs({
dstForkId: ETH_FORK_ID,
dstCcipRouter: ETH_CCIP_ROUTER,
dstCcipChainSelector: ETH_CCIP_CHAIN_SELECTOR,
srcCcipOnRamp: address(0),
dstLzEndpoint: LZ_ENDPOINT_V2,
srcHlMailbox: address(0), // skip Hyperlane on this lane
dstHlMailbox: address(0),
logs: logs
}));
```

**Funding**: a.DI's `CrossChainController` must hold native to pay AMB fees. Caller MUST `vm.deal(address(L1_CCC), N ether)` BEFORE invoking `forwardMessage` — the helper does NOT fund the CCC.

**AMB endpoint addresses**: read each deployed adapter's configured AMB endpoint via its public getter (`HL_MAIL_BOX()`, `LZ_ENDPOINT()`, `getRouter()`) and pass that to `MultiBridgeArgs`. Do NOT hardcode canonical AMB addresses — deployments may use custom AMB infrastructure (different validator sets / ISMs / etc.).

To display estimations, run the `npm install` and `npm run compile` commands from the [utils/scripts directory](./utils/scripts) before running your tests. Then run tests with the `--ffi` flag and `ENABLE_ESTIMATES` env variable set to `true.`

**Gas estimation** is the gas costs required in native tokens to pay for the message delivery.
Expand Down
126 changes: 126 additions & 0 deletions src/adi/AdiHelper.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
// SPDX-License-Identifier: MIT
pragma solidity >=0.8.0;

/// library imports
import "forge-std/Test.sol";

/// local imports
import {CcipHelper} from "../ccip/CcipHelper.sol";
import {LayerZeroV2Helper} from "../layerzero-v2/LayerZeroV2Helper.sol";
import {HyperlaneHelper} from "../hyperlane/HyperlaneHelper.sol";
import {ArbitrumNativeHelper} from "../arbitrum/ArbitrumNativeHelper.sol";

/// @title a.DI Helper
/// @notice Helps simulate Aave Delivery Infrastructure (a.DI) envelope flows by composing pigeon's existing
/// per-AMB helpers (CCIP, LayerZero V2, Hyperlane) and a new Arbitrum-native primitive.
/// @dev a.DI's `CrossChainForwarder.forwardMessage` broadcasts an envelope to a (possibly shuffled) subset of
/// configured bridge adapters; the destination CCC executes the receiver once `requiredConfirmation` adapters
/// have delivered. This helper does not assume which adapters fired — each child helper self-filters its own
/// AMB's events and silently no-ops if none appear.
/// @dev IMPORTANT: a.DI's CCC must hold native to pay AMB fees. Callers MUST `vm.deal(address(CCC), ...)` BEFORE
/// invoking `forwardMessage`. This helper does NOT fund the CCC.
contract AdiHelper is Test {
CcipHelper public immutable ccipHelper;
LayerZeroV2Helper public immutable lzHelper;
HyperlaneHelper public immutable hlHelper;
ArbitrumNativeHelper public immutable arbHelper;

/// @dev keccak256("TransactionForwardingAttempted(bytes32,bytes32,bytes,uint256,address,address,bool,bytes)")
bytes32 public constant TRANSACTION_FORWARDING_ATTEMPTED_SELECTOR =
0x935aa87d643578e6395c90fdbd5d50ffee5f2c1f6ce2cd01274740412bb679f4;

struct EthToArbArgs {
uint256 l2ForkId;
address l1Inbox; // 0 = any
address l1Bridge; // 0 = any
address expectedL1CCC; // expected L1 sender on the retryable (CCC due to delegatecall)
Vm.Log[] logs;
}

/// @notice Args for any "multi-bridge consensus" lane (Arb→Eth in v1; future Arb→Op etc.).
/// @dev Set any endpoint/router to address(0) to disable that AMB. Each child helper self-filters.
struct MultiBridgeArgs {
uint256 dstForkId;
// CCIP
address dstCcipRouter; // 0 disables CCIP relay
uint64 dstCcipChainSelector; // 0 = no selector filter
address srcCcipOnRamp; // 0 = no emitter filter
// LayerZero V2
address dstLzEndpoint; // 0 disables LZ relay
// Hyperlane
address srcHlMailbox; // 0 disables HL relay (HyperlaneHelper requires both)
address dstHlMailbox; // 0 disables HL relay
Vm.Log[] logs;
}

constructor(CcipHelper c, LayerZeroV2Helper l, HyperlaneHelper h, ArbitrumNativeHelper a) {
ccipHelper = c;
lzHelper = l;
hlHelper = h;
arbHelper = a;
vm.makePersistent(address(this));
vm.makePersistent(address(c));
vm.makePersistent(address(l));
vm.makePersistent(address(h));
vm.makePersistent(address(a));
}

//////////////////////////////////////////////////////////////
// EXTERNAL FUNCTIONS //
//////////////////////////////////////////////////////////////

/// @notice Relay an Eth → Arb a.DI envelope via the Arbitrum native bridge.
/// @dev Caller must `vm.deal(L1_CCC, ...)` BEFORE `forwardMessage` to fund the retryable.
/// @param args the relay arguments
function helpEthToArb(EthToArbArgs memory args) external {
ArbitrumNativeHelper.HelpArgs memory inner = ArbitrumNativeHelper.HelpArgs({
l2ForkId: args.l2ForkId,
l1Inbox: args.l1Inbox,
l1Bridge: args.l1Bridge,
expectedL1Sender: args.expectedL1CCC,
logs: args.logs
});
arbHelper.help(inner);
}

/// @notice Relay any multi-bridge consensus a.DI envelope (Arb → Eth canonical lane; future Arb → Op).
/// @dev Each child helper self-filters from `args.logs`. Setting an endpoint to address(0) skips that AMB.
/// @dev Over-delivery is fine: once the destination CCC's threshold is met the envelope transitions to
/// `Delivered`, and subsequent adapter deliveries just increment `confirmations` without re-executing the
/// receiver. The receive path through each adapter must succeed though — the per-adapter `onlyMailBox` /
/// `onlyEndpoint` / `onlyRouter` checks must match the prank target you pass in. Read each deployed adapter's
/// configured AMB endpoint via its public getter (e.g., `HL_MAIL_BOX()`, `LZ_ENDPOINT()`, `getRouter()`) — do
/// NOT hardcode canonical AMB addresses, since deployments may use custom AMB infrastructure.
/// @param args the relay arguments
function helpMultiBridge(MultiBridgeArgs memory args) external {
if (args.dstCcipRouter != address(0)) {
CcipHelper.HelpArgs memory ccipArgs = CcipHelper.HelpArgs({
dstForkId: args.dstForkId,
dstRouter: args.dstCcipRouter,
expDstChainSelector: args.dstCcipChainSelector,
srcOnRamp: args.srcCcipOnRamp,
logs: args.logs
});
ccipHelper.help(ccipArgs);
}
if (args.dstLzEndpoint != address(0)) {
lzHelper.help(args.dstLzEndpoint, args.dstForkId, args.logs);
}
if (args.srcHlMailbox != address(0) && args.dstHlMailbox != address(0)) {
hlHelper.help(args.srcHlMailbox, args.dstHlMailbox, args.dstForkId, args.logs);
}
}

/// @notice Count source-side `TransactionForwardingAttempted` events with `adapterSuccessful = true`.
/// @dev Useful for tests that want to assert the shuffle picked >= N adapters and they succeeded.
/// @param logs the recorded source-tx logs
/// @return count number of successful forwarding attempts
function countSuccessfulForwards(Vm.Log[] memory logs) external pure returns (uint256 count) {
for (uint256 i; i < logs.length; ++i) {
if (logs[i].topics.length < 4) continue;
if (logs[i].topics[0] != TRANSACTION_FORWARDING_ATTEMPTED_SELECTOR) continue;
// adapterSuccessful is the third indexed field; topic[3] = bytes32(uint256(1)) when true
if (logs[i].topics[3] == bytes32(uint256(1))) ++count;
}
}
}
168 changes: 168 additions & 0 deletions src/arbitrum/ArbitrumNativeHelper.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
// SPDX-License-Identifier: MIT
pragma solidity >=0.8.0;

/// library imports
import "forge-std/Test.sol";

/// @title Arbitrum Native Helper
/// @notice Helps simulate L1 → Arbitrum native-bridge retryable-ticket delivery in forked tests.
/// @dev Detects `IBridge.MessageDelivered` + `IDelayedMessageProvider.InboxMessageDelivered` events on L1
/// (paired by `messageNum`), decodes the packed retryable payload to `(to, data)`, switches to the L2 fork,
/// pranks `MessageDelivered.sender` (already aliased by the Inbox), and calls `to.call(data)`. Reusable
/// beyond a.DI for any L1 contract that wraps `Inbox.createRetryableTicket`.
/// @dev Note on aliasing: `AbsInbox._submitRetryable` calls `applyL1ToL2Alias(msg.sender)` BEFORE delivering
/// to the Bridge, so `MessageDelivered.sender` is already the L2 alias. The helper does NOT re-alias.
contract ArbitrumNativeHelper is Test {
/// @dev keccak256("MessageDelivered(uint256,bytes32,address,uint8,address,bytes32,uint256,uint64)")
bytes32 public constant MESSAGE_DELIVERED_SELECTOR =
0x5e3c1311ea442664e8b1611bfabef659120ea7a0a2cfc0667700bebc69cbffe1;

/// @dev keccak256("InboxMessageDelivered(uint256,bytes)")
bytes32 public constant INBOX_MESSAGE_DELIVERED_SELECTOR =
0xff64905f73a67fb594e0f940a8075a860db489ad991e032f48c81123eb52d60b;

/// @dev L1 → L2 address aliasing offset
uint160 public constant ALIAS_OFFSET = uint160(0x1111000000000000000000000000000000001111);

/// @dev `MessageDelivered.kind` value for retryable submissions (`L1MessageType_submitRetryableTx`).
uint8 public constant L1_MESSAGE_TYPE_RETRYABLE = 9;

struct HelpArgs {
uint256 l2ForkId; // destination Arbitrum fork id
address l1Inbox; // optional emitter filter for InboxMessageDelivered (0 = any)
address l1Bridge; // optional emitter filter for MessageDelivered (0 = any)
address expectedL1Sender; // optional raw L1 sender filter (0 = any). Compared via applyL1ToL2Alias.
Vm.Log[] logs; // logs from vm.recordLogs on L1
}

error MessageDeliveredMissing(uint256 messageNum);
error RetryableCallFailed(bytes returnData);
error MalformedRetryablePayload();

mapping(uint256 => bool) internal _processedMessageNums;

//////////////////////////////////////////////////////////////
// EXTERNAL FUNCTIONS //
//////////////////////////////////////////////////////////////

/// @notice helps relay one or more L1 → Arbitrum retryables from recorded logs
/// @param args the relay arguments
function help(HelpArgs memory args) external {
_help(args);
}

/// @notice filter logs to those matching `InboxMessageDelivered`
/// @param logs the recorded logs
/// @param length the maximum number of matching logs to return
/// @return found array of matching logs, sized to the actual number found (≤ length)
function findLogs(Vm.Log[] calldata logs, uint256 length) external pure returns (Vm.Log[] memory found) {
found = new Vm.Log[](length);
Comment thread
0xTimepunk marked this conversation as resolved.
uint256 idx;
for (uint256 i; i < logs.length; ++i) {
if (logs[i].topics.length == 0) continue;
if (logs[i].topics[0] == INBOX_MESSAGE_DELIVERED_SELECTOR) {
found[idx++] = logs[i];
if (idx == length) break;
}
}
// shrink array length to the actual match count so trailing zero entries aren't returned
assembly {
mstore(found, idx)
}
}

/// @notice compute the L2 alias of an L1 address
function applyL1ToL2Alias(address l1) public pure returns (address) {
unchecked {
return address(uint160(l1) + ALIAS_OFFSET);
}
}

//////////////////////////////////////////////////////////////
// INTERNAL FUNCTIONS //
//////////////////////////////////////////////////////////////

/// @notice scan logs for retryables and relay each to the L2 fork
function _help(HelpArgs memory args) internal {
uint256 prevForkId = vm.activeFork();

for (uint256 i; i < args.logs.length; ++i) {
Vm.Log memory l = args.logs[i];
if (l.topics.length < 2) continue;
if (l.topics[0] != INBOX_MESSAGE_DELIVERED_SELECTOR) continue;
if (args.l1Inbox != address(0) && l.emitter != args.l1Inbox) continue;

uint256 messageNum = uint256(l.topics[1]);
if (_processedMessageNums[messageNum]) continue;

(bool foundPair, address aliasedSender, uint8 kind) =
_findPairedMessageDelivered(args.logs, messageNum, args.l1Bridge);
if (!foundPair) revert MessageDeliveredMissing(messageNum);
if (kind != L1_MESSAGE_TYPE_RETRYABLE) continue;
if (
args.expectedL1Sender != address(0)
&& aliasedSender != applyL1ToL2Alias(args.expectedL1Sender)
) continue;

bytes memory payload = abi.decode(l.data, (bytes));
(address to, bytes memory innerData) = _decodeRetryablePayload(payload);

_processedMessageNums[messageNum] = true;

vm.selectFork(args.l2ForkId);
vm.prank(aliasedSender, aliasedSender);
(bool ok, bytes memory ret) = to.call(innerData);
if (!ok) revert RetryableCallFailed(ret);
}

vm.selectFork(prevForkId);
}

/// @notice locate the `MessageDelivered` event paired with a given `messageNum`
/// @return found whether a paired event was found
/// @return aliasedSender the already-aliased L1 sender stored by the Inbox
/// @return kind the message kind (9 for retryables)
function _findPairedMessageDelivered(Vm.Log[] memory logs, uint256 messageNum, address l1Bridge)
internal
pure
returns (bool found, address aliasedSender, uint8 kind)
{
for (uint256 i; i < logs.length; ++i) {
Vm.Log memory l = logs[i];
if (l.topics.length < 2) continue;
if (l.topics[0] != MESSAGE_DELIVERED_SELECTOR) continue;
if (l1Bridge != address(0) && l.emitter != l1Bridge) continue;
if (uint256(l.topics[1]) != messageNum) continue;

// MessageDelivered.data = abi.encode(inbox, kind, sender, messageDataHash, baseFeeL1, timestamp)
(, uint8 _kind, address _sender,,,) =
abi.decode(l.data, (address, uint8, address, bytes32, uint256, uint64));
return (true, _sender, _kind);
}
return (false, address(0), 0);
}

/// @notice decode the abi-packed retryable payload from `Inbox.createRetryableTicket`
/// @dev Layout (from `nitro-contracts/AbsInbox._submitRetryable`):
/// uint256(to) | l2CallValue | msg.value | maxSubmissionCost |
/// uint256(excessFeeRefundAddress) | uint256(callValueRefundAddress) |
/// gasLimit | maxFeePerGas | uint256(callDataLength) | data
function _decodeRetryablePayload(bytes memory payload) internal pure returns (address to, bytes memory data) {
if (payload.length < 9 * 32) revert MalformedRetryablePayload();

uint256 toWord;
uint256 callDataLength;
assembly {
toWord := mload(add(payload, 32))
callDataLength := mload(add(payload, mul(32, 9)))
}

if (payload.length < 9 * 32 + callDataLength) revert MalformedRetryablePayload();
to = address(uint160(toWord));

data = new bytes(callDataLength);
for (uint256 i; i < callDataLength; ++i) {
data[i] = payload[9 * 32 + i];
}
}
}
Loading
Loading