diff --git a/README.md b/README.md index 3d90cd7..cc33994 100644 --- a/README.md +++ b/README.md @@ -32,6 +32,9 @@ By doing near mainnet testing, developers can quickly check sender authenticatio | CCIP | ✅ | ✅ | | a.DI | ✅ | | | Arbitrum (native) | ✅ | | +| Relay | ✅ | | +| CCTP V2 | ✅ | | +| Circle Gateway | ✅ | | ## Getting Started ### Installation @@ -114,6 +117,28 @@ adiHelper.helpMultiBridge(AdiHelper.MultiBridgeArgs({ **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.). +Circle Gateway (unified USDC balance — there is NO source-chain message to relay; the helper plays Circle's attestation signer): + +```solidity +CircleGatewayHelper gw = new CircleGatewayHelper(0); // 0 = default test signer key; persistent across forks + +// optional source-side realism: fund + deposit into GatewayWallet on the source fork +gw.helpDeposit(ETH_FORK_ID, ETH_USDC, depositor, 1000e6); + +// describe the transfer (Gateway domains: Ethereum 0, Avalanche 1, OP 2, Arbitrum 3, Base 6, Polygon 7, ...) +CircleGatewayHelper.TransferSpec memory spec = + gw.buildSpec(0, 3, ETH_USDC, ARB_USDC, depositor, recipient, destinationCaller, 1000e6, hookData); + +// mint on the destination fork through the REAL GatewayMinter (pranked as destinationCaller when set) +CircleGatewayHelper.Attested memory a = gw.help(ARB_FORK_ID, spec); +// or hand the signed payload to a destination adapter that calls gatewayMint itself +gw.helpMintViaAdapter(ARB_FORK_ID, address(adapter), spec); +// or just attest and drive gatewayMint yourself (optionally with an explicit maxBlockHeight) +a = gw.helpAttest(ARB_FORK_ID, spec); +``` + +`helpSet` / `helpMintViaAdapterSet` / `helpAttestSet` use the `AttestationSet` wire format (several specs, one atomic mint; a set of one stays a set). `a.transferSpecHashes` are the minter's replay keys (`isTransferSpecHashUsed`). Attestations are EIP-191 (`personal_sign` over `keccak256(payload)`), valid through 1000 blocks from the destination fork's block by default; the minter's own checks (signer, expiry, domain, caller, token, replay, denylist, pause) all run for real. A reverting destination call restores your previously selected fork before re-raising. + 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. diff --git a/src/circle-gateway/CircleGatewayHelper.sol b/src/circle-gateway/CircleGatewayHelper.sol new file mode 100644 index 0000000..fd899cc --- /dev/null +++ b/src/circle-gateway/CircleGatewayHelper.sol @@ -0,0 +1,383 @@ +// SPDX-License-Identifier: MIT +pragma solidity >=0.8.0; + +/// library imports +import "forge-std/Test.sol"; +import {IGatewayMinter} from "./interfaces/IGatewayMinter.sol"; +import {IGatewayWallet} from "./interfaces/IGatewayWallet.sol"; +import {IGatewayAttestationReceiver} from "./interfaces/IGatewayAttestationReceiver.sol"; +import {IERC20} from "./interfaces/IERC20.sol"; + +/// @title Circle Gateway Helper +/// @notice helps simulate Circle Gateway (unified USDC balance) mints on a destination fork +/// @dev Gateway differs structurally from CCTP: there is NO on-chain source message to relay. The user +/// deposits into GatewayWallet, signs a BurnIntent off-chain, and Circle's API returns an +/// EIP-191-signed Attestation (or AttestationSet) that anyone submits to GatewayMinter.gatewayMint on +/// the destination; the source-side burn happens afterwards. So this helper cannot derive anything +/// from logs: the caller describes the transfer as a TransferSpec, and the helper (1) enrolls a test +/// attestation signer on the destination minter via its owner, (2) encodes the exact Circle wire +/// format, (3) signs it the way Circle's service does (personal_sign over keccak256(payload), no +/// EIP-712 domain), and (4) either mints directly or hands the payload to an adapter. +/// @dev Wire formats (circlefin/evm-gateway-contracts v1.0.0; `testGatewayMintDirect` proves the mainnet +/// minter accepts this encoding by checking `isTransferSpecHashUsed` on the hand-computed hash): +/// TransferSpec = magic 0xca85def7 | version u32 | sourceDomain u32 | destinationDomain u32 | +/// sourceContract b32 | destinationContract b32 | sourceToken b32 | destinationToken b32 | +/// sourceDepositor b32 | destinationRecipient b32 | sourceSigner b32 | destinationCaller b32 | +/// value u256 | salt b32 | hookDataLength u32 | hookData +/// Attestation = magic 0xff6fb334 | maxBlockHeight u256 | transferSpecLength u32 | TransferSpec +/// AttestationSet= magic 0x1e12db71 | numAttestations u32 | Attestation... +/// The minter's replay key is keccak256(TransferSpec bytes) (`isTransferSpecHashUsed`). +/// @dev Everything the minter checks runs for real (signer, expiry, domain, contract, caller, token, replay, +/// denylist, pause, same-domain token equality). What is NOT modeled and cannot be: the off-chain +/// Gateway balance check, the BurnIntent signature, Circle's fee netting and the source-side burn. +/// `helpDeposit` is optional realism and is not linked to `help*`. +/// @dev The helper makes itself persistent in the constructor (it is called from, and mutates, several +/// forks); a reverting destination call restores the previously selected fork before re-raising. +contract CircleGatewayHelper is Test { + /// @dev Gateway proxies (same addresses on all Gateway chains) + address public constant GATEWAY_WALLET = 0x77777777Dcc4d5A8B6E418Fd04D8997ef11000eE; + address public constant GATEWAY_MINTER = 0x2222222d7164433c4C09B0b0D809a9b52C04C205; + + bytes4 public constant TRANSFER_SPEC_MAGIC = 0xca85def7; // bytes4(keccak256("circle.gateway.TransferSpec")) + bytes4 public constant ATTESTATION_MAGIC = 0xff6fb334; // bytes4(keccak256("circle.gateway.Attestation")) + bytes4 public constant ATTESTATION_SET_MAGIC = 0x1e12db71; // bytes4(keccak256("circle.gateway.AttestationSet")) + uint32 public constant TRANSFER_SPEC_VERSION = 1; + + /// @dev attestations stay valid through (inclusive) this many blocks after the destination fork's current + /// block; use the `maxBlockHeight` overloads of `helpAttest*` for anything else + uint256 public constant DEFAULT_VALIDITY_BLOCKS = 1000; + + /// @dev private key used to sign attestations in tests + uint256 public immutable TEST_SIGNER_PK; + + /// @dev address derived from TEST_SIGNER_PK + address public immutable testSignerAddress; + + /// @dev makes every generated salt unique across a test run (shared across forks: the helper is persistent) + uint256 private _saltNonce; + + /// @notice mirrors circlefin's TransferSpec struct (address fields are left-padded to bytes32) + struct TransferSpec { + uint32 version; + uint32 sourceDomain; + uint32 destinationDomain; + bytes32 sourceContract; + bytes32 destinationContract; + bytes32 sourceToken; + bytes32 destinationToken; + bytes32 sourceDepositor; + bytes32 destinationRecipient; + bytes32 sourceSigner; + bytes32 destinationCaller; + uint256 value; + bytes32 salt; + bytes hookData; + } + + /// @notice what a help call produced, so tests can replay / inspect it + struct Attested { + bytes payload; + bytes signature; + bytes32[] transferSpecHashes; + } + + ////////////////////////////////////////////////////////////// + // CONSTRUCTOR // + ////////////////////////////////////////////////////////////// + + /// @notice creates a helper with a test attestation signer key + /// @param signerPK the private key used to sign attestations (use 0 for default key 0x1) + constructor(uint256 signerPK) { + uint256 pk = signerPK == 0 ? 1 : signerPK; + TEST_SIGNER_PK = pk; + testSignerAddress = vm.addr(pk); + /// the helper is called from and mutates several forks; keep code + _saltNonce visible on all of them + vm.makePersistent(address(this)); + } + + ////////////////////////////////////////////////////////////// + // EXTERNAL FUNCTIONS // + ////////////////////////////////////////////////////////////// + + /// @notice attests and mints one TransferSpec on the destination fork through GatewayMinter.gatewayMint + /// @dev the mint is sent from `spec.destinationCaller` when it is non-zero (the minter enforces it), else + /// from this helper; funds land at `spec.destinationRecipient`. Restores the previously selected fork, + /// also when the mint reverts (the revert is re-raised unchanged). NOTE: the live minter mints through + /// FiatToken's minter allowance, so a value above `USDC.minterAllowance(GATEWAY_MINTER)` (tens of + /// millions) reverts with FiatToken's "mint amount exceeds minterAllowance". + /// @param dstForkId the destination chain fork id (its minter must have `spec.destinationDomain`) + /// @param spec the transfer to mint + /// @return result the signed payload and the spec hash the minter marked used + function help(uint256 dstForkId, TransferSpec memory spec) external returns (Attested memory result) { + result = _mint(dstForkId, _one(spec), false, address(0)); + } + + /// @notice attests and mints an AttestationSet (several TransferSpecs minted atomically in one call) + /// @dev always emits the set wire format, even for one member; all members are minted by one gatewayMint + /// and the minter itself rejects a set whose members disagree on destinationCaller + function helpSet(uint256 dstForkId, TransferSpec[] memory specs) external returns (Attested memory result) { + result = _mint(dstForkId, specs, true, address(0)); + } + + /// @notice attests one TransferSpec and hands it to `adapter.receiveAndExecute(payload, signature)` + /// @dev models the Superform path: the adapter is expected to be the spec's destinationRecipient and + /// destinationCaller (not enforced here; build the spec accordingly), calls gatewayMint itself, and + /// acts on `spec.hookData`. No prank: the adapter is the caller of gatewayMint. + function helpMintViaAdapter(uint256 dstForkId, address adapter, TransferSpec memory spec) + external + returns (Attested memory result) + { + result = _mint(dstForkId, _one(spec), false, adapter); + } + + /// @notice attests an AttestationSet and hands it to `adapter.receiveAndExecute(payload, signature)` + function helpMintViaAdapterSet(uint256 dstForkId, address adapter, TransferSpec[] memory specs) + external + returns (Attested memory result) + { + result = _mint(dstForkId, specs, true, adapter); + } + + /// @notice attests one TransferSpec without minting, for tests that drive the destination call themselves + /// @dev enrolls the test signer on the destination minter and reads block.number there for maxBlockHeight + function helpAttest(uint256 dstForkId, TransferSpec memory spec) external returns (Attested memory result) { + result = _attestOnFork(dstForkId, _one(spec), false, 0); + } + + /// @notice `helpAttest` with an explicit `maxBlockHeight` (destination-chain block height, inclusive) + function helpAttest(uint256 dstForkId, TransferSpec memory spec, uint256 maxBlockHeight) + external + returns (Attested memory result) + { + result = _attestOnFork(dstForkId, _one(spec), false, maxBlockHeight); + } + + /// @notice attests an AttestationSet without minting + function helpAttestSet(uint256 dstForkId, TransferSpec[] memory specs) external returns (Attested memory result) { + result = _attestOnFork(dstForkId, specs, true, 0); + } + + /// @notice `helpAttestSet` with an explicit `maxBlockHeight` shared by all members + function helpAttestSet(uint256 dstForkId, TransferSpec[] memory specs, uint256 maxBlockHeight) + external + returns (Attested memory result) + { + result = _attestOnFork(dstForkId, specs, true, maxBlockHeight); + } + + /// @notice funds `depositor` with `amount` of `token` on the source fork and deposits it into GatewayWallet + /// @dev optional source-side realism: the destination mint does not depend on it (Circle's service would + /// verify the balance off-chain), but tests that assert on `availableBalance` can use it. The wallet's + /// own guards apply (token must be Gateway-supported, depositor not denylisted, not paused). + function helpDeposit(uint256 srcForkId, address token, address depositor, uint256 amount) external { + uint256 prevForkId = vm.activeFork(); + vm.selectFork(srcForkId); + deal(token, depositor, amount); + vm.startPrank(depositor); + require(IERC20(token).approve(GATEWAY_WALLET, amount), "CircleGatewayHelper: approve failed"); + try IGatewayWallet(GATEWAY_WALLET).deposit(token, amount) {} + catch (bytes memory reason) { + vm.stopPrank(); + vm.selectFork(prevForkId); + _rethrow(reason); + } + vm.stopPrank(); + vm.selectFork(prevForkId); + } + + /// @notice convenience builder: fills version, the canonical Gateway contracts and a unique salt + /// @param srcDomain Gateway domain of the source chain (Ethereum 0, Avalanche 1, OP 2, Arbitrum 3, Base 6, + /// Polygon 7, Unichain 10, Sonic 13, World Chain 14 — read from `minter.domain()` on-chain) + /// @param dstDomain Gateway domain of the destination chain (must equal the destination minter's `domain()`) + /// @param srcToken the token on the source chain (must equal dstToken when srcDomain == dstDomain) + /// @param dstToken the token to mint on the destination chain + /// @param depositor the source depositor (also used as sourceSigner) + /// @param recipient who receives the minted funds + /// @param destinationCaller who may submit the attestation (address(0) = anyone) + /// @param value amount to mint + /// @param hookData arbitrary bytes for on-chain composition (ignored by the minter) + function buildSpec( + uint32 srcDomain, + uint32 dstDomain, + address srcToken, + address dstToken, + address depositor, + address recipient, + address destinationCaller, + uint256 value, + bytes memory hookData + ) external returns (TransferSpec memory spec) { + spec = TransferSpec({ + version: TRANSFER_SPEC_VERSION, + sourceDomain: srcDomain, + destinationDomain: dstDomain, + sourceContract: bytes32(uint256(uint160(GATEWAY_WALLET))), + destinationContract: bytes32(uint256(uint160(GATEWAY_MINTER))), + sourceToken: bytes32(uint256(uint160(srcToken))), + destinationToken: bytes32(uint256(uint160(dstToken))), + sourceDepositor: bytes32(uint256(uint160(depositor))), + destinationRecipient: bytes32(uint256(uint160(recipient))), + sourceSigner: bytes32(uint256(uint160(depositor))), + destinationCaller: bytes32(uint256(uint160(destinationCaller))), + value: value, + salt: keccak256(abi.encode("pigeon.circle-gateway", address(this), ++_saltNonce)), + hookData: hookData + }); + } + + ////////////////////////////////////////////////////////////// + // ENCODING (PURE / VIEW) // + ////////////////////////////////////////////////////////////// + + /// @notice encodes a TransferSpec exactly as circlefin's TransferSpecLib does + function encodeTransferSpec(TransferSpec memory spec) public pure returns (bytes memory) { + require(spec.hookData.length <= type(uint32).max, "CircleGatewayHelper: hookData too large"); + /// two halves: a single 16-argument encodePacked overflows the stack (Circle's lib splits the same way) + bytes memory head = abi.encodePacked( + TRANSFER_SPEC_MAGIC, + spec.version, + spec.sourceDomain, + spec.destinationDomain, + spec.sourceContract, + spec.destinationContract, + spec.sourceToken, + spec.destinationToken + ); + bytes memory tail = abi.encodePacked( + spec.sourceDepositor, + spec.destinationRecipient, + spec.sourceSigner, + spec.destinationCaller, + spec.value, + spec.salt, + uint32(spec.hookData.length), + spec.hookData + ); + return bytes.concat(head, tail); + } + + /// @notice the minter's replay key for a spec (== AttestationUsed.transferSpecHash) + function transferSpecHash(TransferSpec memory spec) public pure returns (bytes32) { + return keccak256(encodeTransferSpec(spec)); + } + + /// @notice encodes a single Attestation + function encodeAttestation(uint256 maxBlockHeight, TransferSpec memory spec) public pure returns (bytes memory) { + bytes memory encodedSpec = encodeTransferSpec(spec); + return abi.encodePacked(ATTESTATION_MAGIC, maxBlockHeight, uint32(encodedSpec.length), encodedSpec); + } + + /// @notice encodes an AttestationSet (all members share `maxBlockHeight`) + function encodeAttestationSet(uint256 maxBlockHeight, TransferSpec[] memory specs) + public + pure + returns (bytes memory payload) + { + payload = abi.encodePacked(ATTESTATION_SET_MAGIC, uint32(specs.length)); + for (uint256 i; i < specs.length; ++i) { + payload = bytes.concat(payload, encodeAttestation(maxBlockHeight, specs[i])); + } + } + + /// @notice signs a payload the way Circle's attestation service does: EIP-191 personal_sign over + /// keccak256(payload) (NOT EIP-712 — that is the burn-intent path on GatewayWallet) + function signAttestation(bytes memory payload) public view returns (bytes memory signature) { + bytes32 digest = keccak256(abi.encodePacked("\x19Ethereum Signed Message:\n32", keccak256(payload))); + (uint8 v, bytes32 r, bytes32 s) = vm.sign(TEST_SIGNER_PK, digest); + signature = abi.encodePacked(r, s, v); + } + + ////////////////////////////////////////////////////////////// + // INTERNAL FUNCTIONS // + ////////////////////////////////////////////////////////////// + + /// @dev attests on the destination fork, then mints directly (adapter == 0) or via the adapter; a reverting + /// destination call restores the previous fork and re-raises the same revert data + function _mint(uint256 dstForkId, TransferSpec[] memory specs, bool asSet, address adapter) + internal + returns (Attested memory result) + { + _validate(specs, asSet); // before any fork switch, so a bad input never strands the caller + uint256 prevForkId = vm.activeFork(); + vm.selectFork(dstForkId); + _setupTestSigner(); + result = _attest(specs, asSet, block.number + DEFAULT_VALIDITY_BLOCKS); + + if (adapter != address(0)) { + try IGatewayAttestationReceiver(adapter).receiveAndExecute(result.payload, result.signature) {} + catch (bytes memory reason) { + vm.selectFork(prevForkId); + _rethrow(reason); + } + } else { + /// the minter enforces msg.sender == destinationCaller when it is set + address caller = address(uint160(uint256(specs[0].destinationCaller))); + if (caller != address(0)) vm.prank(caller); + try IGatewayMinter(GATEWAY_MINTER).gatewayMint(result.payload, result.signature) {} + catch (bytes memory reason) { + vm.selectFork(prevForkId); + _rethrow(reason); + } + } + + vm.selectFork(prevForkId); + } + + /// @dev attest-only entry: enrolls the signer on the destination fork and reads its block.number + function _attestOnFork(uint256 dstForkId, TransferSpec[] memory specs, bool asSet, uint256 maxBlockHeight) + internal + returns (Attested memory result) + { + _validate(specs, asSet); + uint256 prevForkId = vm.activeFork(); + vm.selectFork(dstForkId); + _setupTestSigner(); + if (maxBlockHeight == 0) maxBlockHeight = block.number + DEFAULT_VALIDITY_BLOCKS; + result = _attest(specs, asSet, maxBlockHeight); + vm.selectFork(prevForkId); + } + + /// @dev encodes (single Attestation or AttestationSet, as requested) and signs + function _attest(TransferSpec[] memory specs, bool asSet, uint256 maxBlockHeight) + internal + view + returns (Attested memory result) + { + _validate(specs, asSet); + result.payload = + asSet ? encodeAttestationSet(maxBlockHeight, specs) : encodeAttestation(maxBlockHeight, specs[0]); + result.signature = signAttestation(result.payload); + result.transferSpecHashes = new bytes32[](specs.length); + for (uint256 i; i < specs.length; ++i) { + result.transferSpecHashes[i] = transferSpecHash(specs[i]); + } + } + + /// @dev enrolls the test signer on the destination minter (owner-only; production signers stay enabled, + /// each attestation needs just one valid signer) + function _setupTestSigner() internal { + IGatewayMinter minter = IGatewayMinter(GATEWAY_MINTER); + if (!minter.isAttestationSigner(testSignerAddress)) { + vm.prank(minter.owner()); + minter.addAttestationSigner(testSignerAddress); + } + } + + /// @dev input checks shared by every entrypoint; runs before any fork switch + function _validate(TransferSpec[] memory specs, bool asSet) internal pure { + require(specs.length != 0, "CircleGatewayHelper: empty set"); + require(asSet || specs.length == 1, "CircleGatewayHelper: single attestation takes one spec"); + } + + function _one(TransferSpec memory spec) internal pure returns (TransferSpec[] memory specs) { + specs = new TransferSpec[](1); + specs[0] = spec; + } + + /// @dev re-raises revert data unchanged + function _rethrow(bytes memory reason) internal pure { + assembly { + revert(add(reason, 32), mload(reason)) + } + } +} diff --git a/src/circle-gateway/interfaces/IERC20.sol b/src/circle-gateway/interfaces/IERC20.sol new file mode 100644 index 0000000..66b68f0 --- /dev/null +++ b/src/circle-gateway/interfaces/IERC20.sol @@ -0,0 +1,8 @@ +// SPDX-License-Identifier: MIT +pragma solidity >=0.8.0; + +interface IERC20 { + function balanceOf(address account) external view returns (uint256); + function approve(address spender, uint256 amount) external returns (bool); + function transfer(address to, uint256 amount) external returns (bool); +} diff --git a/src/circle-gateway/interfaces/IGatewayAttestationReceiver.sol b/src/circle-gateway/interfaces/IGatewayAttestationReceiver.sol new file mode 100644 index 0000000..9ae0175 --- /dev/null +++ b/src/circle-gateway/interfaces/IGatewayAttestationReceiver.sol @@ -0,0 +1,8 @@ +// SPDX-License-Identifier: MIT +pragma solidity >=0.8.0; + +/// @notice a destination contract that consumes a Gateway attestation itself (e.g. Superform's +/// CircleGatewayAdapter): it calls GatewayMinter.gatewayMint and acts on the TransferSpec's hookData +interface IGatewayAttestationReceiver { + function receiveAndExecute(bytes calldata attestationPayload, bytes calldata signature) external; +} diff --git a/src/circle-gateway/interfaces/IGatewayMinter.sol b/src/circle-gateway/interfaces/IGatewayMinter.sol new file mode 100644 index 0000000..5ecf937 --- /dev/null +++ b/src/circle-gateway/interfaces/IGatewayMinter.sol @@ -0,0 +1,12 @@ +// SPDX-License-Identifier: MIT +pragma solidity >=0.8.0; + +/// @notice the GatewayMinter surface the helper and its tests use +/// @dev proxy at 0x2222222d7164433c4C09B0b0D809a9b52C04C205 on every Gateway chain +interface IGatewayMinter { + function gatewayMint(bytes calldata attestationPayload, bytes calldata signature) external; + function owner() external view returns (address); + function isAttestationSigner(address signer) external view returns (bool); + function addAttestationSigner(address signer) external; + function isTransferSpecHashUsed(bytes32 transferSpecHash) external view returns (bool); +} diff --git a/src/circle-gateway/interfaces/IGatewayWallet.sol b/src/circle-gateway/interfaces/IGatewayWallet.sol new file mode 100644 index 0000000..caffca5 --- /dev/null +++ b/src/circle-gateway/interfaces/IGatewayWallet.sol @@ -0,0 +1,9 @@ +// SPDX-License-Identifier: MIT +pragma solidity >=0.8.0; + +/// @notice the GatewayWallet surface the helper and its tests use +/// @dev proxy at 0x77777777Dcc4d5A8B6E418Fd04D8997ef11000eE on every Gateway chain +interface IGatewayWallet { + function deposit(address token, uint256 value) external; + function availableBalance(address token, address depositor) external view returns (uint256); +} diff --git a/test/CircleGateway.t.sol b/test/CircleGateway.t.sol new file mode 100644 index 0000000..dafe42a --- /dev/null +++ b/test/CircleGateway.t.sol @@ -0,0 +1,644 @@ +// SPDX-License-Identifier: MIT +pragma solidity >=0.8.0; + +import "forge-std/Test.sol"; + +import {CircleGatewayHelper} from "src/circle-gateway/CircleGatewayHelper.sol"; +import {IGatewayMinter} from "src/circle-gateway/interfaces/IGatewayMinter.sol"; +import {IGatewayWallet} from "src/circle-gateway/interfaces/IGatewayWallet.sol"; +import {IERC20} from "src/circle-gateway/interfaces/IERC20.sol"; + +/// @dev destination adapter standing in for Superform's CircleGatewayAdapter: it is the spec's +/// destinationRecipient + destinationCaller, calls gatewayMint itself, then forwards the minted delta to +/// the account named in hookData (single attestation) or to a default account (sets) +contract MockGatewayAdapter { + address public immutable minter; + address public immutable usdc; + address public immutable defaultAccount; + uint256 public lastMinted; + bytes public lastHookData; + + constructor(address _minter, address _usdc, address _defaultAccount) { + minter = _minter; + usdc = _usdc; + defaultAccount = _defaultAccount; + } + + function receiveAndExecute(bytes calldata attestationPayload, bytes calldata signature) external { + uint256 before = IERC20(usdc).balanceOf(address(this)); + IGatewayMinter(minter).gatewayMint(attestationPayload, signature); + lastMinted = IERC20(usdc).balanceOf(address(this)) - before; + address account = defaultAccount; + if (bytes4(attestationPayload[:4]) == 0xff6fb334) { + /// TransferSpec.hookData: 40 (attestation header) + 340 (spec header) for a single attestation + lastHookData = attestationPayload[380:]; + account = abi.decode(lastHookData, (address)); + } + IERC20(usdc).transfer(account, lastMinted); + } +} + +abstract contract CircleGatewayTestBase is Test { + CircleGatewayHelper helper; + + uint256 L1_FORK_ID; + uint256 ARBITRUM_FORK_ID; + + address constant L1_USDC = 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48; + address constant ARBITRUM_USDC = 0xaf88d065e77c8cC2239327C5EDb3A432268e5831; + uint32 constant DOMAIN_ETH = 0; + uint32 constant DOMAIN_ARBITRUM = 3; + + address constant DEPOSITOR = address(0xDE905); + address constant ACCOUNT = address(0xCAFE); + address constant CALLER = address(0xCA11E5); + + /// @dev known-good bytes produced by circlefin's TransferSpecLib / AttestationLib for FIXED_SPEC (see + /// _fixedSpec) with maxBlockHeight 123456 — catches field-order / width regressions without an RPC + bytes constant VECTOR_SPEC = + hex"ca85def700000001000000000000000300000000000000000000000077777777dcc4d5a8b6e418fd04d8997ef11000ee0000000000000000000000002222222d7164433c4c09b0b0d809a9b52c04c205000000000000000000000000a0b86991c6218b36c1d19d4a2e9eb0ce3606eb48000000000000000000000000af88d065e77c8cc2239327c5edb3a432268e583100000000000000000000000000000000000000000000000000000000000de905000000000000000000000000000000000000000000000000000000000000cafe00000000000000000000000000000000000000000000000000000000000de9050000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000003b9aca000000000000000000000000000000000000000000000000000000000000005a1700000003c0ffee"; + bytes constant VECTOR_ATTESTATION = + hex"ff6fb334000000000000000000000000000000000000000000000000000000000001e24000000157ca85def700000001000000000000000300000000000000000000000077777777dcc4d5a8b6e418fd04d8997ef11000ee0000000000000000000000002222222d7164433c4c09b0b0d809a9b52c04c205000000000000000000000000a0b86991c6218b36c1d19d4a2e9eb0ce3606eb48000000000000000000000000af88d065e77c8cc2239327c5edb3a432268e583100000000000000000000000000000000000000000000000000000000000de905000000000000000000000000000000000000000000000000000000000000cafe00000000000000000000000000000000000000000000000000000000000de9050000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000003b9aca000000000000000000000000000000000000000000000000000000000000005a1700000003c0ffee"; + bytes constant VECTOR_SET = + hex"1e12db7100000001ff6fb334000000000000000000000000000000000000000000000000000000000001e24000000157ca85def700000001000000000000000300000000000000000000000077777777dcc4d5a8b6e418fd04d8997ef11000ee0000000000000000000000002222222d7164433c4c09b0b0d809a9b52c04c205000000000000000000000000a0b86991c6218b36c1d19d4a2e9eb0ce3606eb48000000000000000000000000af88d065e77c8cc2239327c5edb3a432268e583100000000000000000000000000000000000000000000000000000000000de905000000000000000000000000000000000000000000000000000000000000cafe00000000000000000000000000000000000000000000000000000000000de9050000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000003b9aca000000000000000000000000000000000000000000000000000000000000005a1700000003c0ffee"; + + string RPC_ETH_MAINNET = vm.envString("ETH_MAINNET_RPC_URL"); + string RPC_ARBITRUM_MAINNET = vm.envString("ARBITRUM_MAINNET_RPC_URL"); + + function setUp() external { + /// same pins as CctpV2.t.sol so the RPC cache is shared (Gateway is live at both) + ARBITRUM_FORK_ID = vm.createFork(RPC_ARBITRUM_MAINNET, 459_930_000); + L1_FORK_ID = vm.createSelectFork(RPC_ETH_MAINNET, 25_035_000); + helper = new CircleGatewayHelper(0); // persistent across forks by construction + } + + function _spec(address recipient, address destinationCaller, uint256 value, bytes memory hookData) + internal + returns (CircleGatewayHelper.TransferSpec memory) + { + return helper.buildSpec( + DOMAIN_ETH, + DOMAIN_ARBITRUM, + L1_USDC, + ARBITRUM_USDC, + DEPOSITOR, + recipient, + destinationCaller, + value, + hookData + ); + } +} + +contract CircleGatewayHelperTest is CircleGatewayTestBase { + ////////////////////////////////////////////////////////////// + // HAPPY PATHS // + ////////////////////////////////////////////////////////////// + + /// @dev the wire format is right iff the LIVE minter marks EXACTLY our hand-computed hash used + function testGatewayMintDirect() external { + helper.helpDeposit(L1_FORK_ID, L1_USDC, DEPOSITOR, 1000e6); + assertEq( + IGatewayWallet(helper.GATEWAY_WALLET()).availableBalance(L1_USDC, DEPOSITOR), + 1000e6, + "deposit credited the depositor's Gateway balance" + ); + + CircleGatewayHelper.TransferSpec memory spec = _spec(ACCOUNT, address(0), 1000e6, ""); + CircleGatewayHelper.Attested memory attested = helper.help(ARBITRUM_FORK_ID, spec); + + assertEq(vm.activeFork(), L1_FORK_ID, "fork restored"); + vm.selectFork(ARBITRUM_FORK_ID); + assertEq(IERC20(ARBITRUM_USDC).balanceOf(ACCOUNT), 1000e6, "USDC minted to the recipient on Arbitrum"); + assertEq(bytes4(attested.payload), helper.ATTESTATION_MAGIC(), "single attestation format"); + assertEq(attested.transferSpecHashes.length, 1); + /// independent recomputation: the spec slice starts at attestation offset 40 + bytes memory payload = attested.payload; + bytes memory specBytes = new bytes(payload.length - 40); + for (uint256 i; i < specBytes.length; ++i) { + specBytes[i] = payload[40 + i]; + } + assertEq(attested.transferSpecHashes[0], keccak256(specBytes), "hash is of the spec slice only"); + assertTrue( + IGatewayMinter(helper.GATEWAY_MINTER()).isTransferSpecHashUsed(attested.transferSpecHashes[0]), + "minter marked our hand-encoded spec hash: encoding matches Circle" + ); + } + + function testGatewayMintPinnedCaller() external { + helper.help(ARBITRUM_FORK_ID, _spec(ACCOUNT, CALLER, 5e6, hex"c0ffee")); + + vm.selectFork(ARBITRUM_FORK_ID); + assertEq(IERC20(ARBITRUM_USDC).balanceOf(ACCOUNT), 5e6, "minted from the pinned caller"); + } + + function testGatewayMintViaAdapter() external { + vm.selectFork(ARBITRUM_FORK_ID); + MockGatewayAdapter adapter = new MockGatewayAdapter(helper.GATEWAY_MINTER(), ARBITRUM_USDC, ACCOUNT); + vm.selectFork(L1_FORK_ID); + + CircleGatewayHelper.TransferSpec memory spec = + _spec(address(adapter), address(adapter), 250e6, abi.encode(ACCOUNT)); + helper.helpMintViaAdapter(ARBITRUM_FORK_ID, address(adapter), spec); + + vm.selectFork(ARBITRUM_FORK_ID); + assertEq(adapter.lastMinted(), 250e6, "adapter measured the mint"); + assertEq(keccak256(adapter.lastHookData()), keccak256(abi.encode(ACCOUNT)), "hookData reached the adapter"); + assertEq(IERC20(ARBITRUM_USDC).balanceOf(ACCOUNT), 250e6, "forwarded to the account"); + } + + function testGatewayMintViaAdapterSet() external { + vm.selectFork(ARBITRUM_FORK_ID); + MockGatewayAdapter adapter = new MockGatewayAdapter(helper.GATEWAY_MINTER(), ARBITRUM_USDC, ACCOUNT); + vm.selectFork(L1_FORK_ID); + + CircleGatewayHelper.TransferSpec[] memory specs = new CircleGatewayHelper.TransferSpec[](2); + specs[0] = _spec(address(adapter), address(adapter), 1e6, ""); + specs[1] = _spec(address(adapter), address(adapter), 2e6, ""); + CircleGatewayHelper.Attested memory attested = + helper.helpMintViaAdapterSet(ARBITRUM_FORK_ID, address(adapter), specs); + + assertEq(bytes4(attested.payload), helper.ATTESTATION_SET_MAGIC(), "set format"); + vm.selectFork(ARBITRUM_FORK_ID); + assertEq(adapter.lastMinted(), 3e6, "both members minted into the adapter atomically"); + assertEq(IERC20(ARBITRUM_USDC).balanceOf(ACCOUNT), 3e6); + } + + function testGatewayMintSet() external { + CircleGatewayHelper.TransferSpec[] memory specs = new CircleGatewayHelper.TransferSpec[](2); + specs[0] = _spec(ACCOUNT, CALLER, 3e6, ""); + specs[1] = _spec(ACCOUNT, CALLER, 4e6, ""); + CircleGatewayHelper.Attested memory attested = helper.helpSet(ARBITRUM_FORK_ID, specs); + + vm.selectFork(ARBITRUM_FORK_ID); + assertEq(IERC20(ARBITRUM_USDC).balanceOf(ACCOUNT), 7e6, "both members minted atomically"); + IGatewayMinter minter = IGatewayMinter(helper.GATEWAY_MINTER()); + assertTrue(minter.isTransferSpecHashUsed(attested.transferSpecHashes[0])); + assertTrue(minter.isTransferSpecHashUsed(attested.transferSpecHashes[1])); + } + + /// @dev a one-member set must still be emitted as a SET (consumers test their set-parsing branch with it) + function testGatewaySetOfOneKeepsSetFormat() external { + CircleGatewayHelper.TransferSpec[] memory specs = new CircleGatewayHelper.TransferSpec[](1); + specs[0] = _spec(ACCOUNT, address(0), 1e6, ""); + CircleGatewayHelper.Attested memory attested = helper.helpSet(ARBITRUM_FORK_ID, specs); + assertEq(bytes4(attested.payload), helper.ATTESTATION_SET_MAGIC(), "set magic"); + vm.selectFork(ARBITRUM_FORK_ID); + assertEq(IERC20(ARBITRUM_USDC).balanceOf(ACCOUNT), 1e6, "the live minter accepts a set of one"); + } + + ////////////////////////////////////////////////////////////// + // THE MINTER'S OWN CHECKS RUN // + ////////////////////////////////////////////////////////////// + + /// @dev attest-only: the test drives gatewayMint itself and sees the minter's own replay protection + function testGatewayAttestOnlyReplayRejected() external { + CircleGatewayHelper.Attested memory attested = + helper.helpAttest(ARBITRUM_FORK_ID, _spec(ACCOUNT, address(0), 1e6, "")); + assertEq(vm.activeFork(), L1_FORK_ID, "attest restores the fork"); + + vm.selectFork(ARBITRUM_FORK_ID); + IGatewayMinter minter = IGatewayMinter(helper.GATEWAY_MINTER()); + minter.gatewayMint(attested.payload, attested.signature); + assertEq(IERC20(ARBITRUM_USDC).balanceOf(ACCOUNT), 1e6); + + vm.expectRevert(abi.encodeWithSignature("TransferSpecHashUsed(bytes32)", attested.transferSpecHashes[0])); + minter.gatewayMint(attested.payload, attested.signature); + } + + function testGatewayWrongSignerRejected() external { + CircleGatewayHelper.Attested memory attested = + helper.helpAttest(ARBITRUM_FORK_ID, _spec(ACCOUNT, address(0), 1e6, "")); + + vm.selectFork(ARBITRUM_FORK_ID); + bytes32 digest = keccak256(abi.encodePacked("\x19Ethereum Signed Message:\n32", keccak256(attested.payload))); + (uint8 v, bytes32 r, bytes32 s) = vm.sign(uint256(0xBAD), digest); + IGatewayMinter minter = IGatewayMinter(helper.GATEWAY_MINTER()); + vm.expectRevert(abi.encodeWithSignature("InvalidAttestationSigner()")); + minter.gatewayMint(attested.payload, abi.encodePacked(r, s, v)); + } + + /// @dev the prank in help() is what makes a pinned spec mintable: from anyone else the minter rejects it + function testGatewayPinnedCallerEnforcedByMinter() external { + CircleGatewayHelper.Attested memory attested = + helper.helpAttest(ARBITRUM_FORK_ID, _spec(ACCOUNT, CALLER, 1e6, "")); + + vm.selectFork(ARBITRUM_FORK_ID); + IGatewayMinter minter = IGatewayMinter(helper.GATEWAY_MINTER()); + vm.expectRevert( + abi.encodeWithSignature( + "InvalidAttestationDestinationCallerAtIndex(uint32,address,address)", 0, CALLER, address(this) + ) + ); + minter.gatewayMint(attested.payload, attested.signature); + } + + /// @dev DEFAULT_VALIDITY_BLOCKS is inclusive: +1000 still mints, +1001 is expired + function testGatewayExpiry() external { + vm.selectFork(ARBITRUM_FORK_ID); + uint256 issued = block.number; + vm.selectFork(L1_FORK_ID); + CircleGatewayHelper.Attested memory ok = + helper.helpAttest(ARBITRUM_FORK_ID, _spec(ACCOUNT, address(0), 1e6, "")); + CircleGatewayHelper.Attested memory stale = + helper.helpAttest(ARBITRUM_FORK_ID, _spec(ACCOUNT, address(0), 1e6, "")); + + vm.selectFork(ARBITRUM_FORK_ID); + IGatewayMinter minter = IGatewayMinter(helper.GATEWAY_MINTER()); + vm.roll(issued + helper.DEFAULT_VALIDITY_BLOCKS()); + minter.gatewayMint(ok.payload, ok.signature); + + vm.roll(issued + helper.DEFAULT_VALIDITY_BLOCKS() + 1); + vm.expectRevert( + abi.encodeWithSignature( + "AttestationExpiredAtIndex(uint32,uint256,uint256)", + 0, + issued + helper.DEFAULT_VALIDITY_BLOCKS(), + block.number + ) + ); + minter.gatewayMint(stale.payload, stale.signature); + } + + /// @dev explicit maxBlockHeight overload + function testGatewayAttestWithMaxBlockHeight() external { + vm.selectFork(ARBITRUM_FORK_ID); + uint256 target = block.number + 5; + vm.selectFork(L1_FORK_ID); + CircleGatewayHelper.Attested memory attested = + helper.helpAttest(ARBITRUM_FORK_ID, _spec(ACCOUNT, address(0), 1e6, ""), target); + assertEq(uint256(bytes32(_slice(attested.payload, 4, 36))), target, "maxBlockHeight at offset 4"); + } + + /// @dev the minter rejects a set whose members disagree on destinationCaller + function testGatewayMixedCallerSetRejectedByMinter() external { + CircleGatewayHelper.TransferSpec[] memory specs = new CircleGatewayHelper.TransferSpec[](2); + specs[0] = _spec(ACCOUNT, CALLER, 1e6, ""); + specs[1] = _spec(ACCOUNT, address(0xB0B), 1e6, ""); + vm.expectRevert( + abi.encodeWithSignature( + "InvalidAttestationDestinationCallerAtIndex(uint32,address,address)", 1, address(0xB0B), CALLER + ) + ); + helper.helpSet(ARBITRUM_FORK_ID, specs); + } + + /// @dev a reverting help() must not strand the caller on the destination fork + function testGatewayRevertRestoresFork() external { + CircleGatewayHelper.TransferSpec memory zero = _spec(ACCOUNT, address(0), 0, ""); + try helper.help(ARBITRUM_FORK_ID, zero) { + fail(); + } catch (bytes memory reason) { + assertEq(bytes4(reason), bytes4(keccak256("AttestationValueMustBePositiveAtIndex(uint32)"))); + } + assertEq(vm.activeFork(), L1_FORK_ID, "back on the source fork after a revert"); + } + + ////////////////////////////////////////////////////////////// + // ENCODING (NO RPC NEEDED) // + ////////////////////////////////////////////////////////////// + + function _fixedSpec() internal pure returns (CircleGatewayHelper.TransferSpec memory) { + return CircleGatewayHelper.TransferSpec({ + version: 1, + sourceDomain: 0, + destinationDomain: 3, + sourceContract: bytes32(uint256(uint160(0x77777777Dcc4d5A8B6E418Fd04D8997ef11000eE))), + destinationContract: bytes32(uint256(uint160(0x2222222d7164433c4C09B0b0D809a9b52C04C205))), + sourceToken: bytes32(uint256(uint160(L1_USDC))), + destinationToken: bytes32(uint256(uint160(ARBITRUM_USDC))), + sourceDepositor: bytes32(uint256(0xDE905)), + destinationRecipient: bytes32(uint256(0xCAFE)), + sourceSigner: bytes32(uint256(0xDE905)), + destinationCaller: bytes32(0), + value: 1000e6, + salt: bytes32(uint256(0x5A17)), + hookData: hex"c0ffee" + }); + } + + /// @dev byte-for-byte against circlefin's libraries (vectors generated with TransferSpecLib/AttestationLib) + function testGatewayEncodingMatchesCircleLibrary() external view { + CircleGatewayHelper.TransferSpec memory s = _fixedSpec(); + assertEq(helper.encodeTransferSpec(s), VECTOR_SPEC, "TransferSpec bytes"); + assertEq(helper.encodeTransferSpec(s).length, 340 + 3, "340-byte header + hookData"); + assertEq(helper.encodeAttestation(123_456, s), VECTOR_ATTESTATION, "Attestation bytes"); + CircleGatewayHelper.TransferSpec[] memory specs = new CircleGatewayHelper.TransferSpec[](1); + specs[0] = s; + assertEq(helper.encodeAttestationSet(123_456, specs), VECTOR_SET, "AttestationSet bytes"); + assertEq(helper.transferSpecHash(s), keccak256(VECTOR_SPEC), "replay key"); + } + + function testGatewayBuildSpecSaltsAreUnique() external { + CircleGatewayHelper.TransferSpec memory a = _spec(ACCOUNT, address(0), 1e6, ""); + CircleGatewayHelper.TransferSpec memory b = _spec(ACCOUNT, address(0), 1e6, ""); + assertTrue(a.salt != b.salt, "salts differ"); + assertTrue(helper.transferSpecHash(a) != helper.transferSpecHash(b), "hashes differ"); + } + + function _slice(bytes memory data, uint256 start, uint256 end) internal pure returns (bytes memory out) { + out = new bytes(end - start); + for (uint256 i; i < out.length; ++i) { + out[i] = data[start + i]; + } + } +} + +/// @dev adapter stand-in whose receiveAndExecute can be made to revert with a custom error +contract RevertingGatewayAdapter { + error AdapterBoom(uint256 code); + + function receiveAndExecute(bytes calldata, bytes calldata) external pure { + revert AdapterBoom(42); + } +} + +/// @dev live-minter admin surface used only by the edge-case tests +interface IGatewayMinterAdmin { + function denylister() external view returns (address); + function denylist(address addr) external; + function unDenylist(address addr) external; + function pauser() external view returns (address); + function pause() external; + function unpause() external; + function isAttestationSigner(address signer) external view returns (bool); +} + +/// @title edge cases: revert paths restore state, the live minter's remaining checks, fork/persistence behaviour +contract CircleGatewayHelperEdgeCasesTest is CircleGatewayTestBase { + uint32 constant DOMAIN_BASE = 6; + address constant L1_WETH = 0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2; + + ////////////////////////////////////////////////////////////// + // REVERT PATHS RESTORE FORK + STATE // + ////////////////////////////////////////////////////////////// + + /// @dev helpDeposit on a token the wallet does not support: the wallet reverts, the prank is stopped and the + /// fork restored, and a subsequent valid deposit still works + function testGatewayDepositRevertRestoresForkAndPrank() external { + vm.selectFork(ARBITRUM_FORK_ID); + try helper.helpDeposit(L1_FORK_ID, L1_WETH, DEPOSITOR, 1 ether) { + fail(); + } catch {} + assertEq(vm.activeFork(), ARBITRUM_FORK_ID, "fork restored after a failed deposit"); + + helper.helpDeposit(L1_FORK_ID, L1_USDC, DEPOSITOR, 7e6); + assertEq(vm.activeFork(), ARBITRUM_FORK_ID, "fork restored after a successful deposit"); + vm.selectFork(L1_FORK_ID); + assertEq(IGatewayWallet(helper.GATEWAY_WALLET()).availableBalance(L1_USDC, DEPOSITOR), 7e6); + } + + /// @dev a reverting adapter: the exact custom error is re-raised and the fork restored + function testGatewayAdapterRevertRethrowsAndRestoresFork() external { + vm.selectFork(ARBITRUM_FORK_ID); + RevertingGatewayAdapter adapter = new RevertingGatewayAdapter(); + vm.selectFork(L1_FORK_ID); + + CircleGatewayHelper.TransferSpec memory spec = _spec(address(adapter), address(adapter), 1e6, ""); + vm.expectRevert(abi.encodeWithSelector(RevertingGatewayAdapter.AdapterBoom.selector, 42)); + helper.helpMintViaAdapter(ARBITRUM_FORK_ID, address(adapter), spec); + assertEq(vm.activeFork(), L1_FORK_ID, "fork restored"); + + /// the spec was never consumed: nothing minted, hash unused + vm.selectFork(ARBITRUM_FORK_ID); + assertFalse(IGatewayMinter(helper.GATEWAY_MINTER()).isTransferSpecHashUsed(helper.transferSpecHash(spec))); + } + + /// @dev help() called while the DESTINATION fork is already active restores to it + function testGatewayHelpFromDestinationForkActive() external { + vm.selectFork(ARBITRUM_FORK_ID); + helper.help(ARBITRUM_FORK_ID, _spec(ACCOUNT, address(0), 2e6, "")); + assertEq(vm.activeFork(), ARBITRUM_FORK_ID); + assertEq(IERC20(ARBITRUM_USDC).balanceOf(ACCOUNT), 2e6); + } + + function testGatewayEmptySetReverts() external { + CircleGatewayHelper.TransferSpec[] memory none = new CircleGatewayHelper.TransferSpec[](0); + vm.expectRevert(bytes("CircleGatewayHelper: empty set")); + helper.helpSet(ARBITRUM_FORK_ID, none); + vm.expectRevert(bytes("CircleGatewayHelper: empty set")); + helper.helpAttestSet(ARBITRUM_FORK_ID, none); + assertEq(vm.activeFork(), L1_FORK_ID); + } + + ////////////////////////////////////////////////////////////// + // THE LIVE MINTER'S REMAINING CHECKS // + ////////////////////////////////////////////////////////////// + + function testGatewayWrongDestinationDomainRejected() external { + CircleGatewayHelper.TransferSpec memory spec = + helper.buildSpec(DOMAIN_ETH, DOMAIN_BASE, L1_USDC, ARBITRUM_USDC, DEPOSITOR, ACCOUNT, address(0), 1e6, ""); + vm.expectRevert( + abi.encodeWithSignature( + "InvalidAttestationDestinationDomainAtIndex(uint32,uint32,uint32)", 0, DOMAIN_BASE, DOMAIN_ARBITRUM + ) + ); + helper.help(ARBITRUM_FORK_ID, spec); + assertEq(vm.activeFork(), L1_FORK_ID); + } + + function testGatewayUnsupportedTokenRejected() external { + address notUsdc = address(0xBAD70); + CircleGatewayHelper.TransferSpec memory spec = + helper.buildSpec(DOMAIN_ETH, DOMAIN_ARBITRUM, L1_USDC, notUsdc, DEPOSITOR, ACCOUNT, address(0), 1e6, ""); + vm.expectRevert(abi.encodeWithSignature("UnsupportedTokenAtIndex(uint32,address)", 0, notUsdc)); + helper.help(ARBITRUM_FORK_ID, spec); + } + + /// @dev same-domain transfers must use the same token on both sides; equal tokens mint fine + function testGatewaySameDomainTokenRule() external { + CircleGatewayHelper.TransferSpec memory bad = helper.buildSpec( + DOMAIN_ARBITRUM, DOMAIN_ARBITRUM, L1_USDC, ARBITRUM_USDC, DEPOSITOR, ACCOUNT, address(0), 1e6, "" + ); + vm.expectRevert( + abi.encodeWithSignature("InvalidAttestationTokenAtIndex(uint32,address,address)", 0, L1_USDC, ARBITRUM_USDC) + ); + helper.help(ARBITRUM_FORK_ID, bad); + + CircleGatewayHelper.TransferSpec memory good = helper.buildSpec( + DOMAIN_ARBITRUM, DOMAIN_ARBITRUM, ARBITRUM_USDC, ARBITRUM_USDC, DEPOSITOR, ACCOUNT, address(0), 1e6, "" + ); + helper.help(ARBITRUM_FORK_ID, good); + vm.selectFork(ARBITRUM_FORK_ID); + assertEq(IERC20(ARBITRUM_USDC).balanceOf(ACCOUNT), 1e6, "same-domain mint with equal tokens"); + } + + function testGatewayDenylistedRecipientRejected() external { + IGatewayMinterAdmin admin = IGatewayMinterAdmin(helper.GATEWAY_MINTER()); + vm.selectFork(ARBITRUM_FORK_ID); + vm.prank(admin.denylister()); + admin.denylist(ACCOUNT); + vm.selectFork(L1_FORK_ID); + + CircleGatewayHelper.TransferSpec memory spec = _spec(ACCOUNT, address(0), 1e6, ""); + vm.expectRevert(abi.encodeWithSignature("AccountDenylisted(address)", ACCOUNT)); + helper.help(ARBITRUM_FORK_ID, spec); + + vm.selectFork(ARBITRUM_FORK_ID); + vm.prank(admin.denylister()); + admin.unDenylist(ACCOUNT); + vm.selectFork(L1_FORK_ID); + helper.help(ARBITRUM_FORK_ID, spec); + vm.selectFork(ARBITRUM_FORK_ID); + assertEq(IERC20(ARBITRUM_USDC).balanceOf(ACCOUNT), 1e6, "mints once un-denylisted; same attestation"); + } + + function testGatewayPausedMinterRejected() external { + IGatewayMinterAdmin admin = IGatewayMinterAdmin(helper.GATEWAY_MINTER()); + vm.selectFork(ARBITRUM_FORK_ID); + vm.prank(admin.pauser()); + admin.pause(); + vm.selectFork(L1_FORK_ID); + + CircleGatewayHelper.TransferSpec memory spec = _spec(ACCOUNT, address(0), 1e6, ""); + vm.expectRevert(abi.encodeWithSignature("EnforcedPause()")); + helper.help(ARBITRUM_FORK_ID, spec); + + vm.selectFork(ARBITRUM_FORK_ID); + vm.prank(admin.pauser()); + admin.unpause(); + vm.selectFork(L1_FORK_ID); + helper.help(ARBITRUM_FORK_ID, spec); + vm.selectFork(ARBITRUM_FORK_ID); + assertEq(IERC20(ARBITRUM_USDC).balanceOf(ACCOUNT), 1e6); + } + + /// @dev the documented footgun: the live minter mints through FiatToken's minter allowance; a string revert + /// from FiatToken is re-raised unchanged + function testGatewayMinterAllowanceFootgun() external { + CircleGatewayHelper.TransferSpec memory spec = _spec(ACCOUNT, address(0), 1e30, ""); + vm.expectRevert(bytes("FiatToken: mint amount exceeds minterAllowance")); + helper.help(ARBITRUM_FORK_ID, spec); + assertEq(vm.activeFork(), L1_FORK_ID); + } + + /// @dev expired set via the explicit maxBlockHeight overload; the same error carries the member index + function testGatewayAttestSetWithMaxBlockHeightExpired() external { + vm.selectFork(ARBITRUM_FORK_ID); + uint256 stale = block.number - 1; + vm.selectFork(L1_FORK_ID); + CircleGatewayHelper.TransferSpec[] memory specs = new CircleGatewayHelper.TransferSpec[](2); + specs[0] = _spec(ACCOUNT, address(0), 1e6, ""); + specs[1] = _spec(ACCOUNT, address(0), 1e6, ""); + CircleGatewayHelper.Attested memory attested = helper.helpAttestSet(ARBITRUM_FORK_ID, specs, stale); + assertEq(bytes4(attested.payload), helper.ATTESTATION_SET_MAGIC()); + + vm.selectFork(ARBITRUM_FORK_ID); + IGatewayMinter minter = IGatewayMinter(helper.GATEWAY_MINTER()); + vm.expectRevert( + abi.encodeWithSignature("AttestationExpiredAtIndex(uint32,uint256,uint256)", 0, stale, block.number) + ); + minter.gatewayMint(attested.payload, attested.signature); + } + + ////////////////////////////////////////////////////////////// + // DOMAIN 0, SIGNERS, HOOKDATA, PERSISTENCE // + ////////////////////////////////////////////////////////////// + + /// @dev Ethereum is Gateway domain 0: it works as a DESTINATION too (Arbitrum -> Ethereum) + function testGatewayEthereumIsDomainZeroDestination() external { + vm.selectFork(ARBITRUM_FORK_ID); + CircleGatewayHelper.TransferSpec memory spec = helper.buildSpec( + DOMAIN_ARBITRUM, DOMAIN_ETH, ARBITRUM_USDC, L1_USDC, DEPOSITOR, ACCOUNT, address(0), 9e6, "" + ); + CircleGatewayHelper.Attested memory attested = helper.help(L1_FORK_ID, spec); + assertEq(vm.activeFork(), ARBITRUM_FORK_ID, "restored to the previously active (Arbitrum) fork"); + + vm.selectFork(L1_FORK_ID); + assertEq(IERC20(L1_USDC).balanceOf(ACCOUNT), 9e6, "minted on Ethereum"); + assertTrue(IGatewayMinter(helper.GATEWAY_MINTER()).isTransferSpecHashUsed(attested.transferSpecHashes[0])); + } + + /// @dev two helpers with different keys each enroll their own signer; both mint on the same minter + function testGatewayTwoHelpersDistinctSigners() external { + CircleGatewayHelper other = new CircleGatewayHelper(0xBEEF); + assertTrue(other.testSignerAddress() != helper.testSignerAddress()); + + helper.help(ARBITRUM_FORK_ID, _spec(ACCOUNT, address(0), 1e6, "")); + other.help( + ARBITRUM_FORK_ID, + other.buildSpec( + DOMAIN_ETH, DOMAIN_ARBITRUM, L1_USDC, ARBITRUM_USDC, DEPOSITOR, ACCOUNT, address(0), 2e6, "" + ) + ); + + vm.selectFork(ARBITRUM_FORK_ID); + IGatewayMinterAdmin admin = IGatewayMinterAdmin(helper.GATEWAY_MINTER()); + assertTrue(admin.isAttestationSigner(helper.testSignerAddress())); + assertTrue(admin.isAttestationSigner(other.testSignerAddress())); + assertEq(IERC20(ARBITRUM_USDC).balanceOf(ACCOUNT), 3e6); + } + + /// @dev signAttestation recovers to the helper's signer (EIP-191 digest), independently of the minter + function testGatewaySignatureRecoversToTestSigner() external view { + bytes memory payload = helper.encodeAttestation(1, _fixedSpecLocal()); + bytes memory sig = helper.signAttestation(payload); + assertEq(sig.length, 65); + (bytes32 r, bytes32 s, uint8 v) = _split(sig); + bytes32 digest = keccak256(abi.encodePacked("\x19Ethereum Signed Message:\n32", keccak256(payload))); + assertEq(ecrecover(digest, v, r, s), helper.testSignerAddress(), "EIP-191 over keccak256(payload)"); + assertEq(helper.testSignerAddress(), vm.addr(1), "default key is 0x1"); + } + + /// @dev 4 KB of hookData round-trips through the wire format and the adapter + function testGatewayLargeHookData() external { + vm.selectFork(ARBITRUM_FORK_ID); + MockGatewayAdapter adapter = new MockGatewayAdapter(helper.GATEWAY_MINTER(), ARBITRUM_USDC, ACCOUNT); + vm.selectFork(L1_FORK_ID); + + bytes memory big = new bytes(4096); + for (uint256 i; i < big.length; ++i) { + big[i] = bytes1(uint8(i)); + } + bytes memory hookData = abi.encode(ACCOUNT, big); + CircleGatewayHelper.TransferSpec memory spec = _spec(address(adapter), address(adapter), 1e6, hookData); + assertEq(helper.encodeTransferSpec(spec).length, 340 + hookData.length); + helper.helpMintViaAdapter(ARBITRUM_FORK_ID, address(adapter), spec); + + vm.selectFork(ARBITRUM_FORK_ID); + assertEq(keccak256(adapter.lastHookData()), keccak256(hookData), "hookData intact"); + assertEq(IERC20(ARBITRUM_USDC).balanceOf(ACCOUNT), 1e6); + } + + /// @dev the helper is persistent from construction: usable on a fork it was not deployed on, before any + /// help call, and salts stay unique across forks + function testGatewayPersistentAcrossForksBeforeAnyHelp() external { + vm.selectFork(ARBITRUM_FORK_ID); + CircleGatewayHelper.TransferSpec memory a = _spec(ACCOUNT, address(0), 1e6, ""); + vm.selectFork(L1_FORK_ID); + CircleGatewayHelper.TransferSpec memory b = _spec(ACCOUNT, address(0), 1e6, ""); + assertTrue(a.salt != b.salt, "nonce shared across forks"); + assertEq(helper.transferSpecHash(a), helper.transferSpecHash(a), "pure encoders usable on any fork"); + } + + ////////////////////////////////////////////////////////////// + // HELPERS // + ////////////////////////////////////////////////////////////// + + function _fixedSpecLocal() internal pure returns (CircleGatewayHelper.TransferSpec memory) { + return CircleGatewayHelper.TransferSpec({ + version: 1, + sourceDomain: 0, + destinationDomain: 3, + sourceContract: bytes32(uint256(uint160(0x77777777Dcc4d5A8B6E418Fd04D8997ef11000eE))), + destinationContract: bytes32(uint256(uint160(0x2222222d7164433c4C09B0b0D809a9b52C04C205))), + sourceToken: bytes32(uint256(uint160(L1_USDC))), + destinationToken: bytes32(uint256(uint160(ARBITRUM_USDC))), + sourceDepositor: bytes32(uint256(0xDE905)), + destinationRecipient: bytes32(uint256(0xCAFE)), + sourceSigner: bytes32(uint256(0xDE905)), + destinationCaller: bytes32(0), + value: 1, + salt: bytes32(uint256(1)), + hookData: "" + }); + } + + function _split(bytes memory sig) internal pure returns (bytes32 r, bytes32 s, uint8 v) { + assembly { + r := mload(add(sig, 32)) + s := mload(add(sig, 64)) + v := byte(0, mload(add(sig, 96))) + } + } +}