From bff44ea5a45ea75c1cf36b756a4e7c4fb42e4f2a Mon Sep 17 00:00:00 2001 From: undefined Date: Mon, 19 Oct 2026 03:42:37 +0000 Subject: [PATCH 01/21] =?UTF-8?q?=F0=9F=94=84=20created=20local=20'src/'?= =?UTF-8?q?=20from=20remote=20'src/'?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/.gitkeep | 0 src/access/WithFeeCollectionAddress.sol | 58 ++ src/access/WithTrustedQuoteSigners.sol | 58 ++ src/constants/EIP712TypeHashes.sol | 39 ++ src/constants/QuoteTypes.sol | 29 + src/constants/Roles.sol | 45 ++ src/contracts/Create3Factory.sol | 114 ++++ src/contracts/FeeManager.sol | 494 +++++++++++++++++ src/contracts/TokenMessengerWithFees.sol | 508 ++++++++++++++++++ src/interfaces/IFeeManager.sol | 297 ++++++++++ src/interfaces/IFeeTaker.sol | 43 ++ src/interfaces/ITokenMessengerWithFees.sol | 175 ++++++ src/interfaces/IUSDCBlacklist.sol | 27 + src/interfaces/IWithFeeCollectionAddress.sol | 55 ++ src/interfaces/IWithTrustedQuoteSigners.sol | 42 ++ src/interfaces/README.md | 64 +++ src/interfaces/cctp/IMessageTransmitterV2.sol | 27 + src/interfaces/cctp/ITokenMessengerV2.sol | 59 ++ src/libraries/AddressUtils.sol | 28 + src/libraries/BurnMessageV2Lib.sol | 70 +++ src/libraries/HookDataLib.sol | 96 ++++ src/libraries/MessageV2Lib.sol | 94 ++++ src/libraries/QuoteLib.sol | 208 +++++++ src/libraries/SignedQuoteLib.sol | 87 +++ .../WithFeeCollectionAddressStorage.sol | 38 ++ .../WithTrustedQuoteSignersStorage.sol | 38 ++ 26 files changed, 2793 insertions(+) create mode 100644 src/.gitkeep create mode 100644 src/access/WithFeeCollectionAddress.sol create mode 100644 src/access/WithTrustedQuoteSigners.sol create mode 100644 src/constants/EIP712TypeHashes.sol create mode 100644 src/constants/QuoteTypes.sol create mode 100644 src/constants/Roles.sol create mode 100644 src/contracts/Create3Factory.sol create mode 100644 src/contracts/FeeManager.sol create mode 100644 src/contracts/TokenMessengerWithFees.sol create mode 100644 src/interfaces/IFeeManager.sol create mode 100644 src/interfaces/IFeeTaker.sol create mode 100644 src/interfaces/ITokenMessengerWithFees.sol create mode 100644 src/interfaces/IUSDCBlacklist.sol create mode 100644 src/interfaces/IWithFeeCollectionAddress.sol create mode 100644 src/interfaces/IWithTrustedQuoteSigners.sol create mode 100644 src/interfaces/README.md create mode 100644 src/interfaces/cctp/IMessageTransmitterV2.sol create mode 100644 src/interfaces/cctp/ITokenMessengerV2.sol create mode 100644 src/libraries/AddressUtils.sol create mode 100644 src/libraries/BurnMessageV2Lib.sol create mode 100644 src/libraries/HookDataLib.sol create mode 100644 src/libraries/MessageV2Lib.sol create mode 100644 src/libraries/QuoteLib.sol create mode 100644 src/libraries/SignedQuoteLib.sol create mode 100644 src/storage/WithFeeCollectionAddressStorage.sol create mode 100644 src/storage/WithTrustedQuoteSignersStorage.sol diff --git a/src/.gitkeep b/src/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/src/access/WithFeeCollectionAddress.sol b/src/access/WithFeeCollectionAddress.sol new file mode 100644 index 0000000..88a4ee3 --- /dev/null +++ b/src/access/WithFeeCollectionAddress.sol @@ -0,0 +1,58 @@ +/** + * Copyright 2026 Circle Internet Group, Inc. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + */ +pragma solidity ^0.8.20; + +import {Roles} from "../constants/Roles.sol"; +import {IWithFeeCollectionAddress} from "../interfaces/IWithFeeCollectionAddress.sol"; +import {WithFeeCollectionAddressStorage} from "../storage/WithFeeCollectionAddressStorage.sol"; +import {AccessControlUpgradeable} from "@openzeppelin/contracts-upgradeable/access/AccessControlUpgradeable.sol"; + +/// @title WithFeeCollectionAddress +/// @notice Abstract contract that exposes a role-gated, optional override of +/// the address forwarded to `IFeeTaker` on `takeFee*` calls (the +/// recipient of the fee payout). Access is restricted to holders of +/// `FEE_COLLECTION_ADMIN_ROLE`. State lives in EIP-7201 namespaced +/// storage (WithFeeCollectionAddressStorage). +/// @dev Setting the override to `address(0)` clears it; consumers must +/// then fall back to the prior default +/// (`IFeeTaker.protocolFeeAddress()`). Resolution of the effective +/// address is left to the consuming contract. +abstract contract WithFeeCollectionAddress is IWithFeeCollectionAddress, AccessControlUpgradeable { + /// @inheritdoc IWithFeeCollectionAddress + /// @dev No-op (no SSTORE, no event) when the supplied value already matches the stored + /// override. `FeeCollectionAddressSet` is only emitted on an actual state change so + /// indexers see one event per distinct configuration. + function setFeeCollectionAddress(address payable newFeeCollectionAddress) + external + onlyRole(Roles.FEE_COLLECTION_ADMIN_ROLE) + { + WithFeeCollectionAddressStorage.Layout storage $ = WithFeeCollectionAddressStorage.layout(); + if ($.feeCollectionAddress == newFeeCollectionAddress) return; + $.feeCollectionAddress = newFeeCollectionAddress; + emit FeeCollectionAddressSet(newFeeCollectionAddress); + } + + /// @inheritdoc IWithFeeCollectionAddress + function feeCollectionAddress() external view returns (address payable) { + return _feeCollectionAddress(); + } + + function _feeCollectionAddress() internal view returns (address payable) { + return WithFeeCollectionAddressStorage.layout().feeCollectionAddress; + } +} diff --git a/src/access/WithTrustedQuoteSigners.sol b/src/access/WithTrustedQuoteSigners.sol new file mode 100644 index 0000000..dd09452 --- /dev/null +++ b/src/access/WithTrustedQuoteSigners.sol @@ -0,0 +1,58 @@ +/** + * Copyright 2026 Circle Internet Group, Inc. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + */ +pragma solidity ^0.8.20; + +import {Roles} from "../constants/Roles.sol"; +import {IWithTrustedQuoteSigners} from "../interfaces/IWithTrustedQuoteSigners.sol"; +import {WithTrustedQuoteSignersStorage} from "../storage/WithTrustedQuoteSignersStorage.sol"; +import {AccessControlUpgradeable} from "@openzeppelin/contracts-upgradeable/access/AccessControlUpgradeable.sol"; + +/// @title WithTrustedQuoteSigners +/// @notice Abstract contract that provides a role-gated set of trusted quote +/// signers for EIP-712 signature verification. Access is restricted to +/// holders of `SIGNER_ADMIN_ROLE`. State lives in EIP-7201 namespaced +/// storage (WithTrustedQuoteSignersStorage). +abstract contract WithTrustedQuoteSigners is IWithTrustedQuoteSigners, AccessControlUpgradeable { + /// @inheritdoc IWithTrustedQuoteSigners + function setTrustedQuoteSigner(address signer, bool trusted) external onlyRole(Roles.SIGNER_ADMIN_ROLE) { + if (signer == address(0)) revert ZeroTrustedQuoteSignerAddress(); + + WithTrustedQuoteSignersStorage.Layout storage $ = WithTrustedQuoteSignersStorage.layout(); + bool wasTrusted = $.trustedQuoteSigner[signer]; + if (trusted == wasTrusted) { + emit TrustedQuoteSignerSet(signer, trusted); + return; + } + + if (trusted) { + $.trustedQuoteSigner[signer] = true; + } else { + delete $.trustedQuoteSigner[signer]; + } + emit TrustedQuoteSignerSet(signer, trusted); + } + + /// @inheritdoc IWithTrustedQuoteSigners + function isTrustedQuoteSigner(address signer) external view returns (bool) { + return _isTrustedQuoteSigner(signer); + } + + function _isTrustedQuoteSigner(address signer) internal view returns (bool) { + return WithTrustedQuoteSignersStorage.layout().trustedQuoteSigner[signer]; + } +} diff --git a/src/constants/EIP712TypeHashes.sol b/src/constants/EIP712TypeHashes.sol new file mode 100644 index 0000000..c3aed09 --- /dev/null +++ b/src/constants/EIP712TypeHashes.sol @@ -0,0 +1,39 @@ +/** + * Copyright 2026 Circle Internet Group, Inc. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + */ +pragma solidity ^0.8.20; + +/// @title EIP712TypeHashes +/// @notice Precomputed EIP-712 type hashes shared between FeeManager and its tests. +/// Schemas must match the off-chain signer. +/// @dev Versioning policy: the format version byte in the `signedQuote` blob is +/// NOT included in the EIP-712 struct hash. Each format version defines its +/// own EIP-712 struct (and therefore its own QUOTE_TYPEHASH). A signature +/// produced for one struct can never verify against a different struct's type +/// hash, so the EIP-712 type system already provides cryptographic version +/// isolation. Adding the version to the signed data would redundantly couple +/// the wire format to the signature scheme with no security benefit. +library EIP712TypeHashes { + /// keccak256("EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)") + bytes32 internal constant DOMAIN_TYPEHASH = 0x8b73c3c69bb8fe3d512ecc4cf759cc79239f7b179b0ffacaa9a75d522b39400f; + + /// keccak256("QuoteItem(string quoteType,bytes32 quoteArgsHash,uint256 amount)") + bytes32 internal constant QUOTE_ITEM_TYPEHASH = 0x3d91a2ce6874a25e20e991b5af318af423de58a8ac0ebfeeea4193d329f52c65; + + /// keccak256("Quote(QuoteItem[] items,uint256 expiry,address feeToken,uint256 nonce)QuoteItem(string quoteType,bytes32 quoteArgsHash,uint256 amount)") + bytes32 internal constant QUOTE_TYPEHASH = 0x2f6d3eeb82ea681c037fd4bcc09c4fce0f959e97f3b22728f3ccc009e3375d42; +} diff --git a/src/constants/QuoteTypes.sol b/src/constants/QuoteTypes.sol new file mode 100644 index 0000000..6ac2509 --- /dev/null +++ b/src/constants/QuoteTypes.sol @@ -0,0 +1,29 @@ +/** + * Copyright 2026 Circle Internet Group, Inc. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + */ +pragma solidity ^0.8.20; + +/// @title QuoteTypes +/// @notice Quote-type identifiers defined and consumed by contracts in this repository. +/// @dev This is not a global registry of every quote type accepted by FeeManager or +/// external integrators. FeeManager matching is request-driven, and other +/// integrations may define their own quote-type identifiers alongside their +/// own contracts. +library QuoteTypes { + string internal constant FORWARD = "FORWARD"; + string internal constant PRE_FINALITY = "PRE_FINALITY"; +} diff --git a/src/constants/Roles.sol b/src/constants/Roles.sol new file mode 100644 index 0000000..00d9bd7 --- /dev/null +++ b/src/constants/Roles.sol @@ -0,0 +1,45 @@ +/** + * Copyright 2026 Circle Internet Group, Inc. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + */ +pragma solidity ^0.8.20; + +/// @title Roles +/// @notice AccessControl role identifiers shared across FeeManager, deploy +/// scripts, and tests. Keeping them in one place prevents hash drift +/// and makes audits easier. +library Roles { + /// @dev Granted to addresses that may call `collectFeesWithQuote`. + /// Admin: CALLER_ADMIN_ROLE. + bytes32 internal constant ALLOWED_CALLER_ROLE = keccak256("ALLOWED_CALLER_ROLE"); + + /// @dev Admin role for ALLOWED_CALLER_ROLE. Holders can grant/revoke + /// ALLOWED_CALLER_ROLE. Admin: DEFAULT_ADMIN_ROLE. + bytes32 internal constant CALLER_ADMIN_ROLE = keccak256("CALLER_ADMIN_ROLE"); + + /// @dev Holders can add/remove trusted quote signers via + /// `setTrustedQuoteSigner`. Admin: DEFAULT_ADMIN_ROLE. + bytes32 internal constant SIGNER_ADMIN_ROLE = keccak256("SIGNER_ADMIN_ROLE"); + + /// @dev Holders can pause/unpause `collectFeesWithQuote`. + /// Admin: DEFAULT_ADMIN_ROLE. + bytes32 internal constant PAUSER_ROLE = keccak256("PAUSER_ROLE"); + + /// @dev Holders can set the fee collection address that overrides the + /// default `IFeeTaker.protocolFeeAddress()` recipient via + /// `setFeeCollectionAddress`. Admin: DEFAULT_ADMIN_ROLE. + bytes32 internal constant FEE_COLLECTION_ADMIN_ROLE = keccak256("FEE_COLLECTION_ADMIN_ROLE"); +} diff --git a/src/contracts/Create3Factory.sol b/src/contracts/Create3Factory.sol new file mode 100644 index 0000000..fd11526 --- /dev/null +++ b/src/contracts/Create3Factory.sol @@ -0,0 +1,114 @@ +/** + * Copyright 2026 Circle Internet Group, Inc. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + */ +pragma solidity ^0.8.20; + +import {Ownable, Ownable2Step} from "@openzeppelin/contracts/access/Ownable2Step.sol"; +// solhint-disable-next-line import-path-check +import {CREATE3} from "solady/utils/CREATE3.sol"; + +/// @title Create3Factory +/// @notice Deterministic deployment factory using CREATE3. Ensures identical +/// proxy and implementation addresses across all chains when deployed +/// from the same factory address with the same salts, regardless of +/// the creation bytecode. +/// @dev Unlike CREATE2, CREATE3 derives the deployed address solely from +/// `(factory_address, salt)`, making it immune to bytecode differences. +/// This means constructor arguments, compiler settings, or minor code +/// changes do not affect the deployed address. +/// The factory address itself still depends on how this factory is +/// deployed. With `script/deploy/DeployCreate3Factory.s.sol`'s plain +/// CREATE path, identical factory addresses require the same deployer +/// address and deployment nonce/transaction sequence on every chain; that +/// script passes the deployer as `initialOwner`. If a deterministic +/// factory-deploy path is used instead, constructor-encoded `initialOwner` +/// can also participate in address derivation via the creation payload. +/// See `README.md#cross-chain-address-determinism` and +/// `README.md#deployment` for operator guidance. +contract Create3Factory is Ownable2Step { + /// @notice Thrown when `renounceOwnership()` is called. + error RenounceDisabled(); + + /// @notice Emitted when a contract is deployed via the factory. + /// @param deployed The address of the deployed contract. + /// @param salt The salt used for the deployment. + event Deployed(address indexed deployed, bytes32 indexed salt); + + /// @param initialOwner Address that will become the factory owner. The repo + /// deploy script sets this to the broadcaster/deployer; cross-chain + /// factory address parity requires keeping this value aligned with the + /// deployer/nonce requirements described above. + constructor(address initialOwner) Ownable(initialOwner) {} + + /// @notice Deploys a contract using CREATE3. + /// @param salt Salt for deterministic address derivation. + /// @param bytecode Creation bytecode (optionally including appended constructor args). + /// @return addr The address of the newly deployed contract. + function deploy(bytes32 salt, bytes calldata bytecode) external onlyOwner returns (address addr) { + addr = CREATE3.deployDeterministic(bytecode, salt); + emit Deployed(addr, salt); + } + + /// @notice Deploys a contract using CREATE3 and immediately executes a + /// batch of calls on it in the same transaction. Useful for + /// deploying a proxy and configuring roles atomically. + /// @dev The factory temporarily owns the deployed contract for the + /// duration of the multicall (e.g. proxy initialized with + /// `address(factory)` as owner). Callers must ensure the deployed + /// contract's initializer does not make arbitrary external callbacks. + /// No upper bound is enforced on `calls.length` because this function + /// is restricted to `onlyOwner` and bounded by the block gas limit. + /// @param salt Salt for deterministic address derivation. + /// @param bytecode Creation bytecode (optionally including appended constructor args). + /// @param calls ABI-encoded function calls to invoke on the deployed contract. + /// @return addr The address of the newly deployed contract. + function deployAndMultiCall(bytes32 salt, bytes calldata bytecode, bytes[] calldata calls) + external + onlyOwner + returns (address addr) + { + addr = CREATE3.deployDeterministic(bytecode, salt); + emit Deployed(addr, salt); + + uint256 len = calls.length; + for (uint256 i = 0; i < len; ++i) { + // slither-disable-next-line low-level-calls + // solhint-disable-next-line avoid-low-level-calls + (bool ok, bytes memory ret) = addr.call(calls[i]); + if (!ok) { + // slither-disable-next-line assembly + assembly { + revert(add(ret, 0x20), mload(ret)) + } + } + } + } + + /// @notice Disabled — renouncing ownership would permanently brick the factory. + function renounceOwnership() public pure override { + revert RenounceDisabled(); + } + + /// @notice Computes the deterministic address for a given salt + /// without deploying. The address depends only on the factory + /// address and salt, not on the bytecode. + /// @param salt Salt for deterministic address derivation. + /// @return The address where the contract would be deployed. + function getAddress(bytes32 salt) external view returns (address) { + return CREATE3.predictDeterministicAddress(salt); + } +} diff --git a/src/contracts/FeeManager.sol b/src/contracts/FeeManager.sol new file mode 100644 index 0000000..02dd2c3 --- /dev/null +++ b/src/contracts/FeeManager.sol @@ -0,0 +1,494 @@ +/** + * Copyright 2026 Circle Internet Group, Inc. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + */ +pragma solidity ^0.8.20; + +import {WithFeeCollectionAddress} from "../access/WithFeeCollectionAddress.sol"; +import {WithTrustedQuoteSigners} from "../access/WithTrustedQuoteSigners.sol"; +import {EIP712TypeHashes} from "../constants/EIP712TypeHashes.sol"; +import {Roles} from "../constants/Roles.sol"; +import {IFeeManager} from "../interfaces/IFeeManager.sol"; +import {IFeeTaker} from "../interfaces/IFeeTaker.sol"; +import {IUSDCBlacklist} from "../interfaces/IUSDCBlacklist.sol"; +import {QuoteLib} from "../libraries/QuoteLib.sol"; +import {SignedQuoteLib} from "../libraries/SignedQuoteLib.sol"; +import {AccessControlUpgradeable} from "@openzeppelin/contracts-upgradeable/access/AccessControlUpgradeable.sol"; +import {Ownable2StepUpgradeable} from "@openzeppelin/contracts-upgradeable/access/Ownable2StepUpgradeable.sol"; +import {UUPSUpgradeable} from "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol"; +import {PausableUpgradeable} from "@openzeppelin/contracts-upgradeable/utils/PausableUpgradeable.sol"; +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; +import {ReentrancyGuardTransient} from "@openzeppelin/contracts/utils/ReentrancyGuardTransient.sol"; +import {ECDSA} from "@openzeppelin/contracts/utils/cryptography/ECDSA.sol"; + +/// @title FeeManager +/// @notice Collects fees against server-signed quotes on behalf of callers. +/// @dev UUPS upgradeable: implementation uses `_disableInitializers()`; the proxy must call +/// `initialize(owner_)` once. Access control is managed via OpenZeppelin's +/// AccessControlUpgradeable with dedicated roles (`CALLER_ADMIN_ROLE`, +/// `SIGNER_ADMIN_ROLE`, `PAUSER_ROLE`, `ALLOWED_CALLER_ROLE`, +/// `FEE_COLLECTION_ADMIN_ROLE`). The trusted signer set uses EIP-7201 +/// namespaced storage (WithTrustedQuoteSignersStorage); the optional fee +/// collection address override (forwarded to `IFeeTaker.takeFee*`) uses +/// a separate EIP-7201 namespace (WithFeeCollectionAddressStorage) and +/// is managed by `FEE_COLLECTION_ADMIN_ROLE`. +/// The owner holds DEFAULT_ADMIN_ROLE and authorizes upgrades. +/// No receive() or fallback() by design — ETH only enters via +/// collectFeesWithQuote{value: ...}() and is forwarded immediately +/// to FeeTaker; FeeManager never holds funds. Direct transfers revert. +/// +/// **Signed quotes:** The `collectFeesWithQuote` overload that takes `bytes signedQuote` decodes in-process +/// via `SignedQuoteLib` (same semantics as external `decodeQuote`). See `SignedQuoteLib` and `IFeeManager` +/// NatSpec for custom errors vs Solidity **Panic(0x21)** when ABI-decoding malformed tails. +/// +/// Do not cache a full `_DOMAIN_SEPARATOR` immutable for this UUPS implementation: +/// the constructor runs on the implementation contract, but signatures must bind +/// `verifyingContract` to the proxy address observed through `delegatecall`. +/// @custom:oz-upgrades +contract FeeManager is + IFeeManager, + WithTrustedQuoteSigners, + WithFeeCollectionAddress, + Ownable2StepUpgradeable, + PausableUpgradeable, + ReentrancyGuardTransient, + UUPSUpgradeable +{ + using SafeERC20 for IERC20; + using QuoteLib for IFeeManager.Quote; + using QuoteLib for IFeeManager.QuoteItem; + + // ───────────────────── Errors ───────────────────── + + error RenounceDisabled(); + /// @dev Public `grantRole` / `revokeRole` cannot change `DEFAULT_ADMIN_ROLE`; it follows ownership via `_transferOwnership`. + error DefaultAdminRoleChangeDisabled(); + /// @dev OpenZeppelin `AccessControl` allows `address(0)`; we reject it on `grantRole` / `revokeRole`. + error ZeroRoleAccount(); + error ZeroFeeTakerAddress(); + error ZeroUsdcBlacklistAddress(); + error InvalidQuoteSigner(address recovered); + error IncorrectNativePayment(uint256 required, uint256 provided); + + // ───────────────────── Constants ───────────────────── + + /// @dev Placeholder kitType passed to FeeTaker. FeeManager is not a kit, + /// so a placeholder value is used per the design spec. + // forge-lint: disable-next-line(unsafe-typecast) + bytes8 private constant FEE_TAKER_KIT_TYPE = bytes8("_"); + + /// @dev keccak256(bytes("FeeManager")). + bytes32 internal constant _HASHED_NAME = 0x42e3570c507db8e472a4592e53f4b6df78eb7c8a8d593e718bb47b707f2c6a90; + + /// @dev keccak256(bytes("1")). + bytes32 internal constant _HASHED_VERSION = 0xc89efdaa54c0f20c7adf612882df0950f5a951637e0307cdcb4c672f298b8bc6; + + // ───────────────────── Immutables ───────────────────── + + /// @custom:oz-upgrades-unsafe-allow state-variable-immutable + // slither-disable-next-line naming-convention + IFeeTaker private immutable _FEE_TAKER; + + /// @custom:oz-upgrades-unsafe-allow state-variable-immutable + // slither-disable-next-line naming-convention + IUSDCBlacklist private immutable _USDC_BLACKLIST; + + /// @dev Constructor that sets immutable FeeTaker and USDC blacklist + /// addresses and disables initializers on the implementation contract. + /// @param feeTaker_ Address of the deployed FeeTaker proxy contract. + /// @param usdcBlacklist_ The USDC contract that exposes `isBlacklisted`; must be the same + /// deployment whose blacklist `FeeTaker` uses for screening on this chain. + /// @custom:oz-upgrades-unsafe-allow constructor + constructor(address feeTaker_, address usdcBlacklist_) { + if (feeTaker_ == address(0)) revert ZeroFeeTakerAddress(); + if (usdcBlacklist_ == address(0)) revert ZeroUsdcBlacklistAddress(); + _FEE_TAKER = IFeeTaker(feeTaker_); + _USDC_BLACKLIST = IUSDCBlacklist(usdcBlacklist_); + _disableInitializers(); + } + + /// @notice Initializes the FeeManager proxy. + /// @dev Must be called once via the proxy after deployment. Sets owner to + /// `owner_` and grants DEFAULT_ADMIN_ROLE. Sets CALLER_ADMIN_ROLE as + /// the role admin for ALLOWED_CALLER_ROLE. + /// @param owner_ Address to receive ownership and DEFAULT_ADMIN_ROLE. + function initialize(address owner_) external initializer onlyProxy { + __Ownable_init(owner_); + __Ownable2Step_init(); + __AccessControl_init(); + __Pausable_init(); + + _grantRole(DEFAULT_ADMIN_ROLE, owner_); + _setRoleAdmin(Roles.ALLOWED_CALLER_ROLE, Roles.CALLER_ADMIN_ROLE); + } + + /// @dev Syncs DEFAULT_ADMIN_ROLE with ownership on every transfer. + function _transferOwnership(address newOwner) internal override { + address oldOwner = owner(); + super._transferOwnership(newOwner); + _revokeRole(DEFAULT_ADMIN_ROLE, oldOwner); + _grantRole(DEFAULT_ADMIN_ROLE, newOwner); + } + + /// @notice Disabled — renouncing ownership would permanently brick the proxy. + function renounceOwnership() public pure override { + revert RenounceDisabled(); + } + + /// @dev Prevents renouncing DEFAULT_ADMIN_ROLE, which would desync from ownership. + function renounceRole(bytes32 role, address callerConfirmation) public override { + if (role == DEFAULT_ADMIN_ROLE) revert RenounceDisabled(); + super.renounceRole(role, callerConfirmation); + } + + /// @inheritdoc AccessControlUpgradeable + function grantRole(bytes32 role, address account) public override(AccessControlUpgradeable) { + if (role == DEFAULT_ADMIN_ROLE) revert DefaultAdminRoleChangeDisabled(); + if (account == address(0)) revert ZeroRoleAccount(); + super.grantRole(role, account); + } + + /// @inheritdoc AccessControlUpgradeable + function revokeRole(bytes32 role, address account) public override(AccessControlUpgradeable) { + if (role == DEFAULT_ADMIN_ROLE) revert DefaultAdminRoleChangeDisabled(); + if (account == address(0)) revert ZeroRoleAccount(); + super.revokeRole(role, account); + } + + /// @inheritdoc UUPSUpgradeable + // solhint-disable-next-line no-empty-blocks + function _authorizeUpgrade(address) internal override onlyOwner {} + + // ───────────────────── Pause ───────────────────── + + /// @notice Halts all `collectFeesWithQuote` calls. + function pause() external onlyRole(Roles.PAUSER_ROLE) { + _pause(); + } + + /// @notice Resumes `collectFeesWithQuote` calls. + function unpause() external onlyRole(Roles.PAUSER_ROLE) { + _unpause(); + } + + // ───────────────────── External functions ───────────────────── + + /// @inheritdoc IFeeManager + function collectFeesWithQuote( + bytes32 correlationHash, + FeeWithQuoteRequest[] calldata requests, + QuoteClaim calldata claim + ) + external + payable + override + onlyRole(Roles.ALLOWED_CALLER_ROLE) + nonReentrant + whenNotPaused + returns (uint256 amount) + { + _screenRefundAddress(claim.refundAddress); + if (requests.length == 0) { + if (msg.value != 0) revert IncorrectNativePayment(0, msg.value); + return 0; + } + + return _collectFeesWithQuoteDecoded(correlationHash, requests, SignedQuoteLib.decodeClaim(claim)); + } + + /// @inheritdoc IFeeManager + function collectFeesWithQuote( + bytes32 correlationHash, + FeeWithQuoteRequest[] calldata requests, + DecodedQuoteClaim calldata claim + ) + external + payable + override + onlyRole(Roles.ALLOWED_CALLER_ROLE) + nonReentrant + whenNotPaused + returns (uint256 amount) + { + _screenRefundAddress(claim.refundAddress); + if (requests.length == 0) { + if (msg.value != 0) revert IncorrectNativePayment(0, msg.value); + return 0; + } + + // Implicit calldata→memory copy of the full claim (Quote body + refundAddress) at the call boundary. + return _collectFeesWithQuoteDecoded(correlationHash, requests, claim); + } + + /// @dev `address(0)` is accepted (no refund target) and bypasses the screen. Non-zero + /// addresses are checked against the configured USDC blacklist; the call reverts if listed. + function _screenRefundAddress(address payable refundAddress) private view { + if (refundAddress != address(0) && _USDC_BLACKLIST.isBlacklisted(refundAddress)) { + revert DenylistedRefundAddress(refundAddress); + } + } + + /// @dev Shared body after the `DecodedQuoteClaim` (Quote body + refundAddress) is in memory, + /// whether constructed from a wire decode or copied from calldata. + function _collectFeesWithQuoteDecoded( + bytes32 correlationHash, + FeeWithQuoteRequest[] calldata requests, + DecodedQuoteClaim memory claim + ) private returns (uint256 amount) { + claim.decodedQuote.requireSupportedItemCount(); + claim.decodedQuote.mustNotExpire(); + claim.decodedQuote.requireZeroNonce(); + + _verifySignedQuote(claim.decodedQuote); + + // Resolve the recipient once so `_collectMatchedItems` event attribution + // matches the address that `_forwardFees` pays for the computed total. + address payable feeRecipient = _resolveFeeCollectionAddress(); + uint256 totalCollected = _collectMatchedItems(claim, correlationHash, requests, feeRecipient); + + if (totalCollected > 0) { + _forwardFees(claim.decodedQuote.feeToken, totalCollected, feeRecipient); + } else if (msg.value != 0) { + revert IncorrectNativePayment(0, msg.value); + } + return totalCollected; + } + + /// @inheritdoc IFeeManager + function decodeQuote(bytes calldata signedQuote) external pure override returns (Quote memory) { + return SignedQuoteLib.decodeQuote(signedQuote); + } + + /// @inheritdoc IFeeManager + function getQuoteItem(bytes calldata signedQuote, string calldata quoteType) + external + pure + override + returns (QuoteItem memory item, bool found) + { + if (signedQuote.length == 0) { + return (item, false); + } + Quote memory quote = SignedQuoteLib.decodeQuote(signedQuote); + uint256 itemIndex; + (item, itemIndex, found) = quote.findItemByType(quoteType); + assert(!found || itemIndex < quote.items.length); + return (item, found); + } + + /// @inheritdoc IFeeManager + function getQuoteItem(Quote calldata decodedQuote, string calldata quoteType) + external + pure + override + returns (QuoteItem memory item, bool found) + { + Quote memory quote = decodedQuote; + uint256 itemIndex; + (item, itemIndex, found) = quote.findItemByType(quoteType); + assert(!found || itemIndex < quote.items.length); + return (item, found); + } + + /// @inheritdoc IFeeManager + function getQuoteTotal(bytes calldata signedQuote) + external + pure + override + returns (uint256 totalFee, address feeToken) + { + if (signedQuote.length == 0) { + return (0, address(0)); + } + Quote memory quote = SignedQuoteLib.decodeQuote(signedQuote); + (uint256 sum, address token) = quote.totalFeeAndFeeToken(); + return (sum, token); + } + + /// @inheritdoc IFeeManager + function getQuoteTotal(Quote calldata decodedQuote) + external + pure + override + returns (uint256 totalFee, address feeToken) + { + Quote memory quote = decodedQuote; + (uint256 sum, address token) = quote.totalFeeAndFeeToken(); + return (sum, token); + } + + /// @inheritdoc IFeeManager + function sumFees(bytes calldata signedQuote, string[] calldata quoteTypes) + external + pure + override + returns (uint256 totalFee, address feeToken) + { + if (signedQuote.length == 0) { + return (0, address(0)); + } + Quote memory quote = SignedQuoteLib.decodeQuote(signedQuote); + (totalFee, feeToken) = quote.sumFees(quoteTypes); + } + + /// @inheritdoc IFeeManager + function sumFees(Quote calldata decodedQuote, string[] calldata quoteTypes) + external + pure + override + returns (uint256 totalFee, address feeToken) + { + Quote memory quote = decodedQuote; + (totalFee, feeToken) = quote.sumFees(quoteTypes); + } + + /// @notice Returns the immutable FeeTaker proxy address. + function feeTaker() public view returns (address) { + return address(_FEE_TAKER); + } + + /// @notice USDC contract used for `isDenylisted` (not part of `IFeeManager`). + function usdcBlacklist() external view returns (address) { + return address(_USDC_BLACKLIST); + } + + /// @inheritdoc IFeeManager + function isDenylisted(address account) external view override returns (bool) { + return _USDC_BLACKLIST.isBlacklisted(account); + } + + /// @dev Forwards collected fees to FeeTaker. For native currency, forwards + /// msg.value via takeFeeNative. For ERC-20, pulls tokens from + /// msg.sender, approves FeeTaker, then calls takeFeeERC20. + function _forwardFees(address feeToken, uint256 amount, address payable feeRecipient) private { + assert(feeRecipient != address(0)); + if (feeToken == address(0)) { + if (msg.value != amount) revert IncorrectNativePayment(amount, msg.value); + _FEE_TAKER.takeFeeNative{value: amount}(feeRecipient, FEE_TAKER_KIT_TYPE); + } else { + if (msg.value != 0) revert IncorrectNativePayment(0, msg.value); + IERC20(feeToken).safeTransferFrom(msg.sender, address(this), amount); + IERC20(feeToken).forceApprove(address(_FEE_TAKER), amount); + _FEE_TAKER.takeFeeERC20(feeToken, feeRecipient, amount, FEE_TAKER_KIT_TYPE); + } + } + + /// @dev Returns the effective fee collection address passed as beneficiary + /// to FeeTaker: the override set via `setFeeCollectionAddress` when + /// non-zero, or `IFeeTaker.protocolFeeAddress()` (default) when no + /// override is set. Returned as `address payable` because + /// `IFeeTaker.takeFeeNative` forwards native currency to it. + function _resolveFeeCollectionAddress() private view returns (address payable) { + address payable overrideAddr = _feeCollectionAddress(); + if (overrideAddr != address(0)) { + return overrideAddr; + } + return payable(_FEE_TAKER.protocolFeeAddress()); + } + + /// @dev Single fused loop over `requests`. For each request: + /// - `QuoteLib.findItemForRequest` performs the per-request quote-structure + /// validation (locate by `quoteType`, assert `quoteArgsHash`, enforce + /// `required`); missing optional requests are skipped. + /// - Per-call duplicate detection (bitmap), fee accumulation, and + /// `FeeCollected` event emission are handled here. + function _collectMatchedItems( + DecodedQuoteClaim memory claim, + bytes32 correlationHash, + FeeWithQuoteRequest[] calldata requests, + address payable feeRecipient + ) private returns (uint256 totalCollected) { + uint256 consumedInTx = 0; + for (uint256 i = 0; i < requests.length; ++i) { + (QuoteItem memory item, uint256 itemIndex, bool matched) = + claim.decodedQuote.findItemForRequest(requests[i]); + if (!matched) continue; + + uint256 bit = uint256(1) << itemIndex; + if (consumedInTx & bit != 0) { + revert DuplicateRequestType(requests[i].quoteType); + } + consumedInTx |= bit; + totalCollected += item.amount; + _emitFeeCollected(claim, correlationHash, requests[i], item.amount, feeRecipient); + } + } + + function _emitFeeCollected( + DecodedQuoteClaim memory claim, + bytes32 correlationHash, + FeeWithQuoteRequest calldata request, + uint256 amount, + address payable feeRecipient + ) private { + emit FeeCollected( + msg.sender, + correlationHash, + request.quoteType, + request.quoteArgs, + claim.refundAddress, + feeRecipient, + claim.decodedQuote.feeToken, + amount, + claim.decodedQuote.nonce + ); + } + + /// @dev Recovers the signer from `quote.signature` and requires membership in the trusted signer set. + /// Non-65-byte `bytes` signatures revert in `ECDSA.recover` (`ECDSAInvalidSignatureLength`); no separate length check. + /// **EIP-712:** `digest` is `keccak256("\x19\x01" || domainSeparator || structHash)` where + /// `domainSeparator` binds this contract (`address(this)`) and `block.chainid`, so the same + /// signature cannot be replayed on another chain or against another FeeManager deployment. + /// `structHash` includes `quote.nonce` (currently required to be 0) for + /// EIP-712 domain binding; the field is reserved for future use. + function _verifySignedQuote(Quote memory quote) private view { + bytes32 structHash = _quoteStructHash(quote.items, quote.expiry, quote.feeToken, quote.nonce); + bytes32 digest = keccak256(abi.encodePacked("\x19\x01", _domainSeparator(), structHash)); + address recovered = ECDSA.recover(digest, quote.signature); + if (!_isTrustedQuoteSigner(recovered)) revert InvalidQuoteSigner(recovered); + } + + function _domainSeparator() private view returns (bytes32) { + return keccak256( + abi.encode(EIP712TypeHashes.DOMAIN_TYPEHASH, _HASHED_NAME, _HASHED_VERSION, block.chainid, address(this)) + ); + } + + function _hashQuoteItem(QuoteItem memory item) private pure returns (bytes32) { + return keccak256( + abi.encode( + EIP712TypeHashes.QUOTE_ITEM_TYPEHASH, keccak256(bytes(item.quoteType)), item.quoteArgsHash, item.amount + ) + ); + } + + function _hashQuoteItems(QuoteItem[] memory items) private pure returns (bytes32) { + bytes32[] memory hashes = new bytes32[](items.length); + for (uint256 i = 0; i < items.length; ++i) { + hashes[i] = _hashQuoteItem(items[i]); + } + return keccak256(abi.encodePacked(hashes)); + } + + function _quoteStructHash(QuoteItem[] memory items, uint256 expiry, address feeToken, uint256 nonce) + private + pure + returns (bytes32) + { + return keccak256(abi.encode(EIP712TypeHashes.QUOTE_TYPEHASH, _hashQuoteItems(items), expiry, feeToken, nonce)); + } +} diff --git a/src/contracts/TokenMessengerWithFees.sol b/src/contracts/TokenMessengerWithFees.sol new file mode 100644 index 0000000..99e3eab --- /dev/null +++ b/src/contracts/TokenMessengerWithFees.sol @@ -0,0 +1,508 @@ +/** + * Copyright 2026 Circle Internet Group, Inc. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + */ +pragma solidity ^0.8.20; + +import {QuoteTypes} from "../constants/QuoteTypes.sol"; +import {IFeeManager} from "../interfaces/IFeeManager.sol"; +import {ITokenMessengerWithFees} from "../interfaces/ITokenMessengerWithFees.sol"; +import {IMessageTransmitterV2} from "../interfaces/cctp/IMessageTransmitterV2.sol"; +import {ITokenMessengerV2} from "../interfaces/cctp/ITokenMessengerV2.sol"; +import {AddressUtils} from "../libraries/AddressUtils.sol"; +import {BurnMessageV2Lib} from "../libraries/BurnMessageV2Lib.sol"; +import {HookDataLib} from "../libraries/HookDataLib.sol"; +import {MessageV2Lib} from "../libraries/MessageV2Lib.sol"; +import {QuoteLib} from "../libraries/QuoteLib.sol"; +import {SignedQuoteLib} from "../libraries/SignedQuoteLib.sol"; +import {Ownable2StepUpgradeable} from "@openzeppelin/contracts-upgradeable/access/Ownable2StepUpgradeable.sol"; +import {UUPSUpgradeable} from "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol"; +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; +import {Strings} from "@openzeppelin/contracts/utils/Strings.sol"; + +/// @title TokenMessengerWithFees +/// @notice Wrapper over `TokenMessengerV2` that atomically collects fees via +/// `FeeManager` and then delegates to the underlying `TokenMessengerV2` +/// for the standard CCTP burn flow. Deployed behind a UUPS proxy so +/// that the coupled `MessageV2` formatting logic can be updated if +/// `TokenMessengerV2` changes in the future. +/// @dev This contract replicates the `MessageV2` formatting logic from +/// `TokenMessengerV2` to pre-compute the `correlationHash` +/// (`keccak256(relayFormatMessage)`) before the burn call. This is an +/// accepted coupling in exchange for keeping `TokenMessengerV2` +/// unmodified. +/// +/// **Quotes:** Entry points that take `bytes signedQuote` decode with `SignedQuoteLib` in-process (no external +/// `FeeManager.decodeQuote` call). Decode errors and **Panic(0x21)** behavior match `IFeeManager.decodeQuote`; +/// see `SignedQuoteLib` NatSpec. +/// @custom:oz-upgrades +contract TokenMessengerWithFees is ITokenMessengerWithFees, Ownable2StepUpgradeable, UUPSUpgradeable { + using AddressUtils for address; + using SafeERC20 for IERC20; + using QuoteLib for IFeeManager.Quote; + + // ───────────────────── Types ───────────────────── + + /// @dev Bundles the core CCTP transfer parameters to keep the EVM stack + /// within the 16-slot limit in the legacy (non-viaIR) codegen. + struct BurnParams { + uint256 amount; + bytes32 mintRecipient; + bytes32 destinationCaller; + address burnToken; + uint32 destinationDomain; + uint32 minFinalityThreshold; + bytes hookData; + } + + // ───────────────────── Errors ───────────────────── + + error RenounceDisabled(); + + // ───────────────────── Modifiers ───────────────────── + + /// @dev Entry-point compliance: `TokenMessengerV2` denylist and USDC blocklist + /// (see `_checkDenylist`). Skipped when `msg.sender == tx.origin` because + /// `TokenMessengerV2` and `FeeTaker` already screen `tx.origin`. + modifier notDenylistedSender() { + _checkDenylist(); + _; + } + + // ───────────────────── Constants ───────────────────── + + /// @dev CCTP `minFinalityThreshold` value requesting pre-finality (fast) attestation. + uint32 private constant FAST_FINALITY_THRESHOLD = 1000; + + /// @dev CCTP `minFinalityThreshold` value requesting post-finality attestation. + uint32 private constant SLOW_FINALITY_THRESHOLD = 2000; + + // ───────────────────── Immutables ───────────────────── + + /// @custom:oz-upgrades-unsafe-allow state-variable-immutable + // slither-disable-next-line naming-convention + ITokenMessengerV2 private immutable _TOKEN_MESSENGER; + + /// @custom:oz-upgrades-unsafe-allow state-variable-immutable + // slither-disable-next-line naming-convention + IFeeManager private immutable _FEE_MANAGER; + + /// @dev CCTP source domain from `MessageTransmitterV2.localDomain()` at construction. Fixed for this chain + /// deployment (same value `TokenMessengerV2` would read on every burn). Cached to avoid two external + /// calls per `_computeCorrelationHash` (`localMessageTransmitter` + `localDomain`). + /// @custom:oz-upgrades-unsafe-allow state-variable-immutable + // slither-disable-next-line naming-convention + uint32 private immutable _SOURCE_DOMAIN; + + /// @dev `bytes32(tokenMessenger)` — the message `sender` field in CCTP v2 hashing. Set once from the constructor + /// arg to avoid recomputing on every correlation hash. + /// @custom:oz-upgrades-unsafe-allow state-variable-immutable + // slither-disable-next-line naming-convention + bytes32 private immutable _SENDER_BYTES32; + + // ───────────────────── Constructor ───────────────────── + + /// @param tokenMessenger_ Address of the existing `TokenMessengerV2` contract. + /// @param feeManager_ Address of the `FeeManager` proxy contract. + /// @custom:oz-upgrades-unsafe-allow constructor + constructor(address tokenMessenger_, address feeManager_) { + if (tokenMessenger_ == address(0)) revert ZeroTokenMessengerAddress(); + if (feeManager_ == address(0)) revert ZeroFeeManagerAddress(); + _TOKEN_MESSENGER = ITokenMessengerV2(tokenMessenger_); + _FEE_MANAGER = IFeeManager(feeManager_); + _SENDER_BYTES32 = tokenMessenger_.toBytes32(); + _SOURCE_DOMAIN = IMessageTransmitterV2(_TOKEN_MESSENGER.localMessageTransmitter()).localDomain(); + _disableInitializers(); + } + + /// @notice Disabled — renouncing ownership would permanently brick the proxy. + function renounceOwnership() public pure override { + revert RenounceDisabled(); + } + + // ───────────────────── Initializer ───────────────────── + + /// @notice Initializes the TokenMessengerWithFees proxy. + /// @dev Must be called once via the proxy after deployment. + /// @param owner_ Address to receive Ownable ownership. + function initialize(address owner_) external initializer onlyProxy { + __Ownable_init(owner_); + __Ownable2Step_init(); + } + + // ───────────────────── External functions ───────────────────── + + /// @inheritdoc ITokenMessengerWithFees + function depositForBurnWithFees( + uint256 amount, + uint32 destinationDomain, + bytes32 mintRecipient, + address burnToken, + bytes32 destinationCaller, + IFeeManager.QuoteClaim calldata claim + ) external payable override notDenylistedSender { + IFeeManager.DecodedQuoteClaim memory internalClaim = SignedQuoteLib.decodeClaim(claim); + // msg.data[0:0]: zero-length calldata slice — no caller-provided hookData + BurnParams memory p = _buildBurnParams( + amount, + destinationDomain, + mintRecipient, + burnToken, + destinationCaller, + internalClaim.decodedQuote, + msg.data[0:0] + ); + _collectFeesAndBurn(p, internalClaim); + } + + /// @inheritdoc ITokenMessengerWithFees + function depositForBurnWithFees( + uint256 amount, + uint32 destinationDomain, + bytes32 mintRecipient, + address burnToken, + bytes32 destinationCaller, + IFeeManager.DecodedQuoteClaim calldata claim + ) external payable override notDenylistedSender { + // Copy the claim from calldata to memory once for reuse by downstream internal calls. + IFeeManager.DecodedQuoteClaim memory internalClaim = claim; + BurnParams memory p = _buildBurnParams( + amount, + destinationDomain, + mintRecipient, + burnToken, + destinationCaller, + internalClaim.decodedQuote, + msg.data[0:0] + ); + _collectFeesAndBurn(p, internalClaim); + } + + /// @inheritdoc ITokenMessengerWithFees + function depositForBurnWithHookAndFees( + uint256 amount, + uint32 destinationDomain, + bytes32 mintRecipient, + address burnToken, + bytes32 destinationCaller, + bytes calldata hookData, + IFeeManager.QuoteClaim calldata claim + ) external payable override notDenylistedSender { + if (hookData.length == 0) revert EmptyHookData(); + IFeeManager.DecodedQuoteClaim memory internalClaim = SignedQuoteLib.decodeClaim(claim); + BurnParams memory p = _buildBurnParams( + amount, destinationDomain, mintRecipient, burnToken, destinationCaller, internalClaim.decodedQuote, hookData + ); + _collectFeesAndBurn(p, internalClaim); + } + + /// @inheritdoc ITokenMessengerWithFees + function depositForBurnWithHookAndFees( + uint256 amount, + uint32 destinationDomain, + bytes32 mintRecipient, + address burnToken, + bytes32 destinationCaller, + bytes calldata hookData, + IFeeManager.DecodedQuoteClaim calldata claim + ) external payable override notDenylistedSender { + if (hookData.length == 0) revert EmptyHookData(); + // Copy the claim from calldata to memory once for reuse by downstream internal calls. + IFeeManager.DecodedQuoteClaim memory internalClaim = claim; + BurnParams memory p = _buildBurnParams( + amount, destinationDomain, mintRecipient, burnToken, destinationCaller, internalClaim.decodedQuote, hookData + ); + _collectFeesAndBurn(p, internalClaim); + } + + // ───────────────────── View functions ───────────────────── + + /// @inheritdoc ITokenMessengerWithFees + function tokenMessenger() public view override returns (address) { + return address(_TOKEN_MESSENGER); + } + + /// @inheritdoc ITokenMessengerWithFees + function feeManager() public view override returns (address) { + return address(_FEE_MANAGER); + } + + /// @inheritdoc ITokenMessengerWithFees + function getFee(bytes calldata signedQuote) external pure override returns (uint256 totalFee, address feeToken) { + if (signedQuote.length == 0) { + return (0, address(0)); + } + IFeeManager.Quote memory quote = SignedQuoteLib.decodeQuote(signedQuote); + (totalFee, feeToken) = _sumSupportedFees(quote); + } + + /// @inheritdoc ITokenMessengerWithFees + function getFee(IFeeManager.Quote calldata decodedQuote) + external + pure + override + returns (uint256 totalFee, address feeToken) + { + IFeeManager.Quote memory quote = decodedQuote; + (totalFee, feeToken) = _sumSupportedFees(quote); + } + + // ───────────────────── UUPS ───────────────────── + + /// @inheritdoc UUPSUpgradeable + // solhint-disable-next-line no-empty-blocks + function _authorizeUpgrade(address) internal override onlyOwner {} + + // ───────────────────── Internal ───────────────────── + + /// @dev Builds `BurnParams` from transfer args, quote, and caller hook calldata. + /// @param callerHookData Empty for auto forward-hook; non-empty for `depositForBurnWithHookAndFees`. + function _buildBurnParams( + uint256 amount, + uint32 destinationDomain, + bytes32 mintRecipient, + address burnToken, + bytes32 destinationCaller, + IFeeManager.Quote memory quote, + bytes calldata callerHookData + ) private pure returns (BurnParams memory) { + (uint32 minFinalityThreshold, bytes memory hookData) = _inferParamsFromQuote(quote, callerHookData); + return BurnParams({ + amount: amount, + mintRecipient: mintRecipient, + destinationCaller: destinationCaller, + burnToken: burnToken, + destinationDomain: destinationDomain, + minFinalityThreshold: minFinalityThreshold, + hookData: hookData + }); + } + + /// @dev Core implementation shared by all public entry points. Computes + /// the correlation hash, builds requests, delegates fee collection + /// to `_collectFees`, then pulls and approves the burn token and + /// forwards the burn to the underlying `TokenMessengerV2`. + /// Reentrancy note: this function relies on `FeeManager.collectFeesWithQuote` + /// being `nonReentrant`. Both contracts live in the same repo and are + /// deployed together, so the invariant is maintained by design. + /// @param claim In-memory canonical form of the caller's claim. `refundAddress` is forwarded + /// to FeeManager verbatim — TMWF applies no defaulting at this or any other layer. + function _collectFeesAndBurn(BurnParams memory p, IFeeManager.DecodedQuoteClaim memory claim) internal { + bytes32 correlationHash = _computeCorrelationHash(p); + IFeeManager.FeeWithQuoteRequest[] memory requests = _buildRequests(p); + + _collectFees(correlationHash, requests, claim); + + IERC20(p.burnToken).safeTransferFrom(msg.sender, address(this), p.amount); + IERC20(p.burnToken).forceApprove(address(_TOKEN_MESSENGER), p.amount); + + _depositForBurn(p); + } + + /// @dev Calls `QuoteLib.sumFees` directly via the `using` directive instead of + /// routing through `_FEE_MANAGER.sumFees`. The library function is the + /// sole implementation of `IFeeManager.sumFees`, so the answer is + /// identical, but skipping the external hop saves a CALL and the + /// ABI-encode/decode of the in-memory `Quote` struct on every burn. + function _sumSupportedFees(IFeeManager.Quote memory quote) + private + pure + returns (uint256 totalFee, address feeToken) + { + (totalFee, feeToken) = quote.sumFees(_supportedQuoteTypes()); + } + + function _supportedQuoteTypes() private pure returns (string[] memory quoteTypes) { + quoteTypes = new string[](2); + quoteTypes[0] = QuoteTypes.FORWARD; + quoteTypes[1] = QuoteTypes.PRE_FINALITY; + } + + /// @dev Forwards the burn to the underlying `TokenMessengerV2`, choosing + /// `depositForBurnWithHook` or `depositForBurn` based on `hookData`. + /// `maxFee` is `BurnMessageV2Lib.EMPTY_MAX_FEE`; fees are collected on source via `FeeManager`. + function _depositForBurn(BurnParams memory p) internal { + if (p.hookData.length > 0) { + _TOKEN_MESSENGER.depositForBurnWithHook( + p.amount, + p.destinationDomain, + p.mintRecipient, + p.burnToken, + p.destinationCaller, + BurnMessageV2Lib.EMPTY_MAX_FEE, + p.minFinalityThreshold, + p.hookData + ); + } else { + _TOKEN_MESSENGER.depositForBurn( + p.amount, + p.destinationDomain, + p.mintRecipient, + p.burnToken, + p.destinationCaller, + BurnMessageV2Lib.EMPTY_MAX_FEE, + p.minFinalityThreshold + ); + } + } + + /// @dev Inspects the signed quote and resolves `minFinalityThreshold` and + /// `hookData`. When `callerHookData` is empty and the quote contains + /// a FORWARD item, a default forwarding hook is constructed + /// automatically. When `callerHookData` is non-empty, the presence + /// of a `cctp-forward` hook must match the presence of a FORWARD + /// quote item: a mismatch in either direction reverts. + function _inferParamsFromQuote(IFeeManager.Quote memory quote, bytes calldata callerHookData) + internal + pure + returns (uint32 minFinalityThreshold, bytes memory hookData) + { + bool quotedForPreFinality = quote.hasItemType(QuoteTypes.PRE_FINALITY); + bool quotedForForward = quote.hasItemType(QuoteTypes.FORWARD); + + minFinalityThreshold = quotedForPreFinality ? FAST_FINALITY_THRESHOLD : SLOW_FINALITY_THRESHOLD; + + if (callerHookData.length > 0) { + bool hasForwardHook = _hasForwardHook(callerHookData); + if (quotedForForward && !hasForwardHook) revert ForwardFeeWithoutHook(); + if (!quotedForForward && hasForwardHook) revert ForwardHookWithoutFee(); + hookData = callerHookData; + } else if (quotedForForward) { + hookData = HookDataLib.defaultForwardHookData(); + } + } + + /// @dev Pre-computes the CCTP v2 relay-format message hash that the + /// backend will use to correlate `FeeCollected` events with the + /// `MessageSent` event. + function _computeCorrelationHash(BurnParams memory p) internal view returns (bytes32) { + bytes32 recipient = _TOKEN_MESSENGER.remoteTokenMessengers(p.destinationDomain); + if (recipient == bytes32(0)) revert RemoteTokenMessengerNotSet(p.destinationDomain); + + bytes memory messageBody = BurnMessageV2Lib.formatMessage( + _TOKEN_MESSENGER.messageBodyVersion(), + p.burnToken.toBytes32(), + p.mintRecipient, + p.amount, + address(this).toBytes32(), + p.hookData + ); + + return MessageV2Lib.messageHash( + _SOURCE_DOMAIN, + p.destinationDomain, + _SENDER_BYTES32, + recipient, + p.destinationCaller, + p.minFinalityThreshold, + messageBody + ); + } + + /// @dev Builds the `FeeWithQuoteRequest[]` array for all CCTP-relevant + /// quote types. Both FORWARD and PRE_FINALITY are always included + /// with `required: false` so that unmatched items are silently skipped. + /// FORWARD and PRE_FINALITY `quoteArgs` both begin with this contract + /// address so the signed fees bind to the wrapper that constructs the + /// burn payload, and both include `burnToken` to account for costs + /// associated with the minted token. + function _buildRequests(BurnParams memory p) + internal + view + returns (IFeeManager.FeeWithQuoteRequest[] memory requests) + { + requests = new IFeeManager.FeeWithQuoteRequest[](2); + + string[] memory forwardArgs = new string[](5); + forwardArgs[0] = Strings.toHexString(address(this)); + forwardArgs[1] = Strings.toString(uint256(p.destinationDomain)); + forwardArgs[2] = Strings.toHexString(p.burnToken); + forwardArgs[3] = Strings.toHexString(uint256(p.destinationCaller), 32); + forwardArgs[4] = Strings.toHexString(p.hookData); + requests[0] = + IFeeManager.FeeWithQuoteRequest({quoteType: QuoteTypes.FORWARD, quoteArgs: forwardArgs, required: false}); + + string[] memory preFinArgs = new string[](4); + preFinArgs[0] = Strings.toHexString(address(this)); + preFinArgs[1] = Strings.toString(uint256(p.destinationDomain)); + preFinArgs[2] = Strings.toHexString(p.burnToken); + preFinArgs[3] = Strings.toString(p.amount); + requests[1] = IFeeManager.FeeWithQuoteRequest({ + quoteType: QuoteTypes.PRE_FINALITY, quoteArgs: preFinArgs, required: false + }); + } + + // ───────────────────── Private ───────────────────── + + /// @dev For intermediaries: `TokenMessengerV2` denylist on `msg.sender` and USDC blocklist on + /// `msg.sender` (via `FeeManager.isDenylisted`). When `msg.sender == tx.origin` (direct EOA + /// entry), returns early so TMv2 / FeeTaker / USDC token policy still applies downstream. + /// This only distinguishes EOA vs intermediary routing — **not** authorization via + /// `tx.origin` (see Olympix note on `any-tx-origin`): + /// http://detectors.olympixdevsectools.com/article/web3-vulnerability/any-tx-origin + function _checkDenylist() private view { + /* solhint-disable avoid-tx-origin */ + if (msg.sender == tx.origin) { + return; + } + /* solhint-enable avoid-tx-origin */ + if (_TOKEN_MESSENGER.isDenylisted(msg.sender)) revert DenylistedCaller(msg.sender); + if (_FEE_MANAGER.isDenylisted(msg.sender)) revert DenylistedCaller(msg.sender); + } + + /// @dev Collects fees via `FeeManager.collectFeesWithQuote`, handling + /// both native and ERC-20 fee tokens. For ERC-20 fees the supported + /// fee amount is computed via `sumFees`, pulled from the caller, and + /// approved exactly to FeeManager before collection. `msg.value` is + /// always forwarded so FeeManager's own `IncorrectNativePayment` + /// check rejects native value sent with an ERC-20 quote, keeping + /// that validation in a single place. + /// The post-collection assert guards the assumed-valid query path + /// (`sumFees`) against drift from the validation path + /// (`collectFeesWithQuote`); it also consumes the `feeAmount` + /// return value, satisfying the Olympix unused-return-value detector. + /// See: http://detectors.olympixdevsectools.com/article/web3-vulnerability/unused-return-function-call + function _collectFees( + bytes32 correlationHash, + IFeeManager.FeeWithQuoteRequest[] memory requests, + IFeeManager.DecodedQuoteClaim memory claim + ) private { + (uint256 quotedFee, address feeToken) = _sumSupportedFees(claim.decodedQuote); + + if (feeToken != address(0) && quotedFee > 0) { + IERC20(feeToken).safeTransferFrom(msg.sender, address(this), quotedFee); + IERC20(feeToken).forceApprove(address(_FEE_MANAGER), quotedFee); + } + + uint256 feeAmount = _FEE_MANAGER.collectFeesWithQuote{value: msg.value}(correlationHash, requests, claim); + assert(feeAmount == quotedFee); + } + + /// @dev Thin wrapper around `HookDataLib.getForwardHook` that returns + /// only the `found` flag. When found, the hook name is verified to + /// be the expected forward-hook magic as an integrity check. + /// This indirection exists to work around the Olympix + /// unused-return-value detector which flags the discarded `Hook`. + /// See: http://detectors.olympixdevsectools.com/article/web3-vulnerability/unused-return-function-call + function _hasForwardHook(bytes calldata hookData) private pure returns (bool found) { + HookDataLib.Hook memory hook; + (hook, found) = HookDataLib.getForwardHook(hookData); + if (found) { + assert(hook.name == HookDataLib.FORWARD_MAGIC); + } + } +} diff --git a/src/interfaces/IFeeManager.sol b/src/interfaces/IFeeManager.sol new file mode 100644 index 0000000..ba144a5 --- /dev/null +++ b/src/interfaces/IFeeManager.sol @@ -0,0 +1,297 @@ +/** + * Copyright 2026 Circle Internet Group, Inc. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + */ +pragma solidity ^0.8.20; + +/// @title IFeeManager +/// @notice Interface for the FeeManager contract that handles fee collection and assertion. +interface IFeeManager { + /// @notice A single line item within a quote, binding a fee type and its + /// arguments to a specific amount. + /// @param quoteType Identifier for the kind of fee (e.g. "FORWARD", "PRE_FINALITY"). + /// @param quoteArgsHash Keccak-256 hash of the ABI-encoded quote arguments, + /// used to match a request to its quote item without + /// storing the full arguments on-chain. + /// @param amount The fee amount denominated in the quote's `feeToken` + /// (wei for native currency). + struct QuoteItem { + string quoteType; + bytes32 quoteArgsHash; + uint256 amount; + } + + /// @notice A server-signed bundle of fee line items, specifying the token, + /// expiry, and an EIP-712 ECDSA signature over the contents. + /// @param items Ordered list of fee line items. Each `quoteType` should appear at most once; if the same + /// `quoteType` string appears on multiple lines, lookups and fee collection use the **first** + /// matching line only (see `QuoteLib.findItemByType`). + /// @param expiry Packed expiry: the high byte (bits 255–248) selects the mode; the lower 248 bits hold + /// the deadline value. Mode `0x00` (default): deadline is a Unix timestamp and the quote + /// is valid while `block.timestamp <= deadline`. Mode `0x01`: deadline is a block height and + /// the quote is valid while `block.number <= deadline`. Other mode bytes are rejected on + /// collection. The full `uint256` is included in the EIP-712 struct hash unchanged. + /// @param feeToken Token address in which all fees are denominated. + /// Use `address(0)` to indicate the chain's native currency. + /// @param nonce Must be `0`. Non-zero values are rejected with `QuoteLib.NonZeroNonce`. + /// The field is retained in the EIP-712 struct hash for domain + /// binding and is reserved for future use. + /// @param signature Signature over the canonical encoding of the quote, + /// verified against a trusted signer. + struct Quote { + QuoteItem[] items; + uint256 expiry; + address feeToken; + uint256 nonce; + bytes signature; + } + + /// @notice Caller-supplied request to collect a specific fee from the quote. + /// @param quoteType Must match a `QuoteItem.quoteType` in the signed quote. + /// @param quoteArgs Original arguments whose keccak-256 hash must equal the + /// corresponding `QuoteItem.quoteArgsHash`. + /// @param required If true, revert when no line item with this `quoteType` + /// exists in the quote. If false, silently skip only in + /// that case. If a line item of this type exists but its + /// `quoteArgsHash` does not match the request, the call + /// reverts regardless of `required`. + struct FeeWithQuoteRequest { + string quoteType; + string[] quoteArgs; + bool required; + } + + /// @notice Bundles a server-signed quote payload with the runtime-supplied + /// refund address. `refundAddress` is **not** part of the signed + /// payload — it is declared per call by the on-chain layer that + /// knows who the fee payer is (typically a wrapper such as + /// `TokenMessengerWithFees`, which itself collects the fee from + /// the end user it serves). Refunds, if any, are attributed + /// off-chain and routed to this address. + /// @dev Using a struct here lets every wrapper above FeeManager pass a + /// single value through unchanged instead of growing an extra + /// parameter alongside `signedQuote`. + /// @param signedQuote Version-prefixed, ABI-encoded `Quote` struct + /// (`[uint8 version][abi.encode(Quote)]`). + /// @param refundAddress Address to attribute fee refunds to. Use + /// `address(0)` when no refund address should be + /// recorded. Non-zero values are screened against + /// the configured USDC blacklist on collection and + /// the call reverts if listed. + struct QuoteClaim { + bytes signedQuote; + address payable refundAddress; + } + + /// @notice Decoded counterpart to `QuoteClaim`: bundles an already-decoded `Quote` body with + /// the runtime-supplied refund address. Used by the `Quote calldata` overload of + /// `collectFeesWithQuote` and by wrappers (e.g. `TokenMessengerWithFees`) that have + /// already decoded the wire payload and want to skip a redundant decode at the + /// FeeManager boundary. + /// @dev Same `refundAddress` semantics as `QuoteClaim`. + /// @param decodedQuote The v1 `Quote` body, equivalent to `abi.decode(signedQuote[1:], (Quote))` + /// for a valid v1 versioned blob. Future formats use a distinct struct + /// type and matching overload. + /// @param refundAddress See `QuoteClaim.refundAddress`. + struct DecodedQuoteClaim { + Quote decodedQuote; + address payable refundAddress; + } + + /// @notice Emitted for each fee line item that is successfully collected. + /// @param caller The address that initiated the fee collection + /// (`msg.sender`). + /// @param correlationHash Caller-supplied identifier that ties the fee to + /// an external operation and groups the individual + /// `FeeCollected` events emitted per quote item + /// within a single `collectFeesWithQuote` call. + /// @param quoteType The fee type that was charged. + /// @param quoteArgs The original quote arguments. + /// @param refundAddress Address to attribute fee refunds to (if any). + /// May be `address(0)` (no refund target). + /// @param feeRecipient The address that received the fee. Always non-zero. + /// @param feeToken The token in which the fee was paid + /// (`address(0)` for native currency). + /// @param amount The amount of `feeToken` collected. + /// @param nonce The quote's `nonce` (always `0` in the current version). + event FeeCollected( + address indexed caller, + bytes32 indexed correlationHash, + string quoteType, + string[] quoteArgs, + address payable refundAddress, + address payable feeRecipient, + address feeToken, + uint256 amount, + uint256 nonce + ); + + /// @notice The supplied non-zero `refundAddress` is on the configured USDC blacklist. + /// @dev `address(0)` is accepted ("no refund target") and bypasses this check. + error DenylistedRefundAddress(address refundAddress); + + /// @notice Signed quotes may contain at most 256 line items (bitmap width). + error TooManyQuoteItems(uint256 length); + + /// @notice The same quote line item was requested more than once in one call. + error DuplicateRequestType(string quoteType); + + /// @notice A required request did not have a matching quote line item. + error RequiredFeeNotInQuote(string quoteType, bytes32 quoteArgsHash); + + /// @notice A quote line item existed for the request type, but its args did not match. + error QuoteArgsMismatch(string quoteType, bytes32 requestArgsHash); + + /// @notice The signed quote's format version is not supported by this contract. + /// @param version The unrecognised version byte. + error UnsupportedQuoteVersion(uint8 version); + + /// @notice Decoding `signedQuote` failed (empty payload, tail too short, signature not exactly 65 bytes, etc.). + /// @dev Returned when cheap checks fail before or after `abi.decode` (see `SignedQuoteLib`). If the tail is long + /// enough to pass those checks but the bytes are still not a valid ABI encoding of `Quote`, the compiler + /// may revert with **Panic(0x21)** instead of this error. Off-chain callers should treat any revert while + /// decoding as failure; do not rely on a specific error selector for malformed blobs. + error SignedQuoteDecodeFailed(); + + /// @notice Collect fees from `msg.sender` according to a server-signed quote. + /// @dev The fee token is derived from the signed quote; callers do not + /// specify it directly. Callers may use `sumFees` with the relevant + /// quote types to prepare ERC-20 allowance or native `msg.value` for + /// an assumed-valid quote; this function performs the full + /// quote-argument, signature, expiry, and payment validation. + /// **Wire decode:** `claim.signedQuote` is parsed with `SignedQuoteLib` (same rules as `decodeQuote`). + /// EIP-712 signature verification runs after a successful decode. + /// **Refund address:** `claim.refundAddress` is **not** part of the signed payload — it is supplied + /// per call by the on-chain layer that knows who is paying, and is reflected verbatim in every + /// emitted `FeeCollected` event. `address(0)` is allowed (no refund target). When non-zero, the + /// address is screened against the USDC blacklist. + /// @param correlationHash Opaque identifier emitted in every `FeeCollected` + /// event, used both to correlate the per-item events + /// with each other and to tie them back to the + /// external operation that triggered collection. + /// @param requests Ordered list of fees to collect. Each request is + /// matched against the signed quote by `quoteType` + /// and `quoteArgsHash`. + /// @param claim Bundle of the version-prefixed signed quote + /// payload and the runtime-supplied + /// `refundAddress`. See `QuoteClaim`. + /// @return amount Total amount of `feeToken` collected across all + /// matched requests. + function collectFeesWithQuote( + bytes32 correlationHash, + FeeWithQuoteRequest[] calldata requests, + QuoteClaim calldata claim + ) external payable returns (uint256 amount); + + /// @notice Same as the `QuoteClaim` overload, but accepts an already-decoded `Quote` body + /// to skip redundant decoding when the caller already decoded the payload + /// (e.g. after `decodeQuote` or off-chain ABI decoding). + /// @dev The `Quote` struct type inside `DecodedQuoteClaim` denotes the v1 quote body; + /// future wire-format versions will use separate struct types (e.g. `QuoteV2`) and + /// matching overloads. For EIP-712 verification, `claim.decodedQuote.signature` must + /// be a 65-byte `bytes` value accepted by OpenZeppelin `ECDSA.recover` (non-65-byte + /// lengths revert with `ECDSAInvalidSignatureLength`). The `QuoteClaim` path + /// additionally enforces length in `SignedQuoteLib.decodeQuote` (`SignedQuoteDecodeFailed`). + /// @param correlationHash See the `QuoteClaim` overload. + /// @param requests See the `QuoteClaim` overload. + /// @param claim Bundle of the decoded `Quote` body and the runtime-supplied + /// `refundAddress`. See `DecodedQuoteClaim`. + function collectFeesWithQuote( + bytes32 correlationHash, + FeeWithQuoteRequest[] calldata requests, + DecodedQuoteClaim calldata claim + ) external payable returns (uint256 amount); + + /// @notice Return the amount of `feeToken` present for the supplied quote + /// types in a signed quote. + /// @dev This is a lightweight pure query: it decodes the quote and sums + /// quote items by `quoteType` only. It does not validate quote + /// arguments, required-fee presence, signature/expiry, nonce, + /// refund addresses, emit events, or transfer funds. Full + /// validation happens only during `collectFeesWithQuote`. Empty + /// `signedQuote` returns `(0, address(0))`, matching `getQuoteTotal`. + /// Reverts `TooManyQuoteItems` when the decoded quote has more than + /// 256 items, mirroring `collectFeesWithQuote`. Duplicate entries + /// in `quoteTypes` are silently deduplicated so the returned + /// `totalFee` mirrors what `collectFeesWithQuote` would charge (it + /// rejects per-call duplicate matches with `DuplicateRequestType`). + /// @param signedQuote Version-prefixed, ABI-encoded `Quote` struct. + /// @param quoteTypes Fee types to include in the sum. + /// @return totalFee Sum of first matching `QuoteItem.amount` values. + /// @return feeToken Token address from the quote (`address(0)` for native). + function sumFees(bytes calldata signedQuote, string[] calldata quoteTypes) + external + pure + returns (uint256 totalFee, address feeToken); + + /// @notice Same as the `signedQuote` overload, but accepts a decoded `Quote` body. + /// @param decodedQuote ABI-decoded v1 `Quote`. + /// @param quoteTypes Fee types to include in the sum. + function sumFees(Quote calldata decodedQuote, string[] calldata quoteTypes) + external + pure + returns (uint256 totalFee, address feeToken); + + /// @notice Validates the version prefix and ABI-decodes a versioned `signedQuote` into a `Quote`. + /// @dev Wire format: `[uint8 version][abi.encode(Quote)]`. Implementation delegates to `SignedQuoteLib.decodeQuote`. + /// Reverts `UnsupportedQuoteVersion` for a wrong version byte, `SignedQuoteDecodeFailed` for failed cheap checks + /// (empty payload, tail too short, signature length not 65), or **Panic(0x21)** if `abi.decode` fails on + /// sufficiently long but invalid ABI. Does not verify the EIP-712 signature. + /// @param signedQuote Version-prefixed, ABI-encoded `Quote` struct. + function decodeQuote(bytes calldata signedQuote) external pure returns (Quote memory); + + /// @notice Look up a single quote item by type from a signed quote. + /// @dev Does not validate the quote signature or expiry -- this is a + /// lookup utility. Signature validity is asserted inside + /// `collectFeesWithQuote`. Decodes `signedQuote` with the same + /// `SignedQuoteLib` rules as `decodeQuote` (including panic behavior on malformed ABI). + /// @param signedQuote Version-prefixed, ABI-encoded `Quote` struct. + /// @param quoteType The fee type to search for. + /// @return item The matched `QuoteItem` (zero-valued if not found). + /// @return found `true` if a matching item exists in the quote. + function getQuoteItem(bytes calldata signedQuote, string calldata quoteType) + external + pure + returns (QuoteItem memory item, bool found); + + /// @notice Same as the `signedQuote` overload, but accepts a decoded `Quote` body. + /// @param decodedQuote ABI-decoded v1 `Quote` (same as for `collectFeesWithQuote`). + function getQuoteItem(Quote calldata decodedQuote, string calldata quoteType) + external + pure + returns (QuoteItem memory item, bool found); + + /// @notice Return the total fee amount and fee token from a signed quote. + /// @dev Does not validate the quote signature or expiry -- this is a + /// lookup utility. Signature validity is asserted inside + /// `collectFeesWithQuote`. Decodes `signedQuote` with the same + /// `SignedQuoteLib` rules as `decodeQuote` (including panic behavior on malformed ABI). + /// @param signedQuote Version-prefixed, ABI-encoded `Quote` struct. + /// @return totalFee Sum of all `QuoteItem.amount` values in the quote. + /// @return feeToken Token address from the quote (`address(0)` for native). + function getQuoteTotal(bytes calldata signedQuote) external pure returns (uint256 totalFee, address feeToken); + + /// @notice Same as the `signedQuote` overload, but accepts a decoded `Quote` body. + /// @param decodedQuote ABI-decoded v1 `Quote` (same as for `collectFeesWithQuote`). + function getQuoteTotal(Quote calldata decodedQuote) external pure returns (uint256 totalFee, address feeToken); + + /// @notice Delegates to the USDC contract address set at deploy and returns its + /// `isBlacklisted(account)` result (same USDC deployment `FeeTaker` is wired to on this chain). + /// @dev Used to screen third-party intermediaries against that USDC blocklist. + /// @param account Address to query (typically the caller being screened, e.g. `msg.sender`). + /// @return Whether the configured USDC contract reports `account` as blacklisted. + function isDenylisted(address account) external view returns (bool); +} diff --git a/src/interfaces/IFeeTaker.sol b/src/interfaces/IFeeTaker.sol new file mode 100644 index 0000000..4958a43 --- /dev/null +++ b/src/interfaces/IFeeTaker.sol @@ -0,0 +1,43 @@ +/** + * Copyright 2026 Circle Internet Group, Inc. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + */ +pragma solidity ^0.8.20; + +/// @title IFeeTaker +/// @notice Minimal interface for the FeeTaker contract used by FeeManager +/// to forward collected fees. FeeTaker splits each fee between a +/// protocol fee address and a beneficiary according to a configurable +/// share (set per beneficiary via `setBeneficiaryProtocolFeeShare`). +/// @dev Minimal interface consumed by FeeManager for fee forwarding. +interface IFeeTaker { + /// @notice Returns the address that receives the protocol's share of fees. + function protocolFeeAddress() external view returns (address); + + /// @notice Collects native-asset fee, splits according to protocol rules, + /// and forwards the amounts to protocolFeeAddress & beneficiary. + /// @param beneficiary Merchant / PSP fee recipient. + /// @param kitType 8-byte kit identifier. + function takeFeeNative(address payable beneficiary, bytes8 kitType) external payable; + + /// @notice Collects ERC-20 fee, splits according to protocol rules, + /// and forwards the amounts to protocolFeeAddress & beneficiary. + /// @param token ERC-20 token address. + /// @param beneficiary Merchant / PSP fee recipient. + /// @param fee Total fee amount (already calculated by caller). + /// @param kitType 8-byte kit identifier. + function takeFeeERC20(address token, address beneficiary, uint256 fee, bytes8 kitType) external; +} diff --git a/src/interfaces/ITokenMessengerWithFees.sol b/src/interfaces/ITokenMessengerWithFees.sol new file mode 100644 index 0000000..5aa138b --- /dev/null +++ b/src/interfaces/ITokenMessengerWithFees.sol @@ -0,0 +1,175 @@ +/** + * Copyright 2026 Circle Internet Group, Inc. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + */ +pragma solidity ^0.8.20; + +import {IFeeManager} from "./IFeeManager.sol"; + +/// @title ITokenMessengerWithFees +/// @notice Interface for the wrapper contract that atomically collects fees +/// via FeeManager and delegates to TokenMessengerV2 for the standard +/// CCTP burn flow. +interface ITokenMessengerWithFees { + /// @notice Thrown when `depositForBurnWithHookAndFees` is called with + /// empty `hookData`. + error EmptyHookData(); + + /// @notice Thrown when the signed quote contains a FORWARD fee item but + /// the caller-provided `hookData` does not include a valid + /// `cctp-forward` hook. + error ForwardFeeWithoutHook(); + + /// @notice Thrown when the caller-provided `hookData` contains a valid + /// `cctp-forward` hook but the signed quote does not contain a + /// matching FORWARD fee item. Forwarding behavior must always be + /// paid for via a signed FORWARD quote item. + error ForwardHookWithoutFee(); + + /// @notice Thrown when `address(0)` is passed as the TokenMessengerV2 address. + error ZeroTokenMessengerAddress(); + + /// @notice Thrown when `address(0)` is passed as the FeeManager address. + error ZeroFeeManagerAddress(); + + /// @notice Thrown when the remote TokenMessenger is not registered for + /// the requested destination domain. + error RemoteTokenMessengerNotSet(uint32 destinationDomain); + + /// @notice Thrown when `msg.sender` fails entry screening: `TokenMessengerV2` denylist + /// or USDC blocklist (`FeeManager.isDenylisted` on the intermediary). When + /// `msg.sender == tx.origin`, the wrapper defers these checks to downstream contracts. + error DenylistedCaller(address caller); + + /// @notice Collects fees via `FeeManager`, then delegates to + /// `TokenMessengerV2`. For native-currency fees the caller must + /// attach the exact matched fee amount as `msg.value`. For ERC-20 + /// fees the caller must approve this contract for at least that + /// matched amount. Use `getFee` to estimate the amount required for + /// a valid quote. `maxFee` is omitted because fees + /// are prepaid on the source chain. + /// `minFinalityThreshold` is inferred from the presence of a + /// `PRE_FINALITY` item in the quote. If the quote contains a + /// `FORWARD` item, a default forwarding hook is constructed + /// automatically and `depositForBurnWithHook` is used. + /// + /// **Refund attribution:** the wrapper passes `claim.refundAddress` through to + /// `FeeManager` verbatim. No defaulting — `address(0)` is forwarded as-is and + /// results in `FeeCollected.refundAddress == address(0)`. Callers must explicitly + /// set `refundAddress` to whatever address should receive refunds, if any. + /// @param amount Amount of tokens to burn. + /// @param destinationDomain Destination domain. + /// @param mintRecipient Mint recipient on destination domain. + /// @param burnToken Token to burn. + /// @param destinationCaller Authorized caller on destination domain. + /// @param claim `IFeeManager.QuoteClaim` bundle: the version-prefixed signed + /// quote bytes plus the per-call `refundAddress`. See + /// `IFeeManager.QuoteClaim` for field semantics. + function depositForBurnWithFees( + uint256 amount, + uint32 destinationDomain, + bytes32 mintRecipient, + address burnToken, + bytes32 destinationCaller, + IFeeManager.QuoteClaim calldata claim + ) external payable; + + /// @notice Same as the `QuoteClaim` overload, but the caller passes an already-decoded `Quote` + /// body bundled with the `refundAddress` via `DecodedQuoteClaim`. Avoids the wire-format + /// decode when the caller already has the decoded body. + /// @param claim `IFeeManager.DecodedQuoteClaim` bundle: the decoded `Quote` body plus the + /// per-call `refundAddress`. `refundAddress` is forwarded to `FeeManager` + /// verbatim (no defaulting); see `depositForBurnWithFees(QuoteClaim)`. + function depositForBurnWithFees( + uint256 amount, + uint32 destinationDomain, + bytes32 mintRecipient, + address burnToken, + bytes32 destinationCaller, + IFeeManager.DecodedQuoteClaim calldata claim + ) external payable; + + /// @notice Collects fees via `FeeManager`, then calls + /// `TokenMessengerV2.depositForBurnWithHook`. For native-currency + /// fees the caller must attach the exact matched fee amount as + /// `msg.value`. For ERC-20 fees the caller must approve this + /// contract for at least that matched amount. Use `getFee` to + /// estimate the amount required for a valid quote. + /// `maxFee` is omitted because fees are prepaid on the source + /// chain. `minFinalityThreshold` is inferred from the presence of + /// a `PRE_FINALITY` item in the quote. If the quote contains a + /// `FORWARD` item, `hookData` must include a valid `cctp-forward` + /// hook or the call reverts with `ForwardFeeWithoutHook`. Conversely, + /// if `hookData` contains a `cctp-forward` hook but the quote omits + /// the `FORWARD` item, the call reverts with `ForwardHookWithoutFee` + /// — forwarding behavior must always be paid for. + /// + /// **Refund attribution:** see `depositForBurnWithFees`. + /// @param amount Amount of tokens to burn. + /// @param destinationDomain Destination domain. + /// @param mintRecipient Mint recipient on destination domain. + /// @param burnToken Token to burn. + /// @param destinationCaller Authorized caller on destination domain. + /// @param hookData Hook data for destination domain. Must not + /// be empty. The caller controls the hook + /// format; no automatic reformatting is applied. + /// @param claim `IFeeManager.QuoteClaim` bundle. + function depositForBurnWithHookAndFees( + uint256 amount, + uint32 destinationDomain, + bytes32 mintRecipient, + address burnToken, + bytes32 destinationCaller, + bytes calldata hookData, + IFeeManager.QuoteClaim calldata claim + ) external payable; + + /// @notice Same as the `QuoteClaim` overload, but the caller passes an already-decoded `Quote` + /// body bundled with the `refundAddress` via `DecodedQuoteClaim`. + /// @param claim `IFeeManager.DecodedQuoteClaim` bundle. + function depositForBurnWithHookAndFees( + uint256 amount, + uint32 destinationDomain, + bytes32 mintRecipient, + address burnToken, + bytes32 destinationCaller, + bytes calldata hookData, + IFeeManager.DecodedQuoteClaim calldata claim + ) external payable; + + /// @notice Return the fee amount and token present in a quote for the fee + /// types this wrapper supports (`FORWARD` and `PRE_FINALITY`). + /// @dev Decodes the signed quote in-process and computes the sum via + /// `QuoteLib.sumFees` (the same library `FeeManager.sumFees` + /// delegates to). This is a lightweight type-based query for + /// quotes assumed to be valid; quote-argument, signature, expiry, + /// nonce, and payment validation happen only during collection. + /// Empty `signedQuote` returns `(0, address(0))`, matching + /// `FeeManager.sumFees`, `getQuoteTotal`, and `getQuoteItem`. + /// @param signedQuote Version-prefixed, ABI-encoded `Quote` struct. + /// @return totalFee Sum of supported quote-type amounts. + /// @return feeToken Fee token (`address(0)` for native). + function getFee(bytes calldata signedQuote) external pure returns (uint256 totalFee, address feeToken); + + /// @notice Same as the signed-quote overload, but accepts a decoded `Quote`. + function getFee(IFeeManager.Quote calldata decodedQuote) external pure returns (uint256 totalFee, address feeToken); + + /// @notice Returns the address of the `TokenMessengerV2` contract. + function tokenMessenger() external view returns (address); + + /// @notice Returns the address of the `FeeManager` proxy contract. + function feeManager() external view returns (address); +} diff --git a/src/interfaces/IUSDCBlacklist.sol b/src/interfaces/IUSDCBlacklist.sol new file mode 100644 index 0000000..3f18b3a --- /dev/null +++ b/src/interfaces/IUSDCBlacklist.sol @@ -0,0 +1,27 @@ +/** + * Copyright 2026 Circle Internet Group, Inc. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + */ +pragma solidity ^0.8.20; + +/// @title IUSDCBlacklist +/// @notice Minimal view interface for USDC blacklist checks (see +/// `Blacklistable` in stablecoin-evm). Used by `FeeManager` to expose +/// the same list `FeeTaker` consults for fee screening. +interface IUSDCBlacklist { + /// @notice Returns true if `account` is blocked from sending/receiving. + function isBlacklisted(address account) external view returns (bool); +} diff --git a/src/interfaces/IWithFeeCollectionAddress.sol b/src/interfaces/IWithFeeCollectionAddress.sol new file mode 100644 index 0000000..1e1f33d --- /dev/null +++ b/src/interfaces/IWithFeeCollectionAddress.sol @@ -0,0 +1,55 @@ +/** + * Copyright 2026 Circle Internet Group, Inc. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + */ +pragma solidity ^0.8.20; + +/// @title IWithFeeCollectionAddress +/// @notice Interface for a role-gated, optional override of the address that +/// `FeeManager` forwards to `IFeeTaker.takeFeeNative` / +/// `IFeeTaker.takeFeeERC20` (the recipient of the fee payout). +/// @dev The override is opt-in: when unset (zero address), implementations +/// must fall back to the prior default +/// (`IFeeTaker.protocolFeeAddress()`). +interface IWithFeeCollectionAddress { + /// @notice Emitted when the fee collection address override is changed to a new value. + /// @dev Only emitted on an actual state change. A `setFeeCollectionAddress` call that + /// supplies the currently-stored value is a no-op and emits no event. + /// @param newFeeCollectionAddress The new fee collection address. The zero + /// address means "use the default" (i.e. + /// fall back to + /// `IFeeTaker.protocolFeeAddress()`). + event FeeCollectionAddressSet(address indexed newFeeCollectionAddress); + + /// @notice Set the fee collection address forwarded to `IFeeTaker` on + /// `takeFee*` calls. + /// @dev Only callable by holders of `FEE_COLLECTION_ADMIN_ROLE`. Pass + /// `address(0)` to clear the override and revert to the default + /// (`IFeeTaker.protocolFeeAddress()`). The address must be + /// `payable` because `IFeeTaker.takeFeeNative` forwards native + /// currency to it. + /// + /// No-op when `newFeeCollectionAddress` already equals the stored + /// value: no SSTORE, no `FeeCollectionAddressSet` event. + /// @param newFeeCollectionAddress The new fee collection address, or + /// `address(0)` to use the default. + function setFeeCollectionAddress(address payable newFeeCollectionAddress) external; + + /// @notice Returns the currently configured fee collection address override. + /// @return The configured address, or `address(0)` when no override is set + /// (default behavior is in effect). + function feeCollectionAddress() external view returns (address payable); +} diff --git a/src/interfaces/IWithTrustedQuoteSigners.sol b/src/interfaces/IWithTrustedQuoteSigners.sol new file mode 100644 index 0000000..b9ec804 --- /dev/null +++ b/src/interfaces/IWithTrustedQuoteSigners.sol @@ -0,0 +1,42 @@ +/** + * Copyright 2026 Circle Internet Group, Inc. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + */ +pragma solidity ^0.8.20; + +/// @title IWithTrustedQuoteSigners +/// @notice Interface for a role-gated set of trusted quote signers used to +/// validate off-chain fee quotes on-chain. +interface IWithTrustedQuoteSigners { + /// @notice Emitted when a trusted quote signer is added or removed. + /// @param signer The address whose trusted status was updated. + /// @param trusted Whether the address is now trusted to sign quotes. + event TrustedQuoteSignerSet(address indexed signer, bool trusted); + + /// @notice Thrown when `address(0)` is passed to `setTrustedQuoteSigner`. + error ZeroTrustedQuoteSignerAddress(); + + /// @notice Add or remove a trusted quote signer. + /// @dev Only callable by holders of `SIGNER_ADMIN_ROLE`. + /// @param signer The address to update. + /// @param trusted True to trust, false to revoke. + function setTrustedQuoteSigner(address signer, bool trusted) external; + + /// @notice Returns whether `signer` is currently trusted to sign quotes. + /// @param signer The address to query. + /// @return True if the address is trusted. + function isTrustedQuoteSigner(address signer) external view returns (bool); +} diff --git a/src/interfaces/README.md b/src/interfaces/README.md new file mode 100644 index 0000000..7cc4ee6 --- /dev/null +++ b/src/interfaces/README.md @@ -0,0 +1,64 @@ +# Usage example + +## Paying fees with a signed quote + +`FeeManager` is a UUPS proxy: deploy the implementation and proxy, then call `initialize(owner)` once on the **proxy** (see `script/deploy/DeployFeeManager.s.sol`). That sets `Ownable` ownership and grants `owner` `DEFAULT_ADMIN_ROLE`. `ALLOWED_CALLER_ROLE` is administered by `CALLER_ADMIN_ROLE` (see `src/constants/Roles.sol`). The deploy script may grant `CALLER_ADMIN_ROLE` / `SIGNER_ADMIN_ROLE` when `FEE_MANAGER_CALLER_ADMIN` / `FEE_MANAGER_SIGNER_ADMIN` are set; otherwise the owner assigns those roles later. Optional `FEE_MANAGER_TRUSTED_SIGNER` / `FEE_MANAGER_PAUSER` are also configured in that script. Use the proxy address as `feeManager` below. + +```solidity +import {IAccessControl} from "@openzeppelin/contracts/access/IAccessControl.sol"; +import {Roles} from "../constants/Roles.sol"; +import {IWithTrustedQuoteSigners} from "../interfaces/IWithTrustedQuoteSigners.sol"; + +// 1. Register allowed caller (requires CALLER_ADMIN_ROLE on msg.sender) +IAccessControl(feeManager).grantRole(Roles.ALLOWED_CALLER_ROLE, caller); + +// 1b. Register trusted quote signer (requires SIGNER_ADMIN_ROLE) +IWithTrustedQuoteSigners(feeManager).setTrustedQuoteSigner(quoteSignerAddress, true); + +// 2. Build requests (from caller) +IFeeManager.FeeWithQuoteRequest[] memory requests = new IFeeManager.FeeWithQuoteRequest[](4); + +requests[0].quoteType = "FORWARD"; +requests[0].quoteArgs = new string[](4); +requests[0].quoteArgs[0] = destinationDomain; +requests[0].quoteArgs[1] = messageTransmitterAddress; +requests[0].quoteArgs[2] = "receiveMessage"; +requests[0].quoteArgs[3] = messageRecipientAddress; +requests[0].required = false; + +requests[1].quoteType = "PRE_FINALITY"; +requests[1].quoteArgs = new string[](2); +requests[1].quoteArgs[0] = messageValueAsset; +requests[1].quoteArgs[1] = messageValueAmount; +requests[1].required = false; + +requests[2].quoteType = "PROTOCOL"; +requests[2].quoteArgs = new string[](1); +requests[2].quoteArgs[0] = tokenID; +requests[2].required = true; + +requests[3].quoteType = "ISSUER"; +requests[3].quoteArgs = new string[](1); +requests[3].quoteArgs[0] = tokenID; +requests[3].required = true; + +// 3. Prepare fee payment for an assumed-valid quote by summing the quote types this caller may collect. +string[] memory quoteTypes = new string[](4); +quoteTypes[0] = "FORWARD"; +quoteTypes[1] = "PRE_FINALITY"; +quoteTypes[2] = "PROTOCOL"; +quoteTypes[3] = "ISSUER"; +(uint256 totalFee, address feeToken) = IFeeManager(feeManager).sumFees(signedQuote, quoteTypes); +// If feeToken == address(0), send totalFee as msg.value. Otherwise approve totalFee to FeeManager. +// collectFeesWithQuote performs the full quote-argument, signature, expiry, and payment validation. + +// 4. Collect fees +IFeeManager(feeManager).collectFeesWithQuote( + keccak256(message), // correlationHash + requests, + IFeeManager.QuoteClaim({ + signedQuote: signedQuote, + refundAddress: payable(endUser) // refund attribution, if any, is recorded here + }) +); +``` diff --git a/src/interfaces/cctp/IMessageTransmitterV2.sol b/src/interfaces/cctp/IMessageTransmitterV2.sol new file mode 100644 index 0000000..76150af --- /dev/null +++ b/src/interfaces/cctp/IMessageTransmitterV2.sol @@ -0,0 +1,27 @@ +/** + * Copyright 2026 Circle Internet Group, Inc. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + */ +pragma solidity ^0.8.20; + +/// @title IMessageTransmitterV2 +/// @notice Minimal interface for the existing CCTPv2 MessageTransmitterV2 contract. +/// Only includes the functions required by TokenMessengerWithFees. +/// @dev Original: https://github.com/circlefin/evm-cctp-contracts +interface IMessageTransmitterV2 { + /// @notice Returns the CCTP domain ID of the local chain. + function localDomain() external view returns (uint32); +} diff --git a/src/interfaces/cctp/ITokenMessengerV2.sol b/src/interfaces/cctp/ITokenMessengerV2.sol new file mode 100644 index 0000000..e0152b4 --- /dev/null +++ b/src/interfaces/cctp/ITokenMessengerV2.sol @@ -0,0 +1,59 @@ +/** + * Copyright 2026 Circle Internet Group, Inc. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + */ +pragma solidity ^0.8.20; + +/// @title ITokenMessengerV2 +/// @notice Minimal interface for the existing CCTPv2 TokenMessengerV2 contract. +/// Only includes the functions required by TokenMessengerWithFees. +/// @dev Original: https://github.com/circlefin/evm-cctp-contracts +interface ITokenMessengerV2 { + /// @notice Deposit tokens for burn and send a cross-chain message. + function depositForBurn( + uint256 amount, + uint32 destinationDomain, + bytes32 mintRecipient, + address burnToken, + bytes32 destinationCaller, + uint256 maxFee, + uint32 minFinalityThreshold + ) external; + + /// @notice Deposit tokens for burn with hook data and send a cross-chain message. + function depositForBurnWithHook( + uint256 amount, + uint32 destinationDomain, + bytes32 mintRecipient, + address burnToken, + bytes32 destinationCaller, + uint256 maxFee, + uint32 minFinalityThreshold, + bytes calldata hookData + ) external; + + /// @notice Returns the address of the local MessageTransmitterV2 contract. + function localMessageTransmitter() external view returns (address); + + /// @notice Returns the BurnMessage body version used by this TokenMessenger. + function messageBodyVersion() external view returns (uint32); + + /// @notice Returns the remote TokenMessenger address for a given domain, as bytes32. + function remoteTokenMessengers(uint32 domain) external view returns (bytes32); + + /// @notice Returns whether the given address is on the denylist. + function isDenylisted(address addr) external view returns (bool); +} diff --git a/src/libraries/AddressUtils.sol b/src/libraries/AddressUtils.sol new file mode 100644 index 0000000..546a682 --- /dev/null +++ b/src/libraries/AddressUtils.sol @@ -0,0 +1,28 @@ +/** + * Copyright 2026 Circle Internet Group, Inc. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + */ +pragma solidity ^0.8.20; + +/// @title AddressUtils Library +/// @notice Helper functions for converting addresses to and from bytes. +library AddressUtils { + /// @notice Converts an address to bytes32 by left-padding with zeros (alignment preserving cast). + /// @param addr The address to convert to bytes32. + function toBytes32(address addr) internal pure returns (bytes32) { + return bytes32(uint256(uint160(addr))); + } +} diff --git a/src/libraries/BurnMessageV2Lib.sol b/src/libraries/BurnMessageV2Lib.sol new file mode 100644 index 0000000..0fad682 --- /dev/null +++ b/src/libraries/BurnMessageV2Lib.sol @@ -0,0 +1,70 @@ +/** + * Copyright 2026 Circle Internet Group, Inc. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + */ +pragma solidity ^0.8.20; + +/// @title BurnMessageV2Lib +/// @notice Minimal library for encoding CCTP v2 burn message bodies. +/// Replicates the wire format from circlefin/evm-cctp-contracts +/// BurnMessageV2.sol which cannot be imported directly due to +/// compiler version incompatibility. +/// @dev Original: https://github.com/circlefin/evm-cctp-contracts +/// @dev Relay layout reference: https://github.com/circlefin/evm-cctp-contracts/blob/master/src/messages/v2/BurnMessageV2.sol +/// (`_formatMessageForRelay`). +/// @dev Field layout (packed, no padding between fields): +/// +/// Field Bytes Type Offset +/// version 4 uint32 0 +/// burnToken 32 bytes32 4 +/// mintRecipient 32 bytes32 36 +/// amount 32 uint256 68 +/// messageSender 32 bytes32 100 +/// maxFee 32 uint256 132 +/// feeExecuted 32 uint256 164 +/// expirationBlock 32 uint256 196 +/// hookData dynamic bytes 228 +library BurnMessageV2Lib { + /// @dev Matches upstream `_formatMessageForRelay` when fees are unset on burn. + /// Same value as `maxFee` passed to `TokenMessengerV2.depositForBurn` when fees are prepaid on the source chain (via `FeeManager`). + uint256 internal constant EMPTY_MAX_FEE = 0; + uint256 private constant EMPTY_FEE_EXECUTED = 0; + uint256 private constant EMPTY_EXPIRATION_BLOCK = 0; + + /// @notice Encodes a burn message body with optional hook data appended. + /// @dev `maxFee`, `feeExecuted`, and `expirationBlock` are encapsulated as + /// constants matching the upstream `_formatMessageForRelay`. + function formatMessage( + uint32 version, + bytes32 burnToken, + bytes32 mintRecipient, + uint256 amount, + bytes32 messageSender, + bytes memory hookData + ) internal pure returns (bytes memory) { + return abi.encodePacked( + version, + burnToken, + mintRecipient, + amount, + messageSender, + EMPTY_MAX_FEE, + EMPTY_FEE_EXECUTED, + EMPTY_EXPIRATION_BLOCK, + hookData + ); + } +} diff --git a/src/libraries/HookDataLib.sol b/src/libraries/HookDataLib.sol new file mode 100644 index 0000000..2decf25 --- /dev/null +++ b/src/libraries/HookDataLib.sol @@ -0,0 +1,96 @@ +/** + * Copyright 2026 Circle Internet Group, Inc. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + */ +pragma solidity ^0.8.20; + +/// @title HookDataLib +/// @notice Utilities for parsing CCTP v2 hook data. +/// @dev Hook data may contain one or more chained hooks. Each hook has: +/// +/// Offset Bytes Type Field +/// 0 24 bytes24 Hook name (e.g. "cctp-forward", right-padded) +/// 24 4 uint32 Version +/// 28 4 uint32 Length of Circle hook data that follows +/// 32 var bytes Circle hook data (length from above) +/// +/// The next hook (if any) starts immediately after the Circle data. +/// This layout is backwards-compatible with a single-hook payload. +/// +/// Reference: https://developers.circle.com/cctp/concepts/forwarding-service#hook-format +library HookDataLib { + /// @dev Per-hook header size: 24 (name) + 4 (version) + 4 (circle data length). + uint256 private constant HOOK_HEADER_LENGTH = 32; + + // forge-lint: disable-next-line(unsafe-typecast) + bytes24 internal constant FORWARD_MAGIC = bytes24("cctp-forward"); + + struct Hook { + bytes24 name; + uint32 version; + bytes circleData; + } + + /// @notice Returns a minimal `cctp-forward` hook (version 0, no Circle data). + function defaultForwardHookData() internal pure returns (bytes memory) { + return abi.encodePacked(FORWARD_MAGIC, uint32(0), uint32(0)); + } + + /// @notice Searches `hookData` for a `cctp-forward` hook. + /// @param hookData Raw hook data from a CCTP burn call. + /// @return hook The parsed hook (zero-valued when not found). + /// @return found `true` if a forwarding hook was found. + function getForwardHook(bytes calldata hookData) internal pure returns (Hook memory hook, bool found) { + return _findHook(hookData, FORWARD_MAGIC); + } + + /// @dev Iterates through chained hooks looking for one whose name matches + /// `target`. Returns on the first match. If the remaining bytes are + /// too short for a valid header or the declared circle-data length + /// overflows the remaining data, iteration stops and `found` is false. + // slither-disable-next-line assembly + function _findHook(bytes calldata hookData, bytes24 target) private pure returns (Hook memory hook, bool found) { + uint256 offset = 0; + while (offset + HOOK_HEADER_LENGTH <= hookData.length) { + bytes24 name; + uint32 version; + uint32 circleDataLen; + + // Single calldataload reads the entire 32-byte header at once, + // avoiding per-slice bounds checks the compiler would otherwise + // emit (the while condition already guarantees 32 bytes available). + // word layout: [name: 24 bytes (bits 255..64)] [version: 4 bytes (bits 63..32)] [circleDataLen: 4 bytes (bits 31..0)] + assembly { + let word := calldataload(add(hookData.offset, offset)) + name := and(word, shl(64, not(0))) + version := and(shr(32, word), 0xFFFFFFFF) + circleDataLen := and(word, 0xFFFFFFFF) + } + + uint256 dataEnd = offset + HOOK_HEADER_LENGTH + circleDataLen; + if (dataEnd > hookData.length) break; + + if (name == target) { + hook.name = name; + hook.version = version; + hook.circleData = hookData[offset + HOOK_HEADER_LENGTH:dataEnd]; + return (hook, true); + } + + offset = dataEnd; + } + } +} diff --git a/src/libraries/MessageV2Lib.sol b/src/libraries/MessageV2Lib.sol new file mode 100644 index 0000000..187f9a2 --- /dev/null +++ b/src/libraries/MessageV2Lib.sol @@ -0,0 +1,94 @@ +/** + * Copyright 2025 Circle Internet Group, Inc. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + */ +pragma solidity ^0.8.20; + +/// @title MessageV2Lib +/// @notice Minimal library for encoding CCTP v2 messages and computing their +/// hash. Replicates the wire format from circlefin/evm-cctp-contracts +/// MessageV2.sol (Solidity 0.7.6) which cannot be imported directly +/// due to compiler version incompatibility. +/// @dev Original: https://github.com/circlefin/evm-cctp-contracts/blob/master/src/messages/v2/MessageV2.sol +/// @dev Field layout (packed, no padding between fields): +/// +/// Field Bytes Type Offset +/// version 4 uint32 0 +/// sourceDomain 4 uint32 4 +/// destinationDomain 4 uint32 8 +/// nonce 32 bytes32 12 +/// sender 32 bytes32 44 +/// recipient 32 bytes32 76 +/// destinationCaller 32 bytes32 108 +/// minFinalityThreshold 4 uint32 140 +/// finalityThresholdExecuted 4 uint32 144 +/// messageBody dynamic bytes 148 +library MessageV2Lib { + uint32 private constant VERSION = 1; + bytes32 private constant EMPTY_NONCE = bytes32(0); + uint32 private constant EMPTY_FINALITY_THRESHOLD_EXECUTED = 0; + + /// @notice Encodes a CCTP v2 message in the relay format and returns its + /// keccak256 hash. This is the `correlationHash` used by + /// `FeeManager.collectFeesWithQuote`. + /// @dev `version`, `nonce`, and `finalityThresholdExecuted` are + /// encapsulated as constants matching the upstream + /// `_formatMessageForRelay`. + /// @return messageHash keccak256 of the packed message bytes. + function messageHash( + uint32 sourceDomain, + uint32 destinationDomain, + bytes32 sender, + bytes32 recipient, + bytes32 destinationCaller, + uint32 minFinalityThreshold, + bytes memory messageBody + ) internal pure returns (bytes32) { + return keccak256( + formatMessage( + sourceDomain, destinationDomain, sender, recipient, destinationCaller, minFinalityThreshold, messageBody + ) + ); + } + + /// @notice Encodes a CCTP v2 message in the relay format. + /// @dev Uses `VERSION = 1`, `EMPTY_NONCE`, and + /// `EMPTY_FINALITY_THRESHOLD_EXECUTED` matching the upstream + /// `_formatMessageForRelay`. + /// @return The packed message bytes. + function formatMessage( + uint32 sourceDomain, + uint32 destinationDomain, + bytes32 sender, + bytes32 recipient, + bytes32 destinationCaller, + uint32 minFinalityThreshold, + bytes memory messageBody + ) internal pure returns (bytes memory) { + return abi.encodePacked( + VERSION, + sourceDomain, + destinationDomain, + EMPTY_NONCE, + sender, + recipient, + destinationCaller, + minFinalityThreshold, + EMPTY_FINALITY_THRESHOLD_EXECUTED, + messageBody + ); + } +} diff --git a/src/libraries/QuoteLib.sol b/src/libraries/QuoteLib.sol new file mode 100644 index 0000000..3674f85 --- /dev/null +++ b/src/libraries/QuoteLib.sol @@ -0,0 +1,208 @@ +/** + * Copyright 2026 Circle Internet Group, Inc. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + */ +pragma solidity ^0.8.20; + +import {IFeeManager} from "../interfaces/IFeeManager.sol"; +import {SafeCast} from "@openzeppelin/contracts/utils/math/SafeCast.sol"; + +/// @title QuoteLib +/// @notice Helpers for `IFeeManager.Quote`: line-item lookup, fee totals, and packed `expiry` validation. +library QuoteLib { + /// @param expiryMode High byte of encoded expiry: `0` = timestamp deadline, `1` = block-number deadline. + /// @param deadline Lower 248 bits of encoded expiry (Unix time or block height, per mode). + /// @param current `block.timestamp` in timestamp mode, `block.number` in block-number mode. + error QuoteExpired(uint8 expiryMode, uint256 deadline, uint256 current); + error UnsupportedExpiryMode(uint8 mode); + error NonZeroNonce(uint256 nonce); + + /// @dev High byte (bits 255–248) of `encodedExpiry`. + uint8 internal constant MODE_TIMESTAMP = 0; + uint8 internal constant MODE_BLOCK_NUMBER = 1; + + /// @notice `keccak256(abi.encode(quoteArgs))` — same encoding as `QuoteItem.quoteArgsHash` on signed quotes. + function hashQuoteArgs(string[] calldata quoteArgs) internal pure returns (bytes32) { + return keccak256(abi.encode(quoteArgs)); + } + + /// @notice Whether this line item’s `quoteArgsHash` matches the given request arguments. + /// @dev Use as `qItem.matchesRequest(quoteArgs)` via `using QuoteLib for IFeeManager.QuoteItem`. + function matchesRequest(IFeeManager.QuoteItem memory item, string[] calldata quoteArgs) + internal + pure + returns (bool) + { + return item.quoteArgsHash == hashQuoteArgs(quoteArgs); + } + + /// @notice First line item whose `quoteType` matches (hashed string equality). + /// @dev `matched` means a line with that type exists. Use `item.matchesRequest(request.quoteArgs)` to verify args, + /// or `quote.findItemForRequest(request)` to do the type lookup and args/required check in one step. + /// When `matched` is false, `item` is zeroed and `itemIndex` is 0. + /// @dev Use as `quote.findItemByType(quoteType)` via `using QuoteLib for IFeeManager.Quote`. + /// @param quoteType `string memory` so callers may pass calldata strings or `QuoteTypes` constants. + function findItemByType(IFeeManager.Quote memory quote, string memory quoteType) + internal + pure + returns (IFeeManager.QuoteItem memory item, uint256 itemIndex, bool matched) + { + (uint256 idx, bool found) = _itemIndexOf(quote, quoteType); + if (found) { + return (quote.items[idx], idx, true); + } + return (item, 0, false); + } + + /// @notice Whether any line item has the given `quoteType` (hashed string equality). + /// @dev Use as `quote.hasItemType(quoteType)` via `using QuoteLib for IFeeManager.Quote`. + function hasItemType(IFeeManager.Quote memory quote, string memory quoteType) internal pure returns (bool) { + (uint256 itemIndex, bool matched) = _itemIndexOf(quote, quoteType); + // Uses `itemIndex` so analyzers do not flag an unused tuple return from `_itemIndexOf`. + assert(!matched || itemIndex < quote.items.length); + return matched; + } + + /// @notice Require quote items to fit the duplicate-detection bitmap. + function requireSupportedItemCount(IFeeManager.Quote memory quote) internal pure { + if (quote.items.length > 256) { + revert IFeeManager.TooManyQuoteItems(quote.items.length); + } + } + + /// @notice Require `nonce == 0`; the field is reserved for future use. + function requireZeroNonce(IFeeManager.Quote memory quote) internal pure { + if (quote.nonce != 0) { + revert NonZeroNonce(quote.nonce); + } + } + + /// @notice Locate the quote line item that satisfies a request: same `quoteType`, + /// and `quoteArgsHash` consistent with `request.quoteArgs`. + /// @dev If a line item with `request.quoteType` exists, its `quoteArgsHash` + /// must equal `hashQuoteArgs(request.quoteArgs)` or the call reverts + /// with `QuoteArgsMismatch`. If no such line item exists and + /// `request.required` is true, the call reverts with + /// `RequiredFeeNotInQuote`. Otherwise returns `matched == false` and a + /// zeroed `item`/`itemIndex`. This is the per-request quote-structure + /// validation step used by `FeeManager._collectMatchedItems`; it does + /// not check per-call duplicate request types, expiry, or quote signer. + function findItemForRequest(IFeeManager.Quote memory quote, IFeeManager.FeeWithQuoteRequest calldata request) + internal + pure + returns (IFeeManager.QuoteItem memory item, uint256 itemIndex, bool matched) + { + (item, itemIndex, matched) = findItemByType(quote, request.quoteType); + + if (matched) { + if (!matchesRequest(item, request.quoteArgs)) { + revert IFeeManager.QuoteArgsMismatch(request.quoteType, hashQuoteArgs(request.quoteArgs)); + } + } else if (request.required) { + revert IFeeManager.RequiredFeeNotInQuote(request.quoteType, hashQuoteArgs(request.quoteArgs)); + } + } + + /// @notice Return the amount of `feeToken` found for the supplied quote types. + /// @dev Lightweight query helper for callers that already trust the quote + /// context. It intentionally matches by type only and does not verify + /// quote arguments, missing required fees, nonce, expiry, or signer. + /// Full validation remains in `FeeManager.collectFeesWithQuote`, + /// which is what callers should rely on for actual fee collection. + /// @dev Reverts `TooManyQuoteItems` when `quote.items.length > 256` so the + /// preview agrees with `collectFeesWithQuote`, which always rejects + /// such quotes via `requireSupportedItemCount`. + /// @dev Duplicate entries in `quoteTypes` are silently deduplicated so the + /// returned `totalFee` mirrors what `collectFeesWithQuote` would + /// actually charge (which rejects per-call duplicate matches with + /// `DuplicateRequestType`). Dedup uses the same matched-`itemIndex` + /// bitmap shape as FeeManager's per-call duplicate detection. + function sumFees(IFeeManager.Quote memory quote, string[] memory quoteTypes) + internal + pure + returns (uint256 totalFee, address feeToken) + { + requireSupportedItemCount(quote); + feeToken = quote.feeToken; + uint256 seenItemBitmap = 0; + for (uint256 i = 0; i < quoteTypes.length; ++i) { + (IFeeManager.QuoteItem memory item, uint256 itemIndex, bool matched) = findItemByType(quote, quoteTypes[i]); + if (!matched) continue; + uint256 bit = uint256(1) << itemIndex; + if (seenItemBitmap & bit != 0) continue; + seenItemBitmap |= bit; + totalFee += item.amount; + } + } + + /// @notice Sum of all `QuoteItem.amount` values and the quote’s `feeToken`. + /// @dev Use as `quote.totalFeeAndFeeToken()` via `using QuoteLib for IFeeManager.Quote`. + function totalFeeAndFeeToken(IFeeManager.Quote memory quote) + internal + pure + returns (uint256 totalFee, address feeToken) + { + feeToken = quote.feeToken; + IFeeManager.QuoteItem[] memory items = quote.items; + for (uint256 i = 0; i < items.length; ++i) { + totalFee += items[i].amount; + } + } + + /// @dev Timestamp mode (`0x00`): valid while `block.timestamp <= deadline`. + /// Block-number mode (`0x01`): valid while `block.number <= deadline`. + /// Reverts `QuoteExpired` or `UnsupportedExpiryMode` otherwise. + /// @dev Use as `encodedExpiry.validateExpiry()` via `using QuoteLib for uint256`. + function validateExpiry(uint256 encodedExpiry) internal view { + uint256 modeWord = encodedExpiry >> 248; + uint8 mode = SafeCast.toUint8(modeWord); + uint256 deadline = encodedExpiry & type(uint248).max; + if (mode == MODE_TIMESTAMP) { + // Intentional: in timestamp mode the signed quote TTL is wall-clock based. + // slither-disable-next-line timestamp + if (block.timestamp > deadline) { + revert QuoteExpired(mode, deadline, block.timestamp); + } + } else if (mode == MODE_BLOCK_NUMBER) { + if (block.number > deadline) { + revert QuoteExpired(mode, deadline, block.number); + } + } else { + revert UnsupportedExpiryMode(mode); + } + } + + /// @dev Use as `quote.mustNotExpire()` via `using QuoteLib for IFeeManager.Quote`. + function mustNotExpire(IFeeManager.Quote memory quote) internal view { + validateExpiry(quote.expiry); + } + + /// @dev Shared index lookup for `quoteType` (hashed string equality). Used by `findItemByType` and `hasItemType`. + function _itemIndexOf(IFeeManager.Quote memory quote, string memory quoteType) + private + pure + returns (uint256 itemIndex, bool matched) + { + bytes32 requestTypeHash = keccak256(bytes(quoteType)); + IFeeManager.QuoteItem[] memory items = quote.items; + for (uint256 i = 0; i < items.length; ++i) { + if (keccak256(bytes(items[i].quoteType)) == requestTypeHash) { + return (i, true); + } + } + return (0, false); + } +} diff --git a/src/libraries/SignedQuoteLib.sol b/src/libraries/SignedQuoteLib.sol new file mode 100644 index 0000000..71aeaf6 --- /dev/null +++ b/src/libraries/SignedQuoteLib.sol @@ -0,0 +1,87 @@ +/** + * Copyright 2026 Circle Internet Group, Inc. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + */ +pragma solidity ^0.8.20; + +import {IFeeManager} from "../interfaces/IFeeManager.sol"; + +/// @title SignedQuoteLib +/// @notice Decodes version-prefixed quote blobs `[uint8 version][abi.encode(Quote)]` in-process. +/// @dev Canonical wire decode used by `FeeManager.decodeQuote` (external API) and by any contract that inlines this +/// library (e.g. `TokenMessengerWithFees`, `FeeManager.collectFeesWithQuote` with raw bytes). +/// +/// **Success path:** Version byte must equal `QUOTE_WIRE_FORMAT_VERSION`. The ABI tail must be at least +/// `MIN_ABI_ENCODED_QUOTE_TAIL_BYTES` (minimal encoding for empty `items` and a 65-byte ECDSA signature). After +/// `abi.decode`, `signature.length` must be `ECDSA_SECP256K1_SIGNATURE_BYTES` (OpenZeppelin `ECDSA.recover`). +/// +/// **Custom errors (predictable selectors):** +/// - `SignedQuoteDecodeFailed` — empty input, tail too short, or signature length not 65. +/// - `UnsupportedQuoteVersion` — first byte is not the supported version. +/// +/// **Panic:** If cheap checks pass but the tail is still not valid ABI for `IFeeManager.Quote`, `abi.decode` +/// reverts with Solidity **Panic(0x21)** (ABI decode failure). Callers cannot catch this as a custom error +/// without an external call; treat any revert while decoding as failure. +library SignedQuoteLib { + /// @dev Version byte prefix for `[uint8 version][abi.encode(IFeeManager.Quote)]`. + uint8 public constant QUOTE_WIRE_FORMAT_VERSION = 1; + + /// @dev secp256k1 ECDSA signature length used by OpenZeppelin `ECDSA.recover` (r, s, v). + uint256 internal constant ECDSA_SECP256K1_SIGNATURE_BYTES = 65; + + /// @dev Minimum `abi.encode(IFeeManager.Quote)` tail length when `items` is empty and `signature` holds exactly + /// `ECDSA_SECP256K1_SIGNATURE_BYTES` (352). Shorter tails cannot encode a struct that `FeeManager` could + /// verify with `ECDSA.recover`; reject them before `abi.decode`. + uint256 internal constant MIN_ABI_ENCODED_QUOTE_TAIL_BYTES = 352; + + /// @notice Decodes a signed quote blob into `IFeeManager.Quote` memory. + /// @dev Reverts `SignedQuoteDecodeFailed` when the payload is empty, the ABI tail is shorter than the minimum for + /// an empty-items quote with a 65-byte signature, or the decoded `signature` length is not 65. Does not + /// catch all malformed tails; those may still fail inside `abi.decode` with **Panic(0x21)** — see above. + function decodeQuote(bytes calldata signedQuote) internal pure returns (IFeeManager.Quote memory quote) { + if (signedQuote.length == 0) { + revert IFeeManager.SignedQuoteDecodeFailed(); + } + // forge-lint: disable-next-line(unsafe-typecast) + // slither-disable-next-line unsafe-cast + // One calldata byte; value is always 0–255 (no truncation). + uint8 version = uint8(signedQuote[0]); + if (version != QUOTE_WIRE_FORMAT_VERSION) { + revert IFeeManager.UnsupportedQuoteVersion(version); + } + if (signedQuote.length - 1 < MIN_ABI_ENCODED_QUOTE_TAIL_BYTES) { + revert IFeeManager.SignedQuoteDecodeFailed(); + } + quote = abi.decode(signedQuote[1:], (IFeeManager.Quote)); + if (quote.signature.length != ECDSA_SECP256K1_SIGNATURE_BYTES) { + revert IFeeManager.SignedQuoteDecodeFailed(); + } + } + + /// @notice Decodes a wire-format `QuoteClaim` into a `DecodedQuoteClaim`: parses the signed quote bytes and pairs + /// the resulting `Quote` body with `claim.refundAddress`. + /// @dev Wire decode has the same revert surface as `decodeQuote`. `refundAddress` is copied through verbatim — this + /// library applies no defaulting policy. + function decodeClaim(IFeeManager.QuoteClaim calldata claim) + internal + pure + returns (IFeeManager.DecodedQuoteClaim memory) + { + return IFeeManager.DecodedQuoteClaim({ + decodedQuote: decodeQuote(claim.signedQuote), refundAddress: claim.refundAddress + }); + } +} diff --git a/src/storage/WithFeeCollectionAddressStorage.sol b/src/storage/WithFeeCollectionAddressStorage.sol new file mode 100644 index 0000000..d95e753 --- /dev/null +++ b/src/storage/WithFeeCollectionAddressStorage.sol @@ -0,0 +1,38 @@ +/** + * Copyright 2026 Circle Internet Group, Inc. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + */ +pragma solidity ^0.8.20; + +/// @title WithFeeCollectionAddressStorage +/// @notice EIP-7201 namespaced storage for WithFeeCollectionAddress +library WithFeeCollectionAddressStorage { + /// @dev keccak256(abi.encode(uint256(keccak256("feeorchestrator.storage.WithFeeCollectionAddress")) - 1)) & ~bytes32(uint256(0xff)) + bytes32 internal constant STORAGE_SLOT = 0x115995aaebe10c4a52214233cf5c663349854e10926ccaff7128ebf40098e900; + + /// @custom:storage-location erc7201:feeorchestrator.storage.WithFeeCollectionAddress + struct Layout { + address payable feeCollectionAddress; + } + + function layout() internal pure returns (Layout storage $) { + bytes32 slot = STORAGE_SLOT; + // slither-disable-next-line assembly + assembly { + $.slot := slot + } + } +} diff --git a/src/storage/WithTrustedQuoteSignersStorage.sol b/src/storage/WithTrustedQuoteSignersStorage.sol new file mode 100644 index 0000000..a00ae64 --- /dev/null +++ b/src/storage/WithTrustedQuoteSignersStorage.sol @@ -0,0 +1,38 @@ +/** + * Copyright 2026 Circle Internet Group, Inc. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + */ +pragma solidity ^0.8.20; + +/// @title WithTrustedQuoteSignersStorage +/// @notice EIP-7201 namespaced storage for WithTrustedQuoteSigners +library WithTrustedQuoteSignersStorage { + /// @dev keccak256(abi.encode(uint256(keccak256("feeorchestrator.storage.WithTrustedQuoteSigners")) - 1)) & ~bytes32(uint256(0xff)) + bytes32 internal constant STORAGE_SLOT = 0x235ef36c4f4f4cc7873a899b041e507ea2c4e1fdd0e5f135e4fd1b5e17c31f00; + + /// @custom:storage-location erc7201:feeorchestrator.storage.WithTrustedQuoteSigners + struct Layout { + mapping(address => bool) trustedQuoteSigner; + } + + function layout() internal pure returns (Layout storage $) { + bytes32 slot = STORAGE_SLOT; + // slither-disable-next-line assembly + assembly { + $.slot := slot + } + } +} From 6b4bf9f351e5b6e9aa10bbdd9de18fe796be2d67 Mon Sep 17 00:00:00 2001 From: undefined Date: Mon, 19 Oct 2026 03:42:37 +0000 Subject: [PATCH 02/21] =?UTF-8?q?=F0=9F=94=84=20created=20local=20'test/'?= =?UTF-8?q?=20from=20remote=20'test/'?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- test/.gitkeep | 0 test/BurnMessageV2Lib.t.sol | 225 ++ test/Create3Factory.t.sol | 379 ++++ test/Create3FactoryBytecodePin.t.sol | 77 + test/DeployCreate3Factory.t.sol | 47 + test/DeployCreate3FactoryViaCreate2.t.sol | 242 +++ test/DeployFeeManager.t.sol | 354 ++++ test/DeployTokenMessengerWithFees.t.sol | 187 ++ test/FeeManager.t.sol | 2265 +++++++++++++++++++++ test/HookDataLib.t.sol | 180 ++ test/MessageV2Lib.t.sol | 203 ++ test/QuoteLib.t.sol | 392 ++++ test/TestConstants.sol | 21 + test/TokenMessengerWithFees.t.sol | 1535 ++++++++++++++ test/UpgradeFeeManager.t.sol | 148 ++ test/UpgradeTokenMessengerWithFees.t.sol | 211 ++ test/WithFeeCollectionAddress.t.sol | 166 ++ test/WithTrustedQuoteSigners.t.sol | 141 ++ test/mocks/MockCreate2Factory.sol | 53 + test/mocks/MockMessageTransmitter.sol | 28 + test/mocks/MockTokenMessenger.sol | 28 + test/mocks/MockUSDCBlacklist.sol | 31 + test/mocks/MockUsdcAwareBurnToken.sol | 42 + 23 files changed, 6955 insertions(+) create mode 100644 test/.gitkeep create mode 100644 test/BurnMessageV2Lib.t.sol create mode 100644 test/Create3Factory.t.sol create mode 100644 test/Create3FactoryBytecodePin.t.sol create mode 100644 test/DeployCreate3Factory.t.sol create mode 100644 test/DeployCreate3FactoryViaCreate2.t.sol create mode 100644 test/DeployFeeManager.t.sol create mode 100644 test/DeployTokenMessengerWithFees.t.sol create mode 100644 test/FeeManager.t.sol create mode 100644 test/HookDataLib.t.sol create mode 100644 test/MessageV2Lib.t.sol create mode 100644 test/QuoteLib.t.sol create mode 100644 test/TestConstants.sol create mode 100644 test/TokenMessengerWithFees.t.sol create mode 100644 test/UpgradeFeeManager.t.sol create mode 100644 test/UpgradeTokenMessengerWithFees.t.sol create mode 100644 test/WithFeeCollectionAddress.t.sol create mode 100644 test/WithTrustedQuoteSigners.t.sol create mode 100644 test/mocks/MockCreate2Factory.sol create mode 100644 test/mocks/MockMessageTransmitter.sol create mode 100644 test/mocks/MockTokenMessenger.sol create mode 100644 test/mocks/MockUSDCBlacklist.sol create mode 100644 test/mocks/MockUsdcAwareBurnToken.sol diff --git a/test/.gitkeep b/test/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/test/BurnMessageV2Lib.t.sol b/test/BurnMessageV2Lib.t.sol new file mode 100644 index 0000000..e1967b9 --- /dev/null +++ b/test/BurnMessageV2Lib.t.sol @@ -0,0 +1,225 @@ +/** + * Copyright 2026 Circle Internet Group, Inc. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + */ +pragma solidity ^0.8.20; + +import {BurnMessageV2Lib} from "../src/libraries/BurnMessageV2Lib.sol"; +import {MessageV2Lib} from "../src/libraries/MessageV2Lib.sol"; +import {Test} from "forge-std/Test.sol"; + +/// @dev Test vectors derived from the same Base mainnet transaction used +/// in MessageV2Lib.t.sol: +/// https://basescan.org/tx/0x52b96e0f5a0459a830c590a31f8b057847356ff4d7b54a45f3394a5d684ba2c4 +contract BurnMessageV2LibTest is Test { + uint32 internal constant VERSION = 1; + bytes32 internal constant BURN_TOKEN = bytes32(uint256(uint160(0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913))); + bytes32 internal constant MINT_RECIPIENT = bytes32(uint256(uint160(0xC01f5E3dcdE4f1E8F1dC8Cf1232123DAEB917b8D))); + uint256 internal constant AMOUNT = 30_061_721; + bytes32 internal constant MESSAGE_SENDER = bytes32(uint256(uint160(0xC01f5E3dcdE4f1E8F1dC8Cf1232123DAEB917b8D))); + + /// @dev Full BurnMessageV2 body from the real transaction (relay format: trailing fee fields zero). + function _expectedRawBody() internal pure returns (bytes memory) { + return hex"00000001" hex"000000000000000000000000833589fcd6edb6e08f4c7c32d4f71b54bda02913" + hex"000000000000000000000000c01f5e3dcde4f1e8f1dc8cf1232123daeb917b8d" + hex"0000000000000000000000000000000000000000000000000000000001cab499" + hex"000000000000000000000000c01f5e3dcde4f1e8f1dc8cf1232123daeb917b8d" + hex"0000000000000000000000000000000000000000000000000000000000000000" // maxFee + hex"0000000000000000000000000000000000000000000000000000000000000000" // feeExecuted + hex"0000000000000000000000000000000000000000000000000000000000000000"; // expirationBlock + } + + // ───────────────────── real transaction verification ───────────────────── + + function test_formatMessage_matchesRealTransaction() public pure { + bytes memory encoded = + BurnMessageV2Lib.formatMessage(VERSION, BURN_TOKEN, MINT_RECIPIENT, AMOUNT, MESSAGE_SENDER, ""); + assertEq(keccak256(encoded), keccak256(_expectedRawBody())); + } + + // ───────────────────── encoding properties ───────────────────── + + function test_formatMessage_length() public pure { + bytes memory encoded = + BurnMessageV2Lib.formatMessage(VERSION, BURN_TOKEN, MINT_RECIPIENT, AMOUNT, MESSAGE_SENDER, ""); + assertEq(encoded.length, 228); + } + + function test_formatMessageWithHook_length() public pure { + bytes memory hookData = hex"deadbeef"; + bytes memory encoded = + BurnMessageV2Lib.formatMessage(VERSION, BURN_TOKEN, MINT_RECIPIENT, AMOUNT, MESSAGE_SENDER, hookData); + assertEq(encoded.length, 228 + hookData.length); + } + + function test_formatMessage_emptyHookData_length228() public pure { + bytes memory withoutHook = + BurnMessageV2Lib.formatMessage(VERSION, BURN_TOKEN, MINT_RECIPIENT, AMOUNT, MESSAGE_SENDER, ""); + bytes memory withEmptyHook = + BurnMessageV2Lib.formatMessage(VERSION, BURN_TOKEN, MINT_RECIPIENT, AMOUNT, MESSAGE_SENDER, ""); + assertEq(keccak256(withoutHook), keccak256(withEmptyHook)); + } + + function test_formatMessageWithHook_appendsHookData() public pure { + bytes memory hookData = hex"cafebabe"; + bytes memory encoded = + BurnMessageV2Lib.formatMessage(VERSION, BURN_TOKEN, MINT_RECIPIENT, AMOUNT, MESSAGE_SENDER, hookData); + bytes memory expected = _expectedRawBody(); + + // First 228 bytes match the base message (including zero maxFee / feeExecuted / expirationBlock) + bytes memory prefix = new bytes(228); + for (uint256 i = 0; i < 228; i++) { + prefix[i] = encoded[i]; + } + assertEq(keccak256(prefix), keccak256(expected)); + + // Trailing bytes match hookData + bytes memory suffix = new bytes(hookData.length); + for (uint256 i = 0; i < hookData.length; i++) { + suffix[i] = encoded[228 + i]; + } + assertEq(keccak256(suffix), keccak256(hookData)); + } + + // ───────────────────── field sensitivity ───────────────────── + + function test_versionAffectsHash() public pure { + bytes32 h1 = + keccak256(BurnMessageV2Lib.formatMessage(1, BURN_TOKEN, MINT_RECIPIENT, AMOUNT, MESSAGE_SENDER, "")); + bytes32 h2 = + keccak256(BurnMessageV2Lib.formatMessage(2, BURN_TOKEN, MINT_RECIPIENT, AMOUNT, MESSAGE_SENDER, "")); + assertTrue(h1 != h2); + } + + function test_burnTokenAffectsHash() public pure { + bytes32 h1 = keccak256( + BurnMessageV2Lib.formatMessage(VERSION, bytes32(uint256(1)), MINT_RECIPIENT, AMOUNT, MESSAGE_SENDER, "") + ); + bytes32 h2 = keccak256( + BurnMessageV2Lib.formatMessage(VERSION, bytes32(uint256(2)), MINT_RECIPIENT, AMOUNT, MESSAGE_SENDER, "") + ); + assertTrue(h1 != h2); + } + + function test_mintRecipientAffectsHash() public pure { + bytes32 h1 = keccak256( + BurnMessageV2Lib.formatMessage(VERSION, BURN_TOKEN, bytes32(uint256(1)), AMOUNT, MESSAGE_SENDER, "") + ); + bytes32 h2 = keccak256( + BurnMessageV2Lib.formatMessage(VERSION, BURN_TOKEN, bytes32(uint256(2)), AMOUNT, MESSAGE_SENDER, "") + ); + assertTrue(h1 != h2); + } + + function test_amountAffectsHash() public pure { + bytes32 h1 = + keccak256(BurnMessageV2Lib.formatMessage(VERSION, BURN_TOKEN, MINT_RECIPIENT, 100, MESSAGE_SENDER, "")); + bytes32 h2 = + keccak256(BurnMessageV2Lib.formatMessage(VERSION, BURN_TOKEN, MINT_RECIPIENT, 200, MESSAGE_SENDER, "")); + assertTrue(h1 != h2); + } + + function test_messageSenderAffectsHash() public pure { + bytes32 h1 = keccak256( + BurnMessageV2Lib.formatMessage(VERSION, BURN_TOKEN, MINT_RECIPIENT, AMOUNT, bytes32(uint256(1)), "") + ); + bytes32 h2 = keccak256( + BurnMessageV2Lib.formatMessage(VERSION, BURN_TOKEN, MINT_RECIPIENT, AMOUNT, bytes32(uint256(2)), "") + ); + assertTrue(h1 != h2); + } + + function test_hookDataAffectsHash() public pure { + bytes32 h1 = keccak256( + BurnMessageV2Lib.formatMessage(VERSION, BURN_TOKEN, MINT_RECIPIENT, AMOUNT, MESSAGE_SENDER, hex"aa") + ); + bytes32 h2 = keccak256( + BurnMessageV2Lib.formatMessage(VERSION, BURN_TOKEN, MINT_RECIPIENT, AMOUNT, MESSAGE_SENDER, hex"bb") + ); + assertTrue(h1 != h2); + } + + // ───────────────────── fuzz ───────────────────── + + function testFuzz_formatMessage_emptyHookData_length228( + uint32 version, + bytes32 burnToken, + bytes32 mintRecipient, + uint256 amount, + bytes32 messageSender + ) public pure { + bytes memory encoded = BurnMessageV2Lib.formatMessage( + version, burnToken, mintRecipient, amount, messageSender, "" + ); + assertEq(encoded.length, 228); + } + + function testFuzz_formatMessage_length( + uint32 version, + bytes32 burnToken, + bytes32 mintRecipient, + uint256 amount, + bytes32 messageSender, + bytes memory hookData + ) public pure { + bytes memory encoded = BurnMessageV2Lib.formatMessage( + version, burnToken, mintRecipient, amount, messageSender, hookData + ); + assertEq(encoded.length, 228 + hookData.length); + } + + // ───────────────────── cross-library: correlation hash ───────────────────── + + /// @dev `expectedHash` = `keccak256(abi.decode(messageSentLogData, (bytes)))`. + /// `messageSentLogData` is the raw `data` field from `MessageSent` on Base: + /// https://basescan.org/tx/0x52b96e0f5a0459a830c590a31f8b057847356ff4d7b54a45f3394a5d684ba2c4 + /// (MessageTransmitter `0x81D40F21F12A8F0E3252Bccb954D722d4c464B64`, log index 1045). + /// Extract with `cast receipt --rpc-url https://mainnet.base.org logs`. + function test_correlationHash_matchesKnownTransaction() public pure { + bytes memory messageSentLogData = hex"0000000000000000000000000000000000000000000000000000000000000020" + hex"0000000000000000000000000000000000000000000000000000000000000178" + hex"0000000100000006000000020000000000000000000000000000000000000000" + hex"00000000000000000000000000000000000000000000000028b5a0e9c621a5ba" + hex"daa536219b3a228c8168cf5d00000000000000000000000028b5a0e9c621a5ba" + hex"daa536219b3a228c8168cf5d0000000000000000000000000000000000000000" + hex"0000000000000000000000000000000000000000000000010000000000000000" + hex"00000000833589fcd6edb6e08f4c7c32d4f71b54bda029130000000000000000" + hex"00000000c01f5e3dcde4f1e8f1dc8cf1232123daeb917b8d0000000000000000" + hex"000000000000000000000000000000000000000001cab4990000000000000000" + hex"00000000c01f5e3dcde4f1e8f1dc8cf1232123daeb917b8d0000000000000000" + hex"0000000000000000000000000000000000000000000000000000000000000000" + hex"0000000000000000000000000000000000000000000000000000000000000000" + hex"0000000000000000000000000000000000000000000000000000000000000000"; + + bytes memory decodedMessageSentBytes = abi.decode(messageSentLogData, (bytes)); + bytes32 expectedHash = keccak256(decodedMessageSentBytes); + + bytes memory body = + BurnMessageV2Lib.formatMessage(VERSION, BURN_TOKEN, MINT_RECIPIENT, AMOUNT, MESSAGE_SENDER, ""); + + bytes32 hash = MessageV2Lib.messageHash( + 6, // sourceDomain (Base) + 2, // destinationDomain + bytes32(uint256(uint160(0x28b5a0e9C621a5BadaA536219b3a228C8168cf5d))), // sender + bytes32(uint256(uint160(0x28b5a0e9C621a5BadaA536219b3a228C8168cf5d))), // recipient + bytes32(0), // destinationCaller + 0, // minFinalityThreshold + body + ); + + assertEq(hash, expectedHash); + } +} diff --git a/test/Create3Factory.t.sol b/test/Create3Factory.t.sol new file mode 100644 index 0000000..93ac8f9 --- /dev/null +++ b/test/Create3Factory.t.sol @@ -0,0 +1,379 @@ +/** + * Copyright 2026 Circle Internet Group, Inc. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + */ +pragma solidity ^0.8.20; + +import {Roles} from "../src/constants/Roles.sol"; +import {Create3Factory} from "../src/contracts/Create3Factory.sol"; +import {FeeManager} from "../src/contracts/FeeManager.sol"; + +import {IAccessControl} from "@openzeppelin/contracts/access/IAccessControl.sol"; +import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol"; +import {ERC1967Proxy} from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol"; +import {Test} from "forge-std/Test.sol"; + +/// @dev Minimal contract used to verify CREATE3 deployment behaviour. +contract SimpleContract { + uint256 public value; + + constructor(uint256 value_) { + value = value_; + } + + function setValue(uint256 value_) external { + value = value_; + } +} + +/// @dev Contract whose constructor always reverts. +contract AlwaysReverts { + error Boom(); + + constructor() { + revert Boom(); + } +} + +contract Create3FactoryTest is Test { + Create3Factory internal factory; + + address internal owner = makeAddr("owner"); + address internal stranger = makeAddr("stranger"); + + bytes32 internal constant SALT = keccak256("test-salt"); + event Deployed(address indexed deployed, bytes32 indexed salt); + + function setUp() public { + factory = new Create3Factory(owner); + } + + // ───────────────────── constructor ───────────────────── + + function test_constructor_setsOwner() public view { + assertEq(factory.owner(), owner); + } + + // ───────────────────── deploy ───────────────────── + + function test_deploy_deploysAtPredictedAddress() public { + bytes memory bytecode = abi.encodePacked(type(SimpleContract).creationCode, abi.encode(42)); + + address predicted = factory.getAddress(SALT); + + vm.prank(owner); + address deployed = factory.deploy(SALT, bytecode); + + assertEq(deployed, predicted); + assertEq(SimpleContract(deployed).value(), 42); + } + + function test_deploy_emitsDeployedEvent() public { + bytes memory bytecode = abi.encodePacked(type(SimpleContract).creationCode, abi.encode(1)); + address predicted = factory.getAddress(SALT); + + vm.expectEmit(true, true, false, false); + emit Deployed(predicted, SALT); + + vm.prank(owner); + factory.deploy(SALT, bytecode); + } + + function test_deploy_revertsForNonOwner() public { + bytes memory bytecode = abi.encodePacked(type(SimpleContract).creationCode, abi.encode(1)); + + vm.prank(stranger); + vm.expectRevert(abi.encodeWithSelector(Ownable.OwnableUnauthorizedAccount.selector, stranger)); + factory.deploy(SALT, bytecode); + } + + function test_deploy_revertsOnDuplicateDeployment() public { + bytes memory bytecode = abi.encodePacked(type(SimpleContract).creationCode, abi.encode(1)); + + vm.prank(owner); + factory.deploy(SALT, bytecode); + + vm.prank(owner); + vm.expectRevert(); + factory.deploy(SALT, bytecode); + } + + function test_deploy_differentSaltsProduceDifferentAddresses() public { + bytes memory bytecode1 = abi.encodePacked(type(SimpleContract).creationCode, abi.encode(1)); + bytes memory bytecode2 = abi.encodePacked(type(SimpleContract).creationCode, abi.encode(2)); + bytes32 salt1 = keccak256("salt-1"); + bytes32 salt2 = keccak256("salt-2"); + + vm.prank(owner); + address addr1 = factory.deploy(salt1, bytecode1); + + vm.prank(owner); + address addr2 = factory.deploy(salt2, bytecode2); + + assertTrue(addr1 != addr2); + } + + function test_deploy_sameSaltDifferentBytecodeProducesSameAddress() public { + Create3Factory factory2 = new Create3Factory(owner); + + bytes memory bytecode1 = abi.encodePacked(type(SimpleContract).creationCode, abi.encode(1)); + bytes memory bytecode2 = abi.encodePacked(type(SimpleContract).creationCode, abi.encode(999)); + + vm.prank(owner); + address deployed1 = factory.deploy(SALT, bytecode1); + + vm.prank(owner); + address deployed2 = factory2.deploy(SALT, bytecode2); + + assertEq(deployed1, factory.getAddress(SALT)); + assertEq(deployed2, factory2.getAddress(SALT)); + + // Addresses differ because the factory addresses differ, not the bytecodes + assertTrue(deployed1 != deployed2); + } + + function test_deploy_emptyBytecodeReverts() public { + vm.prank(owner); + vm.expectRevert(); + factory.deploy(SALT, ""); + } + + // ───────────────────── getAddress ───────────────────── + + function test_getAddress_returnsConsistentAddress() public view { + address addr1 = factory.getAddress(SALT); + address addr2 = factory.getAddress(SALT); + + assertEq(addr1, addr2); + } + + function test_getAddress_differsBySalt() public view { + address addr1 = factory.getAddress(keccak256("a")); + address addr2 = factory.getAddress(keccak256("b")); + + assertTrue(addr1 != addr2); + } + + function test_getAddress_doesNotDependOnBytecode() public view { + address addr = factory.getAddress(SALT); + + assertEq(addr, factory.getAddress(SALT)); + } + + // ───────────────────── ownership ───────────────────── + + function test_transferOwnership_twoStepFlow() public { + address newOwner = makeAddr("newOwner"); + + vm.prank(owner); + factory.transferOwnership(newOwner); + + assertEq(factory.pendingOwner(), newOwner); + assertEq(factory.owner(), owner); + + vm.prank(newOwner); + factory.acceptOwnership(); + + assertEq(factory.owner(), newOwner); + assertEq(factory.pendingOwner(), address(0)); + } + + function test_renounceOwnership_reverts() public { + vm.prank(owner); + vm.expectRevert(Create3Factory.RenounceDisabled.selector); + factory.renounceOwnership(); + + assertEq(factory.owner(), owner); + } + + function test_transferOwnership_revertsForNonOwner() public { + vm.prank(stranger); + vm.expectRevert(abi.encodeWithSelector(Ownable.OwnableUnauthorizedAccount.selector, stranger)); + factory.transferOwnership(stranger); + } + + // ───────────────────── deployAndMultiCall ───────────────────── + + function test_deployAndMultiCall_deploysAndExecutesCalls() public { + bytes memory bytecode = abi.encodePacked(type(SimpleContract).creationCode, abi.encode(0)); + + bytes[] memory calls = new bytes[](1); + calls[0] = abi.encodeCall(SimpleContract.setValue, (99)); + + vm.prank(owner); + address deployed = factory.deployAndMultiCall(SALT, bytecode, calls); + + assertEq(SimpleContract(deployed).value(), 99); + } + + function test_deployAndMultiCall_deploysAtPredictedAddress() public { + bytes memory bytecode = abi.encodePacked(type(SimpleContract).creationCode, abi.encode(0)); + address predicted = factory.getAddress(SALT); + + bytes[] memory calls = new bytes[](1); + calls[0] = abi.encodeCall(SimpleContract.setValue, (1)); + + vm.prank(owner); + address deployed = factory.deployAndMultiCall(SALT, bytecode, calls); + + assertEq(deployed, predicted); + } + + function test_deployAndMultiCall_emitsDeployedEvent() public { + bytes memory bytecode = abi.encodePacked(type(SimpleContract).creationCode, abi.encode(0)); + address predicted = factory.getAddress(SALT); + + bytes[] memory calls = new bytes[](0); + + vm.expectEmit(true, true, false, false); + emit Deployed(predicted, SALT); + + vm.prank(owner); + factory.deployAndMultiCall(SALT, bytecode, calls); + } + + function test_deployAndMultiCall_executesMultipleCalls() public { + bytes memory bytecode = abi.encodePacked(type(SimpleContract).creationCode, abi.encode(0)); + + bytes[] memory calls = new bytes[](3); + calls[0] = abi.encodeCall(SimpleContract.setValue, (10)); + calls[1] = abi.encodeCall(SimpleContract.setValue, (20)); + calls[2] = abi.encodeCall(SimpleContract.setValue, (42)); + + vm.prank(owner); + address deployed = factory.deployAndMultiCall(SALT, bytecode, calls); + + assertEq(SimpleContract(deployed).value(), 42); + } + + function test_deployAndMultiCall_worksWithZeroCalls() public { + bytes memory bytecode = abi.encodePacked(type(SimpleContract).creationCode, abi.encode(7)); + + bytes[] memory calls = new bytes[](0); + + vm.prank(owner); + address deployed = factory.deployAndMultiCall(SALT, bytecode, calls); + + assertEq(SimpleContract(deployed).value(), 7); + } + + function test_deployAndMultiCall_revertsForNonOwner() public { + bytes memory bytecode = abi.encodePacked(type(SimpleContract).creationCode, abi.encode(0)); + bytes[] memory calls = new bytes[](0); + + vm.prank(stranger); + vm.expectRevert(abi.encodeWithSelector(Ownable.OwnableUnauthorizedAccount.selector, stranger)); + factory.deployAndMultiCall(SALT, bytecode, calls); + } + + function test_deployAndMultiCall_revertsAndBubblesErrorOnFailedCall() public { + bytes memory bytecode = abi.encodePacked(type(SimpleContract).creationCode, abi.encode(0)); + + bytes[] memory calls = new bytes[](1); + calls[0] = abi.encodeWithSignature("nonExistentFunction()"); + + vm.prank(owner); + vm.expectRevert(); + factory.deployAndMultiCall(SALT, bytecode, calls); + } + + function test_deployAndMultiCall_revertsOnEmptyBytecode() public { + bytes[] memory calls = new bytes[](0); + + vm.prank(owner); + vm.expectRevert(); + factory.deployAndMultiCall(SALT, "", calls); + } + + // ───────────────────── integration: proxy deployment ───────────────────── + + function test_integration_deploysFeeManagerImplementationAndProxy() public { + bytes32 implSalt = keccak256("FeeManager-impl"); + bytes32 proxySalt = keccak256("FeeManager-proxy"); + address feeTakerAddr = makeAddr("feeTaker"); + address usdcBlacklistAddr = makeAddr("usdcBlacklist"); + + vm.startPrank(owner); + + bytes memory implBytecode = + abi.encodePacked(type(FeeManager).creationCode, abi.encode(feeTakerAddr, usdcBlacklistAddr)); + address impl = factory.deploy(implSalt, implBytecode); + assertTrue(impl != address(0)); + + bytes memory initData = abi.encodeCall(FeeManager.initialize, (owner)); + bytes memory proxyBytecode = abi.encodePacked(type(ERC1967Proxy).creationCode, abi.encode(impl, initData)); + + address predicted = factory.getAddress(proxySalt); + address proxy = factory.deploy(proxySalt, proxyBytecode); + + vm.stopPrank(); + + assertEq(proxy, predicted); + assertTrue(proxy != impl); + + FeeManager fm = FeeManager(payable(proxy)); + assertEq(fm.owner(), owner); + assertEq(fm.feeTaker(), feeTakerAddr); + assertEq(fm.usdcBlacklist(), usdcBlacklistAddr); + } + + function test_integration_deployAndMultiCall_proxyWithRoleConfig() public { + bytes32 implSalt = keccak256("FeeManager-impl-mc"); + bytes32 proxySalt = keccak256("FeeManager-proxy-mc"); + address allowedCaller = makeAddr("allowedCaller"); + address feeTakerAddr = makeAddr("feeTaker"); + address usdcBlacklistAddr = makeAddr("usdcBlacklist"); + + vm.startPrank(owner); + + bytes memory implBytecode = + abi.encodePacked(type(FeeManager).creationCode, abi.encode(feeTakerAddr, usdcBlacklistAddr)); + address impl = factory.deploy(implSalt, implBytecode); + + bytes memory initData = abi.encodeCall(FeeManager.initialize, (address(factory))); + bytes memory proxyBytecode = abi.encodePacked(type(ERC1967Proxy).creationCode, abi.encode(impl, initData)); + + bytes[] memory calls = new bytes[](3); + calls[0] = abi.encodeCall(IAccessControl.grantRole, (Roles.CALLER_ADMIN_ROLE, address(factory))); + calls[1] = abi.encodeCall(IAccessControl.grantRole, (Roles.ALLOWED_CALLER_ROLE, allowedCaller)); + calls[2] = abi.encodeCall(Ownable.transferOwnership, (owner)); + + address proxy = factory.deployAndMultiCall(proxySalt, proxyBytecode, calls); + + vm.stopPrank(); + + FeeManager fm = FeeManager(payable(proxy)); + assertTrue(fm.hasRole(Roles.ALLOWED_CALLER_ROLE, allowedCaller)); + assertEq(fm.pendingOwner(), owner); + + vm.prank(owner); + fm.acceptOwnership(); + assertEq(fm.owner(), owner); + } + + // ───────────────────── fuzz ───────────────────── + + function testFuzz_getAddress_matchesDeploy(bytes32 salt, uint256 constructorArg) public { + address predicted = factory.getAddress(salt); + + bytes memory bytecode = abi.encodePacked(type(SimpleContract).creationCode, abi.encode(constructorArg)); + + vm.prank(owner); + address deployed = factory.deploy(salt, bytecode); + + assertEq(deployed, predicted); + } +} diff --git a/test/Create3FactoryBytecodePin.t.sol b/test/Create3FactoryBytecodePin.t.sol new file mode 100644 index 0000000..04a44ab --- /dev/null +++ b/test/Create3FactoryBytecodePin.t.sol @@ -0,0 +1,77 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.20; + +import {Create3Factory} from "../src/contracts/Create3Factory.sol"; +import {Test} from "forge-std/Test.sol"; + +/// @title Create3FactoryBytecodePin +/// @notice Hard pin on the keccak256 of `type(Create3Factory).creationCode` +/// under the production compile profile (`[profile.default]` in +/// `foundry.toml`). +/// +/// Create3Factory is the root of this repo's CREATE3 deployment tree: +/// - The factory itself is deployed via CREATE2 by an external +/// Create2Factory. Its address is a pure function of +/// (create2Factory, salt, keccak256(creationCode || abi.encode(initialOwner))), +/// so it IS sensitive to its own creation bytecode. +/// - Every other contract in the tree is deployed by calling +/// `Create3Factory.deploy(salt, ...)`. CREATE3 addresses depend +/// only on (Create3Factory address, salt) — they are NOT sensitive +/// to the deployed contract's bytecode, but they ARE transitively +/// sensitive to Create3Factory's bytecode (via its address). +/// +/// Net effect: shifting Create3Factory's creation bytecode by even +/// one byte shifts its own CREATE2 address, which in turn shifts the +/// CREATE3 address of every contract subsequently deployed through it. +/// Predicted addresses already shared with downstream consumers +/// (e.g. pre-signed transactions targeting those addresses) are +/// silently invalidated. +/// +/// Concretely, changing ANY of the following will fail this test: +/// - `Create3Factory.sol` source (or any of its imports — Ownable, etc.) +/// - `solc_version` / `auto_detect_solc` in foundry.toml +/// - `optimizer`, `optimizer_runs`, `via_ir` in foundry.toml +/// - `evm_version` in foundry.toml (changes CBOR metadata trailer) +/// - the default `bytecodeHash` setting (changes CBOR metadata trailer) +/// - any source-file relocation that alters `compilationTarget` paths +/// +/// If the change is intentional: +/// 1. Recompute the new hash (`cast keccak $(jq -r .bytecode.object +/// out/Create3Factory.sol/Create3Factory.json)`) +/// 2. Update `EXPECTED_CREATION_CODE_HASH` below +/// 3. Coordinate a republish of the predicted addresses with all +/// downstream consumers BEFORE merging — any address previously +/// derived from the old bytecode will no longer be reproducible. +/// +/// @dev Excluded from `forge coverage` via `--no-match-test '_pinnedBytecode'` +/// in package.json's coverage script: forge coverage compiles without +/// `via_ir`, producing different bytecode that would always fail this pin. +contract Create3FactoryBytecodePinTest is Test { + /// Hash of the prod-compiled Create3Factory creation bytecode (1839 bytes). + /// Matches what `DeployCreate3FactoryViaCreate2.s.sol` reads at deploy time + /// via `type(Create3Factory).creationCode`. + bytes32 internal constant EXPECTED_CREATION_CODE_HASH = + 0x0de7451b9011c389def842a10bcf9d81fb28abba2ae58dbc85436b7d194748a0; + + function test_create3FactoryCreationCodeHash_matchesPin_pinnedBytecode() public pure { + bytes32 actual = keccak256(type(Create3Factory).creationCode); + assertEq( + actual, + EXPECTED_CREATION_CODE_HASH, + "Create3Factory creation bytecode hash drifted. Any predicted addresses already " + "shared with downstream consumers are no longer reproducible. See test header for " + "the update + republish procedure." + ); + } + + /// @dev Sibling pin: the creation-code length. Catches accidental + /// regeneration of the constant without actually rebuilding (e.g. + /// someone copies a hash from a stale artifact). + function test_create3FactoryCreationCodeLength_matchesPin_pinnedBytecode() public pure { + assertEq( + type(Create3Factory).creationCode.length, + 1839, + "Create3Factory creation code length changed; bytecode pin almost certainly stale" + ); + } +} diff --git a/test/DeployCreate3Factory.t.sol b/test/DeployCreate3Factory.t.sol new file mode 100644 index 0000000..280ec2b --- /dev/null +++ b/test/DeployCreate3Factory.t.sol @@ -0,0 +1,47 @@ +/** + * Copyright 2026 Circle Internet Group, Inc. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + */ +pragma solidity ^0.8.20; + +import {DeployCreate3Factory} from "../script/deploy/DeployCreate3Factory.s.sol"; +import {Create3Factory} from "../src/contracts/Create3Factory.sol"; +import {Test} from "forge-std/Test.sol"; + +contract DeployCreate3FactoryTest is Test { + /// @dev Anvil default account #0 + uint256 internal constant DEPLOYER_PK = 0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80; + + DeployCreate3Factory internal script; + Create3Factory internal factory; + address internal deployer; + + function setUp() public { + vm.setEnv("DEPLOYER_PRIVATE_KEY", vm.toString(DEPLOYER_PK)); + script = new DeployCreate3Factory(); + script.run(); + factory = script.factory(); + deployer = vm.addr(DEPLOYER_PK); + } + + function test_deploy_setsOwnerToDeployer() public view { + assertEq(factory.owner(), deployer); + } + + function test_deploy_factoryAddressIsNonZero() public view { + assertTrue(address(factory) != address(0)); + } +} diff --git a/test/DeployCreate3FactoryViaCreate2.t.sol b/test/DeployCreate3FactoryViaCreate2.t.sol new file mode 100644 index 0000000..a726d9f --- /dev/null +++ b/test/DeployCreate3FactoryViaCreate2.t.sol @@ -0,0 +1,242 @@ +/** + * Copyright 2026 Circle Internet Group, Inc. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + */ +pragma solidity ^0.8.20; + +import {DeployCreate3FactoryViaCreate2} from "../script/deploy/DeployCreate3FactoryViaCreate2.s.sol"; +import {ICreate2Factory} from "../script/deploy/interfaces/ICreate2Factory.sol"; +import {Create3Factory} from "../src/contracts/Create3Factory.sol"; + +import {ANVIL_DEFAULT_PK} from "./TestConstants.sol"; +import {MockCreate2Factory} from "./mocks/MockCreate2Factory.sol"; +import {Ownable2Step} from "@openzeppelin/contracts/access/Ownable2Step.sol"; +import {Test} from "forge-std/Test.sol"; + +contract DeployCreate3FactoryViaCreate2Test is Test { + /// @dev Any address; stands in for the post-handoff Create3Factory owner. + address internal constant DEPLOYER = address(0xdeadBEef); + string internal constant CREATE3_FACTORY_SALT_STRING = "fee-orchestrator::v1::create3-factory"; + + MockCreate2Factory internal create2Factory; + DeployCreate3FactoryViaCreate2 internal script; + + function setUp() public { + address create2Owner = vm.addr(ANVIL_DEFAULT_PK); + create2Factory = new MockCreate2Factory(create2Owner); + + vm.setEnv("CREATE2_FACTORY_CONTRACT_ADDRESS", vm.toString(address(create2Factory))); + vm.setEnv("DEPLOYER_ADDRESS", vm.toString(DEPLOYER)); + vm.setEnv("CREATE3_FACTORY_SALT", CREATE3_FACTORY_SALT_STRING); + + script = new DeployCreate3FactoryViaCreate2(); + } + + function test_deployedAddressMatchesCreate2Prediction() public { + bytes memory expectedBytecode = + abi.encodePacked(type(Create3Factory).creationCode, abi.encode(address(create2Factory))); + bytes32 expectedSalt = keccak256(abi.encodePacked(CREATE3_FACTORY_SALT_STRING)); + address predicted = vm.computeCreate2Address(expectedSalt, keccak256(expectedBytecode), address(create2Factory)); + + script.run(); + + assertEq(address(script.factory()), predicted, "Create3Factory address mismatch"); + } + + function test_pendingOwnerIsDeployer_factoryStillOwnedByCreate2() public { + script.run(); + Create3Factory factory = script.factory(); + + assertEq(factory.pendingOwner(), DEPLOYER, "pendingOwner != DEPLOYER"); + assertEq(factory.owner(), address(create2Factory), "owner should still be create2Factory"); + } + + function test_acceptOwnershipCompletesHandoff() public { + script.run(); + Create3Factory factory = script.factory(); + + vm.prank(DEPLOYER); + factory.acceptOwnership(); + + assertEq(factory.owner(), DEPLOYER, "owner did not transfer to DEPLOYER"); + assertEq(factory.pendingOwner(), address(0), "pendingOwner should clear after accept"); + } + + function test_create3FactoryAddressMatchesGetAddressOracle() public { + script.run(); + Create3Factory factory = script.factory(); + + bytes32 sampleSalt = keccak256("fee-orchestrator::sample"); + address onChainPrediction = factory.getAddress(sampleSalt); + + // Off-chain CREATE3 derivation mirroring solady: + // proxy = CREATE2(factory, salt, keccak256(SOLADY_PROXY_INITCODE)) + // addr = CREATE(proxy, nonce=1) + bytes memory soladyProxyInitcode = hex"67363d3d37363d34f03d5260086018f3"; + address proxy = vm.computeCreate2Address(sampleSalt, keccak256(soladyProxyInitcode), address(factory)); + address offChainPrediction = vm.computeCreateAddress(proxy, 1); + + assertEq(offChainPrediction, onChainPrediction, "CREATE3 prediction mismatch"); + } +} + +/// @notice Fork-based verification: prove that the +/// deployed Create3Factory address matches off-chain prediction when +/// we deploy against the REAL Create2Factory on prod chains. Forks +/// mainnet; impersonates the chain's actual create2FactoryOwner; +/// calls deployAndMultiCall on the real prod Create2Factory binary. +/// The only thing under our control is the salt + Create3Factory +/// bytecode — everything else is exactly what will run on deploy day. +/// +/// RPC URLs: +/// MAINNET_RPC_URL (default: https://ethereum-rpc.publicnode.com) +/// The mainnet test never skips: it retries a fork pinned behind the chain +/// head and fails if no attempt succeeds. See `_selectForkBehindHead`. +contract DeployCreate3FactoryViaCreate2AtForkedProdFactoriesTest is Test { + address internal constant PROD_CREATE2_FACTORY = 0xe7b84D8846c96Bb83155Da5537625c75e42d6E42; + + address internal constant DEPLOYER = address(0xdeadBEef); + string internal constant CREATE3_FACTORY_SALT_STRING = "fee-orchestrator::v1::create3-factory"; + + /// @dev Blocks behind head to pin the fork at. Comfortably inside the ~128 blocks of + /// state a pruned node retains, and far enough back that every node behind a load + /// balancer has it. See `_selectForkBehindHead`. + uint256 internal constant HEAD_LAG = 64; + + /// @dev Attempts before failing. A retry lands on a different pool member, which is what + /// clears a transient `block not found` or a reset connection. + uint256 internal constant FORK_ATTEMPTS = 3; + + // Under production optimizer settings (`via_ir = true`), running this fork + // test produces 0x23F6e947132Db95B8282362abA3fc1E3650f26eB (mainnet). We + // don't assertEq against that literal here because `forge coverage` compiles without + // the IR optimizer, producing different creationCode → different addresses. + + function test_deployedAtMainnetCreate2Factory_matchesPrediction() public { + string memory rpcUrl = vm.envOr("MAINNET_RPC_URL", string("https://ethereum-rpc.publicnode.com")); + _runForkVerification(rpcUrl, PROD_CREATE2_FACTORY); + } + + /// @dev Selects a fork `HEAD_LAG` blocks behind the chain head, retrying on RPC + /// errors. Never skips: exhausting the retries fails the test. + /// + /// Forking `latest` is what made this flaky. A load-balanced endpoint can + /// answer `eth_blockNumber` from a node ahead of the one that serves the + /// follow-up state reads, which surfaces as `block not found` for a block + /// the fork was just pinned to. Backing off puts the fork block inside + /// every pool member's history while staying well within a pruned node's + /// state window, so this still needs no archive node. + function _selectForkBehindHead(string memory rpcUrl) private { + string memory lastError = "no attempt made"; + + for (uint256 attempt; attempt < FORK_ATTEMPTS; ++attempt) { + try this.exposedSelectForkBehindHead(rpcUrl) { + return; + } catch Error(string memory reason) { + lastError = reason; + } catch { + lastError = "select fork behind head failed"; + } + } + + emit log_named_string("last RPC error", lastError); + emit log_named_string("rpc url", rpcUrl); + revert(string.concat("fork RPC unreachable after retries; rpc url: ", rpcUrl)); + } + + /// @dev External so `_selectForkBehindHead` can `try`/`catch` the cheatcodes. The first + /// fork only reads the block header, so it survives the race described above; the + /// state reads against the second one are what did not. + function exposedSelectForkBehindHead(string calldata rpcUrl) external { + vm.createSelectFork(rpcUrl); + + uint256 head = vm.getBlockNumber(); + uint256 target = head > HEAD_LAG ? head - HEAD_LAG : head; + + vm.createSelectFork(rpcUrl, target); + } + + /// @dev Fork the given RPC, impersonate the real create2FactoryOwner, call + /// deployAndMultiCall on the actual prod Create2Factory binary, and + /// verify the resulting Create3Factory address equals what off-chain + /// CREATE2 derivation predicts. + function _runForkVerification(string memory rpcUrl, address create2FactoryAddr) + internal + returns (address deployed) + { + _selectForkBehindHead(rpcUrl); + + // Sanity-check that the canonical Create2Factory exists on this fork. + require(create2FactoryAddr.code.length > 0, "Create2Factory not deployed on this fork"); + + // Off-chain prediction. + bytes memory bytecode = abi.encodePacked(type(Create3Factory).creationCode, abi.encode(create2FactoryAddr)); + bytes32 salt = keccak256(abi.encodePacked(CREATE3_FACTORY_SALT_STRING)); + address predicted = vm.computeCreate2Address(salt, keccak256(bytecode), create2FactoryAddr); + + // Once the Create3Factory has shipped to prod, a fresh fork already has it + // live at the predicted address, so redeploying reverts with CreateCollision. + // That collision only happens because real-deployed == predicted, which is + // exactly the invariant under test — so treat an occupied slot as success + // after sanity-checking the live contract behaves like a Create3Factory. + // (Under `forge coverage`, no-IR creationCode yields a different, unoccupied + // address, so the full deploy path below still runs.) + if (predicted.code.length > 0) { + _assertLiveCreate3Factory(predicted); + return predicted; + } + + // Resolve the real owner from on-chain state and fund it for gas. + address realOwner = ICreate2Factory(create2FactoryAddr).owner(); + vm.deal(realOwner, 1 ether); + + // Build the same multicall payload the production script does. + bytes[] memory calls = new bytes[](1); + calls[0] = abi.encodeCall(Ownable2Step.transferOwnership, (DEPLOYER)); + + // Impersonate the real create2FactoryOwner and call deployAndMultiCall + // on the actual prod Create2Factory contract. + vm.startPrank(realOwner); + deployed = ICreate2Factory(create2FactoryAddr).deployAndMultiCall(0, salt, bytecode, calls); + vm.stopPrank(); + + assertEq(deployed, predicted, "Real-prod-factory deploy does not match off-chain prediction"); + + // Confirm Ownable2Step handoff worked: pendingOwner is the deployer, + // owner is still the Create2Factory itself (constructor arg). + Create3Factory factory = Create3Factory(deployed); + assertEq(factory.pendingOwner(), DEPLOYER, "pendingOwner != DEPLOYER"); + assertEq(factory.owner(), create2FactoryAddr, "initial owner should be Create2Factory"); + } + + /// @dev Sanity-checks that the contract already live at the predicted address is a + /// functioning Create3Factory, by confirming its on-chain CREATE3 address oracle + /// matches the off-chain solady derivation. Ownership state is intentionally not + /// asserted here: a prod-deployed factory may have already completed its + /// Ownable2Step handoff, so `owner`/`pendingOwner` need not match the post-deploy + /// values checked on the fresh-deploy path. + function _assertLiveCreate3Factory(address factoryAddr) private view { + Create3Factory factory = Create3Factory(factoryAddr); + bytes32 sampleSalt = keccak256("fee-orchestrator::sample"); + address onChainPrediction = factory.getAddress(sampleSalt); + + bytes memory soladyProxyInitcode = hex"67363d3d37363d34f03d5260086018f3"; + address proxy = vm.computeCreate2Address(sampleSalt, keccak256(soladyProxyInitcode), factoryAddr); + address offChainPrediction = vm.computeCreateAddress(proxy, 1); + + assertEq(offChainPrediction, onChainPrediction, "live Create3Factory CREATE3 oracle mismatch"); + } +} diff --git a/test/DeployFeeManager.t.sol b/test/DeployFeeManager.t.sol new file mode 100644 index 0000000..9f0890f --- /dev/null +++ b/test/DeployFeeManager.t.sol @@ -0,0 +1,354 @@ +/** + * Copyright 2026 Circle Internet Group, Inc. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + */ +pragma solidity ^0.8.20; + +import {DeployFeeManager, DeployFeeManagerConfig} from "../script/deploy/DeployFeeManager.s.sol"; +import {Salts} from "../script/deploy/Salts.sol"; +import {Roles} from "../src/constants/Roles.sol"; +import {Create3Factory} from "../src/contracts/Create3Factory.sol"; +import {FeeManager} from "../src/contracts/FeeManager.sol"; +import {ANVIL_DEFAULT_PK} from "./TestConstants.sol"; +import {Test} from "forge-std/Test.sol"; + +string constant DEFAULT_DEPLOYMENT_SALT = "fee-orchestrator-v1"; + +contract DeployFeeManagerTest is Test { + DeployFeeManager internal script; + FeeManager internal fm; + Create3Factory internal factory; + address internal deployer; + address internal feeTakerAddr; + address internal usdcBlacklistAddr; + address internal predictedTmwfProxy; + + /// @dev Sets deployment salt env vars and immediately caches all salt-derived + /// values into a config struct. This eliminates the race window between + /// setting salts and reading them via Salts/Addresses libraries. + function _configForSalt(string memory salt) internal returns (DeployFeeManagerConfig memory config) { + vm.setEnv("FEE_MANAGER_PROXY_SALT", string.concat(salt, ".fm.proxy")); + vm.setEnv("FEE_MANAGER_IMPLEMENTATION_SALT", string.concat(salt, ".fm.impl")); + vm.setEnv("TOKEN_MESSENGER_WITH_FEES_PROXY_SALT", string.concat(salt, ".tmwf.proxy")); + vm.setEnv("TOKEN_MESSENGER_WITH_FEES_IMPLEMENTATION_SALT", string.concat(salt, ".tmwf.impl")); + + bytes32 implSalt = Salts.feeManagerImpl(); + bytes32 proxySalt = Salts.feeManagerProxy(); + address tmwfProxy = factory.getAddress(Salts.tokenMessengerWithFeesProxy()); + + address[] memory callers = new address[](1); + callers[0] = tmwfProxy; + config = DeployFeeManagerConfig({ + factory: factory, + implSalt: implSalt, + proxySalt: proxySalt, + owner: address(0), + feeTaker: feeTakerAddr, + usdc: usdcBlacklistAddr, + callerAdmin: address(0), + signerAdmin: address(0), + trustedSigner: address(0), + pauser: address(0), + feeCollectionAdmin: address(0), + feeCollectionAddress: payable(address(0)), + allowedCallers: callers + }); + } + + function setUp() public { + deployer = vm.addr(ANVIL_DEFAULT_PK); + feeTakerAddr = makeAddr("feeTaker"); + usdcBlacklistAddr = makeAddr("usdcBlacklist"); + + vm.prank(deployer); + factory = new Create3Factory(deployer); + + vm.setEnv("DEPLOYER_PRIVATE_KEY", vm.toString(ANVIL_DEFAULT_PK)); + vm.setEnv("CREATE3_FACTORY_ADDRESS", vm.toString(address(factory))); + + DeployFeeManagerConfig memory config = _configForSalt(DEFAULT_DEPLOYMENT_SALT); + predictedTmwfProxy = config.allowedCallers[0]; + + script = new DeployFeeManager(); + script.runWithConfig(config); + fm = script.feeManager(); + } + + function test_deploy_setsPendingOwnerToDeployer() public view { + assertEq(fm.pendingOwner(), deployer); + } + + function test_deploy_factoryRemainsOwnerUntilAcceptance() public view { + assertEq(fm.owner(), address(factory)); + } + + function test_deploy_defaultAdminRoleStaysWithFactory() public view { + assertTrue(fm.hasRole(fm.DEFAULT_ADMIN_ROLE(), address(factory))); + assertFalse(fm.hasRole(fm.DEFAULT_ADMIN_ROLE(), deployer)); + } + + function test_deploy_doesNotRegisterDeployerAsAllowedCaller() public view { + assertFalse(fm.hasRole(Roles.ALLOWED_CALLER_ROLE, deployer)); + } + + function test_deploy_setsFeeTaker() public view { + assertEq(fm.feeTaker(), feeTakerAddr); + } + + function test_deploy_setsUsdcBlacklist() public view { + assertEq(fm.usdcBlacklist(), usdcBlacklistAddr); + } + + function test_deploy_doesNotGrantCallerAdminWhenUnset() public view { + assertFalse(fm.hasRole(Roles.CALLER_ADMIN_ROLE, deployer)); + } + + function test_deploy_doesNotGrantSignerAdminWhenUnset() public view { + assertFalse(fm.hasRole(Roles.SIGNER_ADMIN_ROLE, deployer)); + } + + function test_deploy_doesNotGrantFeeCollectionAdminWhenUnset() public view { + assertFalse(fm.hasRole(Roles.FEE_COLLECTION_ADMIN_ROLE, deployer)); + assertFalse(fm.hasRole(Roles.FEE_COLLECTION_ADMIN_ROLE, address(factory))); + } + + function test_deploy_feeCollectionAddressUnsetByDefault() public view { + assertEq(fm.feeCollectionAddress(), address(0)); + } + + function test_deploy_grantsFeeCollectionAdminWhenSet() public { + address feeCollectionAdminAddr = makeAddr("explicitFeeCollectionAdmin"); + + DeployFeeManagerConfig memory config = _configForSalt("test-explicit-fee-collection-admin"); + config.feeCollectionAdmin = feeCollectionAdminAddr; + + DeployFeeManager s = new DeployFeeManager(); + s.runWithConfig(config); + FeeManager fm2 = s.feeManager(); + + assertTrue(fm2.hasRole(Roles.FEE_COLLECTION_ADMIN_ROLE, feeCollectionAdminAddr)); + assertFalse(fm2.hasRole(Roles.FEE_COLLECTION_ADMIN_ROLE, address(factory))); + assertEq(fm2.feeCollectionAddress(), address(0)); + } + + function test_deploy_setsFeeCollectionAddressWhenSet() public { + address payable feeCollectionAddr = payable(makeAddr("explicitFeeCollectionAddress")); + + DeployFeeManagerConfig memory config = _configForSalt("test-explicit-fee-collection-address"); + config.feeCollectionAddress = feeCollectionAddr; + + DeployFeeManager s = new DeployFeeManager(); + s.runWithConfig(config); + FeeManager fm2 = s.feeManager(); + + assertEq(fm2.feeCollectionAddress(), feeCollectionAddr); + // Temp FEE_COLLECTION_ADMIN_ROLE grant to the factory must be revoked at the + // end of the multicall — factory should NOT retain the role post-deploy. + assertFalse(fm2.hasRole(Roles.FEE_COLLECTION_ADMIN_ROLE, address(factory))); + // The new 3-call sub-sequence must not disturb DEFAULT_ADMIN_ROLE state: + // factory retains DEFAULT_ADMIN_ROLE (pending-owner pattern; transferred on acceptance); + // deployer was never granted DEFAULT_ADMIN_ROLE. Validates the minimal-config callCount + // calculation and that the new conditional block is correctly fenced from the + // CALLER_ADMIN/SIGNER_ADMIN revoke + transferOwnership tail. + assertTrue(fm2.hasRole(fm2.DEFAULT_ADMIN_ROLE(), address(factory))); + assertFalse(fm2.hasRole(fm2.DEFAULT_ADMIN_ROLE(), deployer)); + } + + function test_deploy_setsFeeCollectionAddressWithExplicitAdmin() public { + // Combined case: both a non-zero feeCollectionAdmin AND a non-zero + // feeCollectionAddress. Verifies the admin keeps the role and the address + // is set, with no factory residual role. + address feeCollectionAdminAddr = makeAddr("explicitFeeCollectionAdminCombined"); + address payable feeCollectionAddr = payable(makeAddr("explicitFeeCollectionAddressCombined")); + + DeployFeeManagerConfig memory config = _configForSalt("test-fca-and-address"); + config.feeCollectionAdmin = feeCollectionAdminAddr; + config.feeCollectionAddress = feeCollectionAddr; + + DeployFeeManager s = new DeployFeeManager(); + s.runWithConfig(config); + FeeManager fm2 = s.feeManager(); + + assertTrue(fm2.hasRole(Roles.FEE_COLLECTION_ADMIN_ROLE, feeCollectionAdminAddr)); + assertFalse(fm2.hasRole(Roles.FEE_COLLECTION_ADMIN_ROLE, address(factory))); + assertEq(fm2.feeCollectionAddress(), feeCollectionAddr); + } + + function test_deploy_grantsCallerAndSignerAdminWhenSet() public { + address callerAdminAddr = makeAddr("explicitCallerAdmin"); + address signerAdminAddr = makeAddr("explicitSignerAdmin"); + + DeployFeeManagerConfig memory config = _configForSalt("test-explicit-caller-signer-admin"); + config.callerAdmin = callerAdminAddr; + config.signerAdmin = signerAdminAddr; + + DeployFeeManager s = new DeployFeeManager(); + s.runWithConfig(config); + FeeManager fm2 = s.feeManager(); + + assertTrue(fm2.hasRole(Roles.CALLER_ADMIN_ROLE, callerAdminAddr)); + assertTrue(fm2.hasRole(Roles.SIGNER_ADMIN_ROLE, signerAdminAddr)); + assertFalse(fm2.hasRole(Roles.CALLER_ADMIN_ROLE, deployer)); + assertFalse(fm2.hasRole(Roles.SIGNER_ADMIN_ROLE, deployer)); + } + + function test_deploy_withoutTrustedSigner_noSignerTrusted() public view { + assertFalse(fm.isTrustedQuoteSigner(deployer)); + } + + function test_deploy_withTrustedSigner_registersSigner() public { + address signer = makeAddr("trustedSigner"); + + DeployFeeManagerConfig memory config = _configForSalt("test-with-trusted-signer"); + config.trustedSigner = signer; + + DeployFeeManager s = new DeployFeeManager(); + s.runWithConfig(config); + FeeManager fm2 = s.feeManager(); + + assertTrue(fm2.isTrustedQuoteSigner(signer)); + } + + function test_deploy_withDifferentSignerAdmin_grantsCorrectRole() public { + address otherSignerAdmin = makeAddr("otherSignerAdmin"); + address signer = makeAddr("envTrustedSigner"); + + DeployFeeManagerConfig memory config = _configForSalt("test-signer-admin-handoff"); + config.signerAdmin = otherSignerAdmin; + config.trustedSigner = signer; + + DeployFeeManager s = new DeployFeeManager(); + s.runWithConfig(config); + FeeManager fm2 = s.feeManager(); + + assertTrue(fm2.hasRole(Roles.SIGNER_ADMIN_ROLE, otherSignerAdmin)); + assertEq(fm2.pendingOwner(), deployer); + assertTrue(fm2.isTrustedQuoteSigner(signer)); + + vm.prank(otherSignerAdmin); + fm2.setTrustedQuoteSigner(signer, false); + assertFalse(fm2.isTrustedQuoteSigner(signer)); + } + + function test_deploy_proxyPointsToImplementation() public view { + assertTrue(script.implementation() != address(0)); + assertEq(address(fm), address(script.feeManager())); + assertTrue(script.implementation() != address(fm)); + } + + function test_deploy_ownershipTransferInitiated() public view { + assertEq(fm.owner(), address(factory)); + assertEq(fm.pendingOwner(), deployer); + } + + function test_deploy_preRegistersPredictedTmwfProxyAsAllowedCaller() public view { + assertTrue(fm.hasRole(Roles.ALLOWED_CALLER_ROLE, predictedTmwfProxy)); + } + + function test_deploy_defaultPauserIsNotGranted() public view { + assertFalse(fm.hasRole(Roles.PAUSER_ROLE, deployer)); + } + + function test_deploy_factoryRetainsOnlyOwnerRole() public view { + assertTrue(fm.hasRole(fm.DEFAULT_ADMIN_ROLE(), address(factory))); + assertFalse(fm.hasRole(Roles.CALLER_ADMIN_ROLE, address(factory))); + assertFalse(fm.hasRole(Roles.SIGNER_ADMIN_ROLE, address(factory))); + assertFalse(fm.hasRole(Roles.ALLOWED_CALLER_ROLE, address(factory))); + assertFalse(fm.hasRole(Roles.PAUSER_ROLE, address(factory))); + assertFalse(fm.hasRole(Roles.FEE_COLLECTION_ADMIN_ROLE, address(factory))); + } + + function test_deploy_withPauser_grantsPauserRole() public { + address pauserAddr = makeAddr("pauser"); + + DeployFeeManagerConfig memory config = _configForSalt("test-with-pauser"); + config.pauser = pauserAddr; + + DeployFeeManager s = new DeployFeeManager(); + s.runWithConfig(config); + FeeManager fm2 = s.feeManager(); + + assertTrue(fm2.hasRole(Roles.PAUSER_ROLE, pauserAddr)); + } + + function test_deploy_multipleAllowedCallers_grantsAll() public { + address extraCaller1 = makeAddr("extraCaller1"); + address extraCaller2 = makeAddr("extraCaller2"); + + DeployFeeManagerConfig memory config = _configForSalt("test-multi-callers"); + address tmwfProxy = config.allowedCallers[0]; + + address[] memory callers = new address[](3); + callers[0] = tmwfProxy; + callers[1] = extraCaller1; + callers[2] = extraCaller2; + config.allowedCallers = callers; + + DeployFeeManager s = new DeployFeeManager(); + s.runWithConfig(config); + FeeManager fm2 = s.feeManager(); + + assertTrue(fm2.hasRole(Roles.ALLOWED_CALLER_ROLE, tmwfProxy)); + assertTrue(fm2.hasRole(Roles.ALLOWED_CALLER_ROLE, extraCaller1)); + assertTrue(fm2.hasRole(Roles.ALLOWED_CALLER_ROLE, extraCaller2)); + assertFalse(fm2.hasRole(Roles.ALLOWED_CALLER_ROLE, deployer)); + } + + function test_deploy_withExplicitOwner_setsPendingOwner() public { + address explicitOwner = makeAddr("explicitOwner"); + + DeployFeeManagerConfig memory config = _configForSalt("test-explicit-owner"); + config.owner = explicitOwner; + + DeployFeeManager s = new DeployFeeManager(); + s.runWithConfig(config); + FeeManager fm2 = s.feeManager(); + + assertEq(fm2.pendingOwner(), explicitOwner); + assertEq(fm2.owner(), address(factory)); + } + + function test_deploy_multipleAllowedCallers_withAllOptionalRoles() public { + address callerAdminAddr = makeAddr("callerAdmin"); + address signerAdminAddr = makeAddr("signerAdmin"); + address signer = makeAddr("signer"); + address pauserAddr = makeAddr("pauser"); + address extraCaller = makeAddr("extraCaller"); + + DeployFeeManagerConfig memory config = _configForSalt("test-multi-callers-full"); + address tmwfProxy = config.allowedCallers[0]; + + address[] memory callers = new address[](2); + callers[0] = tmwfProxy; + callers[1] = extraCaller; + config.callerAdmin = callerAdminAddr; + config.signerAdmin = signerAdminAddr; + config.trustedSigner = signer; + config.pauser = pauserAddr; + config.allowedCallers = callers; + + DeployFeeManager s = new DeployFeeManager(); + s.runWithConfig(config); + FeeManager fm2 = s.feeManager(); + + assertTrue(fm2.hasRole(Roles.CALLER_ADMIN_ROLE, callerAdminAddr)); + assertTrue(fm2.hasRole(Roles.SIGNER_ADMIN_ROLE, signerAdminAddr)); + assertTrue(fm2.isTrustedQuoteSigner(signer)); + assertTrue(fm2.hasRole(Roles.PAUSER_ROLE, pauserAddr)); + assertTrue(fm2.hasRole(Roles.ALLOWED_CALLER_ROLE, tmwfProxy)); + assertTrue(fm2.hasRole(Roles.ALLOWED_CALLER_ROLE, extraCaller)); + } +} diff --git a/test/DeployTokenMessengerWithFees.t.sol b/test/DeployTokenMessengerWithFees.t.sol new file mode 100644 index 0000000..1b68bfc --- /dev/null +++ b/test/DeployTokenMessengerWithFees.t.sol @@ -0,0 +1,187 @@ +/** + * Copyright 2026 Circle Internet Group, Inc. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + */ +pragma solidity ^0.8.20; + +import {DeployFeeManager, DeployFeeManagerConfig} from "../script/deploy/DeployFeeManager.s.sol"; +import { + DeployTokenMessengerWithFees, + DeployTokenMessengerWithFeesConfig +} from "../script/deploy/DeployTokenMessengerWithFees.s.sol"; +import {Salts} from "../script/deploy/Salts.sol"; +import {Roles} from "../src/constants/Roles.sol"; +import {Create3Factory} from "../src/contracts/Create3Factory.sol"; +import {FeeManager} from "../src/contracts/FeeManager.sol"; +import {TokenMessengerWithFees} from "../src/contracts/TokenMessengerWithFees.sol"; +import {ANVIL_DEFAULT_PK} from "./TestConstants.sol"; +import {MockMessageTransmitter} from "./mocks/MockMessageTransmitter.sol"; +import {MockTokenMessenger} from "./mocks/MockTokenMessenger.sol"; +import {Test} from "forge-std/Test.sol"; + +/// @dev Cached salt-derived values for both FM and TMWF deployments. +struct CachedSalts { + bytes32 fmImplSalt; + bytes32 fmProxySalt; + bytes32 tmwfImplSalt; + bytes32 tmwfProxySalt; + address predictedTmwfProxy; +} + +contract DeployTokenMessengerWithFeesTest is Test { + DeployTokenMessengerWithFees internal script; + TokenMessengerWithFees internal tmwf; + FeeManager internal fm; + Create3Factory internal factory; + address internal deployer; + address internal mockTokenMessengerV2; + address internal feeTakerAddr; + address internal usdcBlacklistAddr; + + /// @dev Sets deployment salt env vars and immediately caches all salt-derived values. + function _cacheSaltsForSalt(string memory salt) internal returns (CachedSalts memory s) { + vm.setEnv("FEE_MANAGER_PROXY_SALT", string.concat(salt, ".fm.proxy")); + vm.setEnv("FEE_MANAGER_IMPLEMENTATION_SALT", string.concat(salt, ".fm.impl")); + vm.setEnv("TOKEN_MESSENGER_WITH_FEES_PROXY_SALT", string.concat(salt, ".tmwf.proxy")); + vm.setEnv("TOKEN_MESSENGER_WITH_FEES_IMPLEMENTATION_SALT", string.concat(salt, ".tmwf.impl")); + s.fmImplSalt = Salts.feeManagerImpl(); + s.fmProxySalt = Salts.feeManagerProxy(); + s.tmwfImplSalt = Salts.tokenMessengerWithFeesImpl(); + s.tmwfProxySalt = Salts.tokenMessengerWithFeesProxy(); + s.predictedTmwfProxy = factory.getAddress(s.tmwfProxySalt); + } + + function _fmConfig(CachedSalts memory s) internal view returns (DeployFeeManagerConfig memory) { + address[] memory callers = new address[](1); + callers[0] = s.predictedTmwfProxy; + return DeployFeeManagerConfig({ + factory: factory, + implSalt: s.fmImplSalt, + proxySalt: s.fmProxySalt, + owner: address(0), + feeTaker: feeTakerAddr, + usdc: usdcBlacklistAddr, + callerAdmin: address(0), + signerAdmin: address(0), + trustedSigner: address(0), + pauser: address(0), + feeCollectionAdmin: address(0), + feeCollectionAddress: payable(address(0)), + allowedCallers: callers + }); + } + + function _tmwfConfig(CachedSalts memory s, address feeManagerProxy) + internal + view + returns (DeployTokenMessengerWithFeesConfig memory) + { + return DeployTokenMessengerWithFeesConfig({ + factory: factory, + tokenMessengerV2: mockTokenMessengerV2, + feeManagerProxy: feeManagerProxy, + owner: address(0), + implSalt: s.tmwfImplSalt, + proxySalt: s.tmwfProxySalt + }); + } + + function setUp() public { + deployer = vm.addr(ANVIL_DEFAULT_PK); + feeTakerAddr = makeAddr("feeTaker"); + usdcBlacklistAddr = makeAddr("usdcBlacklist"); + MockMessageTransmitter mt = new MockMessageTransmitter(0); + mockTokenMessengerV2 = address(new MockTokenMessenger(address(mt))); + + vm.prank(deployer); + factory = new Create3Factory(deployer); + + vm.setEnv("DEPLOYER_PRIVATE_KEY", vm.toString(ANVIL_DEFAULT_PK)); + vm.setEnv("CREATE3_FACTORY_ADDRESS", vm.toString(address(factory))); + + CachedSalts memory salts = _cacheSaltsForSalt("fee-orchestrator-v1"); + + DeployFeeManager fmScript = new DeployFeeManager(); + fmScript.runWithConfig(_fmConfig(salts)); + fm = fmScript.feeManager(); + + script = new DeployTokenMessengerWithFees(); + script.runWithConfig(_tmwfConfig(salts, address(fm))); + tmwf = script.tokenMessengerWithFees(); + } + + function test_deploy_setsPendingOwnerToDeployer() public view { + assertEq(tmwf.pendingOwner(), deployer); + } + + function test_deploy_factoryRemainsOwnerUntilAcceptance() public view { + assertEq(tmwf.owner(), address(factory)); + } + + function test_deploy_setsTokenMessenger() public view { + assertEq(tmwf.tokenMessenger(), mockTokenMessengerV2); + } + + function test_deploy_setsFeeManager() public view { + assertEq(tmwf.feeManager(), address(fm)); + } + + function test_deploy_predictedProxyMatchesAllowedCaller() public view { + assertTrue(fm.hasRole(Roles.ALLOWED_CALLER_ROLE, address(tmwf))); + } + + function test_deploy_proxyPointsToImplementation() public view { + assertTrue(script.implementation() != address(0)); + assertEq(address(tmwf), address(script.tokenMessengerWithFees())); + assertTrue(script.implementation() != address(tmwf)); + } + + function test_deploy_ownershipTransferInitiated() public view { + assertEq(tmwf.owner(), address(factory)); + assertEq(tmwf.pendingOwner(), deployer); + } + + function test_deploy_withExplicitOwner_setsPendingOwner() public { + address explicitOwner = makeAddr("explicitOwner"); + + CachedSalts memory salts = _cacheSaltsForSalt("test-explicit-owner"); + + DeployFeeManager fmScript2 = new DeployFeeManager(); + fmScript2.runWithConfig(_fmConfig(salts)); + + DeployTokenMessengerWithFeesConfig memory config = _tmwfConfig(salts, address(fmScript2.feeManager())); + config.owner = explicitOwner; + + DeployTokenMessengerWithFees s2 = new DeployTokenMessengerWithFees(); + s2.runWithConfig(config); + + assertEq(s2.tokenMessengerWithFees().pendingOwner(), explicitOwner); + assertEq(s2.tokenMessengerWithFees().owner(), address(factory)); + } + + function test_deploy_withDifferentSalt_producesDifferentAddresses() public { + CachedSalts memory salts = _cacheSaltsForSalt("fee-orchestrator-v2"); + + DeployFeeManager fmScript2 = new DeployFeeManager(); + fmScript2.runWithConfig(_fmConfig(salts)); + + DeployTokenMessengerWithFees s2 = new DeployTokenMessengerWithFees(); + s2.runWithConfig(_tmwfConfig(salts, address(fmScript2.feeManager()))); + + assertTrue(address(s2.tokenMessengerWithFees()) != address(tmwf)); + assertTrue(s2.implementation() != script.implementation()); + } +} diff --git a/test/FeeManager.t.sol b/test/FeeManager.t.sol new file mode 100644 index 0000000..992f256 --- /dev/null +++ b/test/FeeManager.t.sol @@ -0,0 +1,2265 @@ +/** + * Copyright 2026 Circle Internet Group, Inc. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + */ +pragma solidity ^0.8.20; + +import {EIP712TypeHashes} from "../src/constants/EIP712TypeHashes.sol"; +import {QuoteTypes} from "../src/constants/QuoteTypes.sol"; +import {Roles} from "../src/constants/Roles.sol"; +import {FeeManager} from "../src/contracts/FeeManager.sol"; +import {IFeeManager} from "../src/interfaces/IFeeManager.sol"; +import {IFeeTaker} from "../src/interfaces/IFeeTaker.sol"; +import {QuoteLib} from "../src/libraries/QuoteLib.sol"; +import {SignedQuoteLib} from "../src/libraries/SignedQuoteLib.sol"; +import {Initializable} from "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol"; +import {PausableUpgradeable} from "@openzeppelin/contracts-upgradeable/utils/PausableUpgradeable.sol"; +import {IAccessControl} from "@openzeppelin/contracts/access/IAccessControl.sol"; +import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol"; +import {ERC1967Proxy} from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol"; +import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol"; +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import {ECDSA} from "@openzeppelin/contracts/utils/cryptography/ECDSA.sol"; +import {Test} from "forge-std/Test.sol"; + +import {MockUSDCBlacklist} from "./mocks/MockUSDCBlacklist.sol"; + +/// @dev Minimal ERC-20 for testing. +contract MockERC20 is ERC20 { + constructor() ERC20("Mock", "MCK") {} + + function mint(address to, uint256 amount) external { + _mint(to, amount); + } +} + +/// @dev Mock FeeTaker that accepts fees and tracks calls. +contract MockFeeTaker is IFeeTaker { + uint256 public nativeFeeReceived; + uint256 public erc20FeeReceived; + address public lastBeneficiary; + bytes8 public lastKitType; + address public lastToken; + + function protocolFeeAddress() external view override returns (address) { + return address(this); + } + + function takeFeeNative(address payable beneficiary, bytes8 kitType) external payable override { + nativeFeeReceived += msg.value; + lastBeneficiary = beneficiary; + lastKitType = kitType; + } + + function takeFeeERC20(address token, address beneficiary, uint256 fee, bytes8 kitType) external override { + // forge-lint: disable-next-line(erc20-unchecked-transfer) + IERC20(token).transferFrom(msg.sender, address(this), fee); + erc20FeeReceived += fee; + lastToken = token; + lastBeneficiary = beneficiary; + lastKitType = kitType; + } +} + +/// @dev FeeTaker that always reverts, for testing atomic failure. +contract RevertingFeeTaker is IFeeTaker { + function protocolFeeAddress() external view override returns (address) { + return address(this); + } + + function takeFeeNative(address payable, bytes8) external payable override { + revert("FeeTaker: reverted"); + } + + function takeFeeERC20(address, address, uint256, bytes8) external pure override { + revert("FeeTaker: reverted"); + } +} + +contract FeeManagerTest is Test { + FeeManager internal feeManager; + MockFeeTaker internal mockFeeTaker; + MockUSDCBlacklist internal mockUsdcBlacklist; + + uint256 internal constant TRUSTED_SIGNER_PK = 0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d; + bytes32 internal constant CORRELATION_HASH = keccak256("test-correlation"); + + address internal trustedSigner; + + address internal owner = makeAddr("owner"); + address internal callerAdmin = makeAddr("callerAdmin"); + address internal signerAdmin = makeAddr("signerAdmin"); + address internal feeCollectionAdmin = makeAddr("feeCollectionAdmin"); + address internal pauser_ = makeAddr("pauser"); + address internal allowedCaller = makeAddr("allowedCaller"); + address internal refundAddress = makeAddr("refundAddress"); + + event FeeCollected( + address indexed caller, + bytes32 indexed correlationHash, + string quoteType, + string[] quoteArgs, + address payable refundAddress, + address payable feeRecipient, + address feeToken, + uint256 amount, + uint256 nonce + ); + + /// @dev Builds a `QuoteClaim` from a signed-quote payload, using this test's `refundAddress` + /// EOA as the refund target. Used by every call site that exercises the bytes overload. + function _claim(bytes memory signedQuote) internal view returns (IFeeManager.QuoteClaim memory) { + return IFeeManager.QuoteClaim({signedQuote: signedQuote, refundAddress: payable(refundAddress)}); + } + + /// @dev Same as `_claim`, but with an explicit refund-target override (e.g. `address(0)`). + function _claimWithRefund(bytes memory signedQuote, address payable refundTarget) + internal + pure + returns (IFeeManager.QuoteClaim memory) + { + return IFeeManager.QuoteClaim({signedQuote: signedQuote, refundAddress: refundTarget}); + } + + /// @dev Decoded counterpart to `_claim` — bundles a decoded `Quote` with this test's `refundAddress`. + function _decodedClaim(IFeeManager.Quote memory decoded) + internal + view + returns (IFeeManager.DecodedQuoteClaim memory) + { + return IFeeManager.DecodedQuoteClaim({decodedQuote: decoded, refundAddress: payable(refundAddress)}); + } + + event Paused(address account); + event Unpaused(address account); + + function _deployFeeManagerProxy(address initialOwner) internal returns (FeeManager) { + FeeManager impl = new FeeManager(address(mockFeeTaker), address(mockUsdcBlacklist)); + bytes memory initData = abi.encodeCall(FeeManager.initialize, (initialOwner)); + ERC1967Proxy proxy = new ERC1967Proxy(address(impl), initData); + return FeeManager(payable(address(proxy))); + } + + function _deployFeeManagerProxyWithFeeTaker(address initialOwner, address feeTakerAddr) + internal + returns (FeeManager) + { + FeeManager impl = new FeeManager(feeTakerAddr, address(mockUsdcBlacklist)); + bytes memory initData = abi.encodeCall(FeeManager.initialize, (initialOwner)); + ERC1967Proxy proxy = new ERC1967Proxy(address(impl), initData); + return FeeManager(payable(address(proxy))); + } + + function setUp() public { + mockFeeTaker = new MockFeeTaker(); + mockUsdcBlacklist = new MockUSDCBlacklist(); + trustedSigner = vm.addr(TRUSTED_SIGNER_PK); + feeManager = _deployFeeManagerProxy(owner); + + vm.startPrank(owner); + feeManager.grantRole(Roles.CALLER_ADMIN_ROLE, callerAdmin); + feeManager.grantRole(Roles.SIGNER_ADMIN_ROLE, signerAdmin); + feeManager.grantRole(Roles.FEE_COLLECTION_ADMIN_ROLE, feeCollectionAdmin); + feeManager.grantRole(Roles.PAUSER_ROLE, pauser_); + vm.stopPrank(); + + vm.prank(callerAdmin); + feeManager.grantRole(Roles.ALLOWED_CALLER_ROLE, allowedCaller); + + vm.prank(signerAdmin); + feeManager.setTrustedQuoteSigner(trustedSigner, true); + } + + function _domainSeparator(address verifyingContract) internal view returns (bytes32) { + return keccak256( + abi.encode( + EIP712TypeHashes.DOMAIN_TYPEHASH, + keccak256(bytes("FeeManager")), + keccak256(bytes("1")), + block.chainid, + verifyingContract + ) + ); + } + + function _hashQuoteItems(IFeeManager.QuoteItem[] memory items) internal pure returns (bytes32) { + bytes32[] memory hashes = new bytes32[](items.length); + for (uint256 i = 0; i < items.length; ++i) { + hashes[i] = keccak256( + abi.encode( + EIP712TypeHashes.QUOTE_ITEM_TYPEHASH, + keccak256(bytes(items[i].quoteType)), + items[i].quoteArgsHash, + items[i].amount + ) + ); + } + return keccak256(abi.encodePacked(hashes)); + } + + function _quoteStructHash(IFeeManager.QuoteItem[] memory items, uint256 expiry, address feeToken, uint256 nonce) + internal + pure + returns (bytes32) + { + return keccak256(abi.encode(EIP712TypeHashes.QUOTE_TYPEHASH, _hashQuoteItems(items), expiry, feeToken, nonce)); + } + + function _encodeSignedQuote( + IFeeManager.QuoteItem[] memory items, + uint256 expiry, + address feeToken, + uint256 nonce, + uint256 signerPk, + address verifyingContract + ) internal view returns (bytes memory) { + bytes32 digest = keccak256( + abi.encodePacked( + hex"1901", _domainSeparator(verifyingContract), _quoteStructHash(items, expiry, feeToken, nonce) + ) + ); + (uint8 v, bytes32 r, bytes32 s) = vm.sign(signerPk, digest); + IFeeManager.Quote memory q = IFeeManager.Quote({ + items: items, expiry: expiry, feeToken: feeToken, nonce: nonce, signature: abi.encodePacked(r, s, v) + }); + return abi.encodePacked(uint8(SignedQuoteLib.QUOTE_WIRE_FORMAT_VERSION), abi.encode(q)); + } + + function _encodeSignedQuoteWithWrongDomain(IFeeManager.QuoteItem[] memory items) + internal + view + returns (bytes memory signedQuote, address recovered) + { + uint256 expiry = block.timestamp + 1 days; + bytes32 domainSeparator = keccak256( + abi.encode( + EIP712TypeHashes.DOMAIN_TYPEHASH, + keccak256(bytes("NotFeeManager")), + keccak256(bytes("2")), + block.chainid, + address(feeManager) + ) + ); + bytes32 digest = + keccak256(abi.encodePacked(hex"1901", domainSeparator, _quoteStructHash(items, expiry, address(0), 0))); + (uint8 v, bytes32 r, bytes32 s) = vm.sign(TRUSTED_SIGNER_PK, digest); + bytes memory signature = abi.encodePacked(r, s, v); + bytes32 contractDigest = keccak256( + abi.encodePacked( + hex"1901", _domainSeparator(address(feeManager)), _quoteStructHash(items, expiry, address(0), 0) + ) + ); + // This is the address FeeManager recovers against its own domain, not + // the trusted signer that produced the wrong-domain signature above. + recovered = ECDSA.recover(contractDigest, signature); + IFeeManager.Quote memory q = + IFeeManager.Quote({items: items, expiry: expiry, feeToken: address(0), nonce: 0, signature: signature}); + signedQuote = abi.encodePacked(uint8(SignedQuoteLib.QUOTE_WIRE_FORMAT_VERSION), abi.encode(q)); + } + + function _encodeSignedQuoteWithVersion( + IFeeManager.QuoteItem[] memory items, + uint256 expiry, + address feeToken, + uint256 nonce, + uint256 signerPk, + address verifyingContract, + uint8 version + ) internal view returns (bytes memory) { + bytes32 digest = keccak256( + abi.encodePacked( + hex"1901", _domainSeparator(verifyingContract), _quoteStructHash(items, expiry, feeToken, nonce) + ) + ); + (uint8 v, bytes32 r, bytes32 s) = vm.sign(signerPk, digest); + IFeeManager.Quote memory q = IFeeManager.Quote({ + items: items, expiry: expiry, feeToken: feeToken, nonce: nonce, signature: abi.encodePacked(r, s, v) + }); + return abi.encodePacked(version, abi.encode(q)); + } + + function _sliceFrom(bytes memory data, uint256 start) internal pure returns (bytes memory result) { + require(start < data.length + 1, "slice out of bounds"); + result = new bytes(data.length - start); + for (uint256 i = 0; i < result.length; ++i) { + result[i] = data[start + i]; + } + } + + function _encodeLookupQuote(IFeeManager.QuoteItem[] memory items, address feeToken, uint256 nonce) + internal + pure + returns (bytes memory) + { + IFeeManager.Quote memory q = IFeeManager.Quote({ + items: items, expiry: 0, feeToken: feeToken, nonce: nonce, signature: new bytes(65) + }); + return abi.encodePacked(uint8(SignedQuoteLib.QUOTE_WIRE_FORMAT_VERSION), abi.encode(q)); + } + + // ───────────────────── SignedQuoteLib wire format version ───────────────────── + + function test_signedQuoteLib_wireFormatVersion_isOne() public pure { + assertEq(SignedQuoteLib.QUOTE_WIRE_FORMAT_VERSION, uint8(1)); + } + + // ───────────────────── initialize ───────────────────── + + function test_initialize_setsOwner() public view { + assertEq(feeManager.owner(), owner); + } + + function test_initialize_hasNoPendingOwner() public view { + assertEq(feeManager.pendingOwner(), address(0)); + } + + function test_initialize_grantsDefaultAdminRole() public view { + assertTrue(feeManager.hasRole(feeManager.DEFAULT_ADMIN_ROLE(), owner)); + } + + function test_initialize_setsCallerAdminAsRoleAdminForAllowedCaller() public view { + assertEq(feeManager.getRoleAdmin(Roles.ALLOWED_CALLER_ROLE), Roles.CALLER_ADMIN_ROLE); + } + + function test_proxy_initializeRevertsWhenCalledTwice() public { + vm.expectRevert(Initializable.InvalidInitialization.selector); + feeManager.initialize(makeAddr("attacker")); + } + + // ───────────────────── collectFeesWithQuote ───────────────────── + + function test_collectFeesWithQuote_emptyRequests_withoutTrustedSigner_succeeds() public { + FeeManager fresh = _deployFeeManagerProxyWithFeeTaker(owner, address(mockFeeTaker)); + vm.startPrank(owner); + fresh.grantRole(Roles.CALLER_ADMIN_ROLE, owner); + fresh.grantRole(Roles.ALLOWED_CALLER_ROLE, allowedCaller); + vm.stopPrank(); + + IFeeManager.FeeWithQuoteRequest[] memory requests = new IFeeManager.FeeWithQuoteRequest[](0); + + vm.prank(allowedCaller); + uint256 amount = fresh.collectFeesWithQuote(CORRELATION_HASH, requests, _claim("")); + assertEq(amount, 0); + } + + function test_collectFeesWithQuote_emptyRequests() public { + IFeeManager.FeeWithQuoteRequest[] memory requests = new IFeeManager.FeeWithQuoteRequest[](0); + + vm.prank(allowedCaller); + uint256 amount = feeManager.collectFeesWithQuote(CORRELATION_HASH, requests, _claim("")); + + assertEq(amount, 0); + } + + function test_collectFeesWithQuote_emptyRequests_revertsIfNativeSent() public { + IFeeManager.FeeWithQuoteRequest[] memory requests = new IFeeManager.FeeWithQuoteRequest[](0); + vm.deal(allowedCaller, 1 ether); + vm.prank(allowedCaller); + vm.expectRevert( + abi.encodeWithSelector(FeeManager.IncorrectNativePayment.selector, uint256(0), uint256(0.5 ether)) + ); + feeManager.collectFeesWithQuote{value: 0.5 ether}(CORRELATION_HASH, requests, _claim("")); + } + + function test_collectFeesWithQuote_succeedsWhenQuoteNotExpired_timestampMode() public { + string[] memory args = new string[](2); + args[0] = "chain:137"; + args[1] = "token:USDC"; + bytes32 argsHash = keccak256(abi.encode(args)); + + IFeeManager.QuoteItem[] memory items = new IFeeManager.QuoteItem[](1); + items[0] = IFeeManager.QuoteItem({quoteType: QuoteTypes.FORWARD, quoteArgsHash: argsHash, amount: 0.25 ether}); + // Mode byte 0x00: deadline is a Unix timestamp sufficiently in the future. + uint256 expiry = block.timestamp + 1 days; + bytes memory signedQuote = + _encodeSignedQuote(items, expiry, address(0), 0, TRUSTED_SIGNER_PK, address(feeManager)); + + IFeeManager.FeeWithQuoteRequest[] memory requests = new IFeeManager.FeeWithQuoteRequest[](1); + requests[0] = IFeeManager.FeeWithQuoteRequest({quoteType: QuoteTypes.FORWARD, quoteArgs: args, required: true}); + + vm.expectEmit(true, true, false, true); + emit FeeCollected( + allowedCaller, + CORRELATION_HASH, + QuoteTypes.FORWARD, + args, + payable(refundAddress), + payable(address(mockFeeTaker)), + address(0), + 0.25 ether, + 0 + ); + + vm.deal(allowedCaller, 1 ether); + vm.prank(allowedCaller); + uint256 amount = + feeManager.collectFeesWithQuote{value: 0.25 ether}(CORRELATION_HASH, requests, _claim(signedQuote)); + + assertEq(amount, 0.25 ether); + assertEq(address(feeManager).balance, 0, "FeeManager should not hold funds"); + assertEq(address(mockFeeTaker).balance, 0.25 ether, "FeeTaker should receive native fees"); + assertEq(mockFeeTaker.nativeFeeReceived(), 0.25 ether); + assertEq( + mockFeeTaker.lastBeneficiary(), + mockFeeTaker.protocolFeeAddress(), + "beneficiary should be protocolFeeAddress" + ); + } + + function test_collectFeesWithQuote_succeedsWhenQuoteNotExpired_blockNumberMode() public { + string[] memory args = new string[](2); + args[0] = "chain:137"; + args[1] = "token:USDC"; + bytes32 argsHash = keccak256(abi.encode(args)); + + IFeeManager.QuoteItem[] memory items = new IFeeManager.QuoteItem[](1); + items[0] = IFeeManager.QuoteItem({quoteType: QuoteTypes.FORWARD, quoteArgsHash: argsHash, amount: 0.25 ether}); + // Mode byte 0x01: deadline is a block height sufficiently ahead of the current block. + uint256 expiry = (uint256(1) << 248) | (block.number + 1000); + bytes memory signedQuote = + _encodeSignedQuote(items, expiry, address(0), 0, TRUSTED_SIGNER_PK, address(feeManager)); + + IFeeManager.FeeWithQuoteRequest[] memory requests = new IFeeManager.FeeWithQuoteRequest[](1); + requests[0] = IFeeManager.FeeWithQuoteRequest({quoteType: QuoteTypes.FORWARD, quoteArgs: args, required: true}); + + vm.expectEmit(true, true, false, true); + emit FeeCollected( + allowedCaller, + CORRELATION_HASH, + QuoteTypes.FORWARD, + args, + payable(refundAddress), + payable(address(mockFeeTaker)), + address(0), + 0.25 ether, + 0 + ); + + vm.deal(allowedCaller, 1 ether); + vm.prank(allowedCaller); + uint256 amount = + feeManager.collectFeesWithQuote{value: 0.25 ether}(CORRELATION_HASH, requests, _claim(signedQuote)); + + assertEq(amount, 0.25 ether); + assertEq(address(feeManager).balance, 0, "FeeManager should not hold funds"); + assertEq(mockFeeTaker.nativeFeeReceived(), 0.25 ether); + } + + function test_collectFeesWithQuote_emitsFeeCollectedPerMatchedRequest() public { + string[] memory args0 = new string[](1); + args0[0] = "arg0"; + string[] memory args1 = new string[](1); + args1[0] = "arg1"; + + IFeeManager.QuoteItem[] memory items = new IFeeManager.QuoteItem[](2); + items[0] = IFeeManager.QuoteItem({ + quoteType: QuoteTypes.FORWARD, quoteArgsHash: keccak256(abi.encode(args0)), amount: 10 + }); + items[1] = IFeeManager.QuoteItem({ + quoteType: QuoteTypes.PRE_FINALITY, quoteArgsHash: keccak256(abi.encode(args1)), amount: 20 + }); + bytes memory signedQuote = + _encodeSignedQuote(items, block.timestamp + 1 days, address(0), 0, TRUSTED_SIGNER_PK, address(feeManager)); + + IFeeManager.FeeWithQuoteRequest[] memory requests = new IFeeManager.FeeWithQuoteRequest[](2); + requests[0] = IFeeManager.FeeWithQuoteRequest({quoteType: QuoteTypes.FORWARD, quoteArgs: args0, required: true}); + requests[1] = + IFeeManager.FeeWithQuoteRequest({quoteType: QuoteTypes.PRE_FINALITY, quoteArgs: args1, required: false}); + + vm.expectEmit(true, true, false, true); + emit FeeCollected( + allowedCaller, + CORRELATION_HASH, + QuoteTypes.FORWARD, + args0, + payable(refundAddress), + payable(address(mockFeeTaker)), + address(0), + 10, + 0 + ); + vm.expectEmit(true, true, false, true); + emit FeeCollected( + allowedCaller, + CORRELATION_HASH, + QuoteTypes.PRE_FINALITY, + args1, + payable(refundAddress), + payable(address(mockFeeTaker)), + address(0), + 20, + 0 + ); + + vm.deal(allowedCaller, 100); + vm.prank(allowedCaller); + uint256 amount = feeManager.collectFeesWithQuote{value: 30}(CORRELATION_HASH, requests, _claim(signedQuote)); + + assertEq(amount, 30); + } + + function test_collectFeesWithQuote_revertsSignedQuoteDecodeFailed() public { + IFeeManager.FeeWithQuoteRequest[] memory requests = new IFeeManager.FeeWithQuoteRequest[](1); + string[] memory args = new string[](0); + requests[0] = IFeeManager.FeeWithQuoteRequest({quoteType: "X", quoteArgs: args, required: true}); + + vm.prank(allowedCaller); + vm.expectRevert(IFeeManager.SignedQuoteDecodeFailed.selector); + feeManager.collectFeesWithQuote(CORRELATION_HASH, requests, _claim(hex"01deadbeef")); + } + + function test_collectFeesWithQuote_revertsUnsupportedQuoteVersion_zero() public { + IFeeManager.FeeWithQuoteRequest[] memory requests = new IFeeManager.FeeWithQuoteRequest[](1); + string[] memory args = new string[](0); + requests[0] = IFeeManager.FeeWithQuoteRequest({quoteType: "X", quoteArgs: args, required: true}); + + vm.prank(allowedCaller); + vm.expectRevert(abi.encodeWithSelector(IFeeManager.UnsupportedQuoteVersion.selector, uint8(0))); + feeManager.collectFeesWithQuote(CORRELATION_HASH, requests, _claim(hex"00aabbccdd")); + } + + function test_collectFeesWithQuote_revertsUnsupportedQuoteVersion_future() public { + string[] memory args = new string[](0); + IFeeManager.QuoteItem[] memory items = new IFeeManager.QuoteItem[](1); + items[0] = IFeeManager.QuoteItem({ + quoteType: QuoteTypes.FORWARD, quoteArgsHash: keccak256(abi.encode(args)), amount: 1 + }); + bytes memory signedQuote = _encodeSignedQuoteWithVersion( + items, block.timestamp + 1 days, address(0), 0, TRUSTED_SIGNER_PK, address(feeManager), 2 + ); + + IFeeManager.FeeWithQuoteRequest[] memory requests = new IFeeManager.FeeWithQuoteRequest[](1); + requests[0] = IFeeManager.FeeWithQuoteRequest({quoteType: QuoteTypes.FORWARD, quoteArgs: args, required: true}); + + vm.deal(allowedCaller, 1 ether); + vm.prank(allowedCaller); + vm.expectRevert(abi.encodeWithSelector(IFeeManager.UnsupportedQuoteVersion.selector, uint8(2))); + feeManager.collectFeesWithQuote{value: 1}(CORRELATION_HASH, requests, _claim(signedQuote)); + } + + function test_collectFeesWithQuote_revertsSignedQuoteDecodeFailed_emptyPayload() public { + IFeeManager.FeeWithQuoteRequest[] memory requests = new IFeeManager.FeeWithQuoteRequest[](1); + string[] memory args = new string[](0); + requests[0] = IFeeManager.FeeWithQuoteRequest({quoteType: "X", quoteArgs: args, required: true}); + + vm.prank(allowedCaller); + vm.expectRevert(IFeeManager.SignedQuoteDecodeFailed.selector); + feeManager.collectFeesWithQuote(CORRELATION_HASH, requests, _claim("")); + } + + /// @dev Complements `QuoteLib.t.sol`: ensures `collectFeesWithQuote` applies expiry checks on the full path. + function test_collectFeesWithQuote_revertsQuoteExpired_timestampMode() public { + string[] memory args = new string[](0); + IFeeManager.QuoteItem[] memory items = new IFeeManager.QuoteItem[](1); + items[0] = IFeeManager.QuoteItem({ + quoteType: QuoteTypes.FORWARD, quoteArgsHash: keccak256(abi.encode(args)), amount: 1 + }); + uint256 expiry = block.timestamp - 1; + bytes memory signedQuote = + _encodeSignedQuote(items, expiry, address(0), 0, TRUSTED_SIGNER_PK, address(feeManager)); + + IFeeManager.FeeWithQuoteRequest[] memory requests = new IFeeManager.FeeWithQuoteRequest[](1); + requests[0] = IFeeManager.FeeWithQuoteRequest({quoteType: QuoteTypes.FORWARD, quoteArgs: args, required: true}); + + vm.prank(allowedCaller); + vm.expectRevert(abi.encodeWithSelector(QuoteLib.QuoteExpired.selector, uint8(0), expiry, block.timestamp)); + feeManager.collectFeesWithQuote(CORRELATION_HASH, requests, _claim(signedQuote)); + } + + function test_collectFeesWithQuote_revertsQuoteExpired_blockNumberMode() public { + string[] memory args = new string[](0); + IFeeManager.QuoteItem[] memory items = new IFeeManager.QuoteItem[](1); + items[0] = IFeeManager.QuoteItem({ + quoteType: QuoteTypes.FORWARD, quoteArgsHash: keccak256(abi.encode(args)), amount: 1 + }); + uint256 deadline = block.number - 1; + uint256 expiry = (uint256(1) << 248) | deadline; + bytes memory signedQuote = + _encodeSignedQuote(items, expiry, address(0), 0, TRUSTED_SIGNER_PK, address(feeManager)); + + IFeeManager.FeeWithQuoteRequest[] memory requests = new IFeeManager.FeeWithQuoteRequest[](1); + requests[0] = IFeeManager.FeeWithQuoteRequest({quoteType: QuoteTypes.FORWARD, quoteArgs: args, required: true}); + + vm.prank(allowedCaller); + vm.expectRevert(abi.encodeWithSelector(QuoteLib.QuoteExpired.selector, uint8(1), deadline, block.number)); + feeManager.collectFeesWithQuote(CORRELATION_HASH, requests, _claim(signedQuote)); + } + + function test_collectFeesWithQuote_revertsUnsupportedExpiryMode() public { + string[] memory args = new string[](0); + IFeeManager.QuoteItem[] memory items = new IFeeManager.QuoteItem[](1); + items[0] = IFeeManager.QuoteItem({ + quoteType: QuoteTypes.FORWARD, quoteArgsHash: keccak256(abi.encode(args)), amount: 1 + }); + uint256 expiry = (uint256(0x02) << 248) | uint256(block.timestamp + 1 days); + bytes memory signedQuote = + _encodeSignedQuote(items, expiry, address(0), 0, TRUSTED_SIGNER_PK, address(feeManager)); + + IFeeManager.FeeWithQuoteRequest[] memory requests = new IFeeManager.FeeWithQuoteRequest[](1); + requests[0] = IFeeManager.FeeWithQuoteRequest({quoteType: QuoteTypes.FORWARD, quoteArgs: args, required: true}); + + vm.prank(allowedCaller); + vm.expectRevert(abi.encodeWithSelector(QuoteLib.UnsupportedExpiryMode.selector, uint8(0x02))); + feeManager.collectFeesWithQuote(CORRELATION_HASH, requests, _claim(signedQuote)); + } + + function test_collectFeesWithQuote_emptyQuoteSucceedsForNonRequiredRequests() public { + IFeeManager.QuoteItem[] memory items = new IFeeManager.QuoteItem[](0); + bytes memory signedQuote = + _encodeSignedQuote(items, block.timestamp + 1 days, address(0), 0, TRUSTED_SIGNER_PK, address(feeManager)); + + IFeeManager.FeeWithQuoteRequest[] memory requests = new IFeeManager.FeeWithQuoteRequest[](1); + string[] memory args = new string[](0); + requests[0] = IFeeManager.FeeWithQuoteRequest({quoteType: QuoteTypes.FORWARD, quoteArgs: args, required: false}); + + vm.prank(allowedCaller); + uint256 amount = feeManager.collectFeesWithQuote(CORRELATION_HASH, requests, _claim(signedQuote)); + assertEq(amount, 0); + } + + function test_collectFeesWithQuote_revertsDuplicateRequestType() public { + string[] memory args = new string[](0); + IFeeManager.QuoteItem[] memory items = new IFeeManager.QuoteItem[](1); + items[0] = IFeeManager.QuoteItem({ + quoteType: QuoteTypes.FORWARD, quoteArgsHash: keccak256(abi.encode(args)), amount: 3 + }); + bytes memory signedQuote = + _encodeSignedQuote(items, block.timestamp + 1 days, address(0), 0, TRUSTED_SIGNER_PK, address(feeManager)); + + IFeeManager.FeeWithQuoteRequest[] memory requests = new IFeeManager.FeeWithQuoteRequest[](2); + requests[0] = IFeeManager.FeeWithQuoteRequest({quoteType: QuoteTypes.FORWARD, quoteArgs: args, required: true}); + requests[1] = IFeeManager.FeeWithQuoteRequest({quoteType: QuoteTypes.FORWARD, quoteArgs: args, required: true}); + + vm.deal(allowedCaller, 20); + vm.prank(allowedCaller); + vm.expectRevert(abi.encodeWithSelector(IFeeManager.DuplicateRequestType.selector, QuoteTypes.FORWARD)); + feeManager.collectFeesWithQuote{value: 6}(CORRELATION_HASH, requests, _claim(signedQuote)); + } + + function test_collectFeesWithQuote_revertsInvalidQuoteSigner_tamperedAmount() public { + string[] memory args = new string[](0); + IFeeManager.QuoteItem[] memory items = new IFeeManager.QuoteItem[](1); + items[0] = IFeeManager.QuoteItem({ + quoteType: QuoteTypes.FORWARD, quoteArgsHash: keccak256(abi.encode(args)), amount: 100 + }); + bytes memory signedQuote = + _encodeSignedQuote(items, block.timestamp + 1 days, address(0), 0, TRUSTED_SIGNER_PK, address(feeManager)); + + IFeeManager.Quote memory tampered = abi.decode(_sliceFrom(signedQuote, 1), (IFeeManager.Quote)); + tampered.items[0].amount = 999; + bytes memory tamperedEncoded = + abi.encodePacked(uint8(SignedQuoteLib.QUOTE_WIRE_FORMAT_VERSION), abi.encode(tampered)); + + IFeeManager.FeeWithQuoteRequest[] memory requests = new IFeeManager.FeeWithQuoteRequest[](1); + requests[0] = IFeeManager.FeeWithQuoteRequest({quoteType: QuoteTypes.FORWARD, quoteArgs: args, required: true}); + + vm.deal(allowedCaller, 2000); + vm.prank(allowedCaller); + vm.expectRevert(); + feeManager.collectFeesWithQuote{value: 999}(CORRELATION_HASH, requests, _claim(tamperedEncoded)); + } + + function test_collectFeesWithQuote_revertsInvalidQuoteSigner_wrongKey() public { + uint256 otherPk = 0x5de4111afa1a4b94908f83103eb1f1706367c2e68ca870fc3fb9a804cdab365a; + address otherAddr = vm.addr(otherPk); + assertTrue(otherAddr != trustedSigner); + + string[] memory args = new string[](0); + IFeeManager.QuoteItem[] memory items = new IFeeManager.QuoteItem[](1); + items[0] = IFeeManager.QuoteItem({ + quoteType: QuoteTypes.FORWARD, quoteArgsHash: keccak256(abi.encode(args)), amount: 1 + }); + bytes memory signedQuote = + _encodeSignedQuote(items, block.timestamp + 1 days, address(0), 0, otherPk, address(feeManager)); + + IFeeManager.FeeWithQuoteRequest[] memory requests = new IFeeManager.FeeWithQuoteRequest[](1); + requests[0] = IFeeManager.FeeWithQuoteRequest({quoteType: QuoteTypes.FORWARD, quoteArgs: args, required: true}); + + vm.deal(allowedCaller, 10); + vm.prank(allowedCaller); + vm.expectRevert(abi.encodeWithSelector(FeeManager.InvalidQuoteSigner.selector, otherAddr)); + feeManager.collectFeesWithQuote{value: 1}(CORRELATION_HASH, requests, _claim(signedQuote)); + } + + function test_collectFeesWithQuote_revertsInvalidQuoteSigner_wrongDomainNameVersion() public { + string[] memory args = new string[](0); + IFeeManager.QuoteItem[] memory items = new IFeeManager.QuoteItem[](1); + items[0] = IFeeManager.QuoteItem({ + quoteType: QuoteTypes.FORWARD, quoteArgsHash: keccak256(abi.encode(args)), amount: 1 + }); + (bytes memory signedQuote, address recovered) = _encodeSignedQuoteWithWrongDomain(items); + + IFeeManager.FeeWithQuoteRequest[] memory requests = new IFeeManager.FeeWithQuoteRequest[](1); + requests[0] = IFeeManager.FeeWithQuoteRequest({quoteType: QuoteTypes.FORWARD, quoteArgs: args, required: true}); + + vm.deal(allowedCaller, 10); + vm.prank(allowedCaller); + vm.expectRevert(abi.encodeWithSelector(FeeManager.InvalidQuoteSigner.selector, recovered)); + feeManager.collectFeesWithQuote{value: 1}(CORRELATION_HASH, requests, _claim(signedQuote)); + } + + function test_domainSeparator_constants_matchDocumentedPreimages() public pure { + assertEq(keccak256(bytes("FeeManager")), 0x42e3570c507db8e472a4592e53f4b6df78eb7c8a8d593e718bb47b707f2c6a90); + assertEq(keccak256(bytes("1")), 0xc89efdaa54c0f20c7adf612882df0950f5a951637e0307cdcb4c672f298b8bc6); + } + + function test_collectFeesWithQuote_revertsRequiredFeeNotInQuote() public { + string[] memory preFinArgs = new string[](1); + preFinArgs[0] = "x"; + string[] memory forwardArgs = new string[](1); + forwardArgs[0] = "y"; + + IFeeManager.QuoteItem[] memory items = new IFeeManager.QuoteItem[](1); + items[0] = IFeeManager.QuoteItem({ + quoteType: QuoteTypes.PRE_FINALITY, quoteArgsHash: keccak256(abi.encode(preFinArgs)), amount: 1 + }); + bytes memory signedQuote = + _encodeSignedQuote(items, block.timestamp + 1 days, address(0), 0, TRUSTED_SIGNER_PK, address(feeManager)); + + IFeeManager.FeeWithQuoteRequest[] memory requests = new IFeeManager.FeeWithQuoteRequest[](1); + requests[0] = + IFeeManager.FeeWithQuoteRequest({quoteType: QuoteTypes.FORWARD, quoteArgs: forwardArgs, required: true}); + bytes32 wantedHash = keccak256(abi.encode(forwardArgs)); + + vm.prank(allowedCaller); + vm.expectRevert( + abi.encodeWithSelector(IFeeManager.RequiredFeeNotInQuote.selector, QuoteTypes.FORWARD, wantedHash) + ); + feeManager.collectFeesWithQuote(CORRELATION_HASH, requests, _claim(signedQuote)); + } + + function test_collectFeesWithQuote_revertsQuoteArgsMismatch() public { + string[] memory argsWanted = new string[](1); + argsWanted[0] = "need-this"; + string[] memory argsInQuote = new string[](1); + argsInQuote[0] = "other"; + + IFeeManager.QuoteItem[] memory items = new IFeeManager.QuoteItem[](1); + items[0] = IFeeManager.QuoteItem({ + quoteType: QuoteTypes.FORWARD, quoteArgsHash: keccak256(abi.encode(argsInQuote)), amount: 1 + }); + bytes memory signedQuote = + _encodeSignedQuote(items, block.timestamp + 1 days, address(0), 0, TRUSTED_SIGNER_PK, address(feeManager)); + + IFeeManager.FeeWithQuoteRequest[] memory requests = new IFeeManager.FeeWithQuoteRequest[](1); + requests[0] = + IFeeManager.FeeWithQuoteRequest({quoteType: QuoteTypes.FORWARD, quoteArgs: argsWanted, required: true}); + bytes32 wantedHash = keccak256(abi.encode(argsWanted)); + + vm.prank(allowedCaller); + vm.expectRevert(abi.encodeWithSelector(IFeeManager.QuoteArgsMismatch.selector, QuoteTypes.FORWARD, wantedHash)); + feeManager.collectFeesWithQuote(CORRELATION_HASH, requests, _claim(signedQuote)); + } + + function test_collectFeesWithQuote_revertsQuoteArgsMismatch_whenNotRequired() public { + string[] memory argsWanted = new string[](1); + argsWanted[0] = "need-this"; + string[] memory argsInQuote = new string[](1); + argsInQuote[0] = "other"; + + IFeeManager.QuoteItem[] memory items = new IFeeManager.QuoteItem[](1); + items[0] = IFeeManager.QuoteItem({ + quoteType: QuoteTypes.FORWARD, quoteArgsHash: keccak256(abi.encode(argsInQuote)), amount: 1 + }); + bytes memory signedQuote = + _encodeSignedQuote(items, block.timestamp + 1 days, address(0), 0, TRUSTED_SIGNER_PK, address(feeManager)); + + IFeeManager.FeeWithQuoteRequest[] memory requests = new IFeeManager.FeeWithQuoteRequest[](1); + requests[0] = + IFeeManager.FeeWithQuoteRequest({quoteType: QuoteTypes.FORWARD, quoteArgs: argsWanted, required: false}); + bytes32 wantedHash = keccak256(abi.encode(argsWanted)); + + vm.prank(allowedCaller); + vm.expectRevert(abi.encodeWithSelector(IFeeManager.QuoteArgsMismatch.selector, QuoteTypes.FORWARD, wantedHash)); + feeManager.collectFeesWithQuote(CORRELATION_HASH, requests, _claim(signedQuote)); + } + + function test_collectFeesWithQuote_optionalRequest_skipsWhenNoMatch() public { + string[] memory inQuote = new string[](1); + inQuote[0] = "only"; + + IFeeManager.QuoteItem[] memory items = new IFeeManager.QuoteItem[](1); + items[0] = IFeeManager.QuoteItem({ + quoteType: QuoteTypes.FORWARD, quoteArgsHash: keccak256(abi.encode(inQuote)), amount: 5 + }); + bytes memory signedQuote = + _encodeSignedQuote(items, block.timestamp + 1 days, address(0), 0, TRUSTED_SIGNER_PK, address(feeManager)); + + string[] memory missingArgs = new string[](1); + missingArgs[0] = "missing"; + IFeeManager.FeeWithQuoteRequest[] memory requests = new IFeeManager.FeeWithQuoteRequest[](1); + requests[0] = IFeeManager.FeeWithQuoteRequest({ + quoteType: QuoteTypes.PRE_FINALITY, quoteArgs: missingArgs, required: false + }); + + vm.prank(allowedCaller); + uint256 amount = feeManager.collectFeesWithQuote(CORRELATION_HASH, requests, _claim(signedQuote)); + assertEq(amount, 0); + } + + function test_collectFeesWithQuote_partialMatch_onlyMatchedEmitEvents() public { + string[] memory a0 = new string[](1); + a0[0] = "m0"; + string[] memory a1 = new string[](1); + a1[0] = "m1"; + + IFeeManager.QuoteItem[] memory items = new IFeeManager.QuoteItem[](1); + items[0] = + IFeeManager.QuoteItem({quoteType: QuoteTypes.FORWARD, quoteArgsHash: keccak256(abi.encode(a0)), amount: 7}); + bytes memory signedQuote = + _encodeSignedQuote(items, block.timestamp + 1 days, address(0), 0, TRUSTED_SIGNER_PK, address(feeManager)); + + IFeeManager.FeeWithQuoteRequest[] memory requests = new IFeeManager.FeeWithQuoteRequest[](2); + requests[0] = IFeeManager.FeeWithQuoteRequest({quoteType: QuoteTypes.FORWARD, quoteArgs: a0, required: true}); + requests[1] = IFeeManager.FeeWithQuoteRequest({quoteType: "OTHER", quoteArgs: a1, required: false}); + + vm.expectEmit(true, true, false, true); + emit FeeCollected( + allowedCaller, + CORRELATION_HASH, + QuoteTypes.FORWARD, + a0, + payable(refundAddress), + payable(address(mockFeeTaker)), + address(0), + 7, + 0 + ); + + vm.deal(allowedCaller, 100); + vm.prank(allowedCaller); + uint256 amount = feeManager.collectFeesWithQuote{value: 7}(CORRELATION_HASH, requests, _claim(signedQuote)); + assertEq(amount, 7); + } + + function test_collectFeesWithQuote_argsHashMismatch_sameQuoteType_revertsQuoteArgsMismatch() public { + string[] memory inQuote = new string[](1); + inQuote[0] = "in-quote"; + string[] memory wrongArgs = new string[](1); + wrongArgs[0] = "different"; + + IFeeManager.QuoteItem[] memory items = new IFeeManager.QuoteItem[](1); + items[0] = IFeeManager.QuoteItem({ + quoteType: QuoteTypes.FORWARD, quoteArgsHash: keccak256(abi.encode(inQuote)), amount: 1 + }); + bytes memory signedQuote = + _encodeSignedQuote(items, block.timestamp + 1 days, address(0), 0, TRUSTED_SIGNER_PK, address(feeManager)); + + IFeeManager.FeeWithQuoteRequest[] memory requests = new IFeeManager.FeeWithQuoteRequest[](1); + requests[0] = + IFeeManager.FeeWithQuoteRequest({quoteType: QuoteTypes.FORWARD, quoteArgs: wrongArgs, required: true}); + bytes32 wrongHash = keccak256(abi.encode(wrongArgs)); + + vm.prank(allowedCaller); + vm.expectRevert(abi.encodeWithSelector(IFeeManager.QuoteArgsMismatch.selector, QuoteTypes.FORWARD, wrongHash)); + feeManager.collectFeesWithQuote(CORRELATION_HASH, requests, _claim(signedQuote)); + } + + function test_collectFeesWithQuote_e2e_signEncodeSubmit() public { + string[] memory args = new string[](1); + args[0] = "e2e"; + IFeeManager.QuoteItem[] memory items = new IFeeManager.QuoteItem[](1); + items[0] = IFeeManager.QuoteItem({ + quoteType: QuoteTypes.FORWARD, quoteArgsHash: keccak256(abi.encode(args)), amount: 42 + }); + bytes memory signedQuote = + _encodeSignedQuote(items, block.timestamp + 1 hours, address(0), 0, TRUSTED_SIGNER_PK, address(feeManager)); + + IFeeManager.FeeWithQuoteRequest[] memory requests = new IFeeManager.FeeWithQuoteRequest[](1); + requests[0] = IFeeManager.FeeWithQuoteRequest({quoteType: QuoteTypes.FORWARD, quoteArgs: args, required: true}); + + vm.deal(allowedCaller, 100); + vm.prank(allowedCaller); + uint256 out = feeManager.collectFeesWithQuote{value: 42}(CORRELATION_HASH, requests, _claim(signedQuote)); + assertEq(out, 42); + assertEq(address(feeManager).balance, 0); + assertEq(address(mockFeeTaker).balance, 42); + } + + // ───────────────────── collectFeesWithQuote (decoded `Quote` overload) ───────────────────── + + function test_collectFeesWithQuote_decodedQuote_native_happyPath() public { + string[] memory args = new string[](1); + args[0] = "e2e"; + IFeeManager.QuoteItem[] memory items = new IFeeManager.QuoteItem[](1); + items[0] = IFeeManager.QuoteItem({ + quoteType: QuoteTypes.FORWARD, quoteArgsHash: keccak256(abi.encode(args)), amount: 42 + }); + bytes memory signedQuote = + _encodeSignedQuote(items, block.timestamp + 1 hours, address(0), 0, TRUSTED_SIGNER_PK, address(feeManager)); + IFeeManager.Quote memory decoded = feeManager.decodeQuote(signedQuote); + + IFeeManager.FeeWithQuoteRequest[] memory requests = new IFeeManager.FeeWithQuoteRequest[](1); + requests[0] = IFeeManager.FeeWithQuoteRequest({quoteType: QuoteTypes.FORWARD, quoteArgs: args, required: true}); + + vm.deal(allowedCaller, 100); + vm.prank(allowedCaller); + uint256 out = feeManager.collectFeesWithQuote{value: 42}(CORRELATION_HASH, requests, _decodedClaim(decoded)); + assertEq(out, 42); + assertEq(address(feeManager).balance, 0); + assertEq(address(mockFeeTaker).balance, 42); + } + + function test_collectFeesWithQuote_decodedQuote_erc20_happyPath() public { + MockERC20 token = new MockERC20(); + string[] memory args = new string[](0); + IFeeManager.QuoteItem[] memory items = new IFeeManager.QuoteItem[](1); + items[0] = IFeeManager.QuoteItem({ + quoteType: QuoteTypes.FORWARD, quoteArgsHash: keccak256(abi.encode(args)), amount: 100e18 + }); + bytes memory signedQuote = _encodeSignedQuote( + items, block.timestamp + 1 days, address(token), 0, TRUSTED_SIGNER_PK, address(feeManager) + ); + IFeeManager.Quote memory decoded = feeManager.decodeQuote(signedQuote); + + IFeeManager.FeeWithQuoteRequest[] memory requests = new IFeeManager.FeeWithQuoteRequest[](1); + requests[0] = IFeeManager.FeeWithQuoteRequest({quoteType: QuoteTypes.FORWARD, quoteArgs: args, required: true}); + + token.mint(allowedCaller, 100e18); + vm.prank(allowedCaller); + token.approve(address(feeManager), 100e18); + + vm.prank(allowedCaller); + uint256 amount = feeManager.collectFeesWithQuote(CORRELATION_HASH, requests, _decodedClaim(decoded)); + assertEq(amount, 100e18); + assertEq(token.balanceOf(address(feeManager)), 0, "FeeManager should not hold ERC-20 funds"); + assertEq(token.balanceOf(address(mockFeeTaker)), 100e18, "FeeTaker should receive ERC-20 fees"); + assertEq(mockFeeTaker.erc20FeeReceived(), 100e18); + assertEq( + token.allowance(address(feeManager), address(mockFeeTaker)), + 0, + "FeeManager should have zero residual allowance to FeeTaker" + ); + } + + function test_collectFeesWithQuote_decodedQuote_emptyRequests_returnsZero() public { + IFeeManager.FeeWithQuoteRequest[] memory requests = new IFeeManager.FeeWithQuoteRequest[](0); + IFeeManager.Quote memory dummy = IFeeManager.Quote({ + items: new IFeeManager.QuoteItem[](0), expiry: 0, feeToken: address(0), nonce: 0, signature: "" + }); + + vm.prank(allowedCaller); + uint256 amount = feeManager.collectFeesWithQuote(CORRELATION_HASH, requests, _decodedClaim(dummy)); + assertEq(amount, 0); + } + + function test_collectFeesWithQuote_decodedQuote_emptyRequests_revertsIfNativeSent() public { + IFeeManager.FeeWithQuoteRequest[] memory requests = new IFeeManager.FeeWithQuoteRequest[](0); + IFeeManager.Quote memory dummy = IFeeManager.Quote({ + items: new IFeeManager.QuoteItem[](0), expiry: 0, feeToken: address(0), nonce: 0, signature: "" + }); + vm.deal(allowedCaller, 1 ether); + vm.prank(allowedCaller); + vm.expectRevert( + abi.encodeWithSelector(FeeManager.IncorrectNativePayment.selector, uint256(0), uint256(0.5 ether)) + ); + feeManager.collectFeesWithQuote{value: 0.5 ether}(CORRELATION_HASH, requests, _decodedClaim(dummy)); + } + + /// @dev Exercises the post-match `else if (msg.value != 0)` branch in `_collectFeesWithQuoteDecoded`: + /// requests are non-empty (so the early-return path is bypassed), but the quote contains no + /// matching items (so `totalCollected == 0`), and `msg.value > 0` must trigger the revert. + function test_collectFeesWithQuote_decodedQuote_noMatchedItems_revertsIfNativeSent() public { + IFeeManager.QuoteItem[] memory items = new IFeeManager.QuoteItem[](1); + items[0] = IFeeManager.QuoteItem({quoteType: "OTHER", quoteArgsHash: bytes32(0), amount: 0}); + bytes memory signedQuote = + _encodeSignedQuote(items, block.timestamp + 1 days, address(0), 0, TRUSTED_SIGNER_PK, address(feeManager)); + IFeeManager.Quote memory decoded = feeManager.decodeQuote(signedQuote); + + IFeeManager.FeeWithQuoteRequest[] memory requests = new IFeeManager.FeeWithQuoteRequest[](1); + requests[0] = IFeeManager.FeeWithQuoteRequest({ + quoteType: QuoteTypes.FORWARD, quoteArgs: new string[](0), required: false + }); + + vm.deal(allowedCaller, 1 ether); + vm.prank(allowedCaller); + vm.expectRevert( + abi.encodeWithSelector(FeeManager.IncorrectNativePayment.selector, uint256(0), uint256(0.25 ether)) + ); + feeManager.collectFeesWithQuote{value: 0.25 ether}(CORRELATION_HASH, requests, _decodedClaim(decoded)); + } + + function test_collectFeesWithQuote_decodedQuote_revertsIfSignatureNot65Bytes() public { + string[] memory args = new string[](1); + args[0] = "e2e"; + IFeeManager.QuoteItem[] memory items = new IFeeManager.QuoteItem[](1); + items[0] = IFeeManager.QuoteItem({ + quoteType: QuoteTypes.FORWARD, quoteArgsHash: keccak256(abi.encode(args)), amount: 42 + }); + bytes memory signedQuote = + _encodeSignedQuote(items, block.timestamp + 1 hours, address(0), 0, TRUSTED_SIGNER_PK, address(feeManager)); + IFeeManager.Quote memory decoded = feeManager.decodeQuote(signedQuote); + decoded.signature = new bytes(64); + + IFeeManager.FeeWithQuoteRequest[] memory requests = new IFeeManager.FeeWithQuoteRequest[](1); + requests[0] = IFeeManager.FeeWithQuoteRequest({quoteType: QuoteTypes.FORWARD, quoteArgs: args, required: true}); + + vm.prank(allowedCaller); + vm.expectRevert(abi.encodeWithSelector(ECDSA.ECDSAInvalidSignatureLength.selector, uint256(64))); + feeManager.collectFeesWithQuote(CORRELATION_HASH, requests, _decodedClaim(decoded)); + } + + function test_collectFeesWithQuote_erc20_transferFromCaller() public { + MockERC20 token = new MockERC20(); + string[] memory args = new string[](0); + IFeeManager.QuoteItem[] memory items = new IFeeManager.QuoteItem[](1); + items[0] = IFeeManager.QuoteItem({ + quoteType: QuoteTypes.FORWARD, quoteArgsHash: keccak256(abi.encode(args)), amount: 100e18 + }); + bytes memory signedQuote = _encodeSignedQuote( + items, block.timestamp + 1 days, address(token), 0, TRUSTED_SIGNER_PK, address(feeManager) + ); + + IFeeManager.FeeWithQuoteRequest[] memory requests = new IFeeManager.FeeWithQuoteRequest[](1); + requests[0] = IFeeManager.FeeWithQuoteRequest({quoteType: QuoteTypes.FORWARD, quoteArgs: args, required: true}); + + token.mint(allowedCaller, 100e18); + vm.prank(allowedCaller); + token.approve(address(feeManager), 100e18); + + vm.prank(allowedCaller); + uint256 amount = feeManager.collectFeesWithQuote(CORRELATION_HASH, requests, _claim(signedQuote)); + assertEq(amount, 100e18); + assertEq(token.balanceOf(address(feeManager)), 0, "FeeManager should not hold ERC-20 funds"); + assertEq(token.balanceOf(address(mockFeeTaker)), 100e18, "FeeTaker should receive ERC-20 fees"); + assertEq(mockFeeTaker.erc20FeeReceived(), 100e18); + assertEq( + mockFeeTaker.lastBeneficiary(), + mockFeeTaker.protocolFeeAddress(), + "beneficiary should be protocolFeeAddress" + ); + assertEq( + token.allowance(address(feeManager), address(mockFeeTaker)), + 0, + "FeeManager should have zero residual allowance to FeeTaker" + ); + } + + function test_collectFeesWithQuote_revertsInsufficientNativePayment() public { + string[] memory args = new string[](0); + IFeeManager.QuoteItem[] memory items = new IFeeManager.QuoteItem[](1); + items[0] = IFeeManager.QuoteItem({ + quoteType: QuoteTypes.FORWARD, quoteArgsHash: keccak256(abi.encode(args)), amount: 100 + }); + bytes memory signedQuote = + _encodeSignedQuote(items, block.timestamp + 1 days, address(0), 0, TRUSTED_SIGNER_PK, address(feeManager)); + + IFeeManager.FeeWithQuoteRequest[] memory requests = new IFeeManager.FeeWithQuoteRequest[](1); + requests[0] = IFeeManager.FeeWithQuoteRequest({quoteType: QuoteTypes.FORWARD, quoteArgs: args, required: true}); + + vm.deal(allowedCaller, 200); + vm.prank(allowedCaller); + vm.expectRevert(abi.encodeWithSelector(FeeManager.IncorrectNativePayment.selector, uint256(100), uint256(50))); + feeManager.collectFeesWithQuote{value: 50}(CORRELATION_HASH, requests, _claim(signedQuote)); + } + + function test_collectFeesWithQuote_revertsNativeOverpayment() public { + string[] memory args = new string[](0); + IFeeManager.QuoteItem[] memory items = new IFeeManager.QuoteItem[](1); + items[0] = IFeeManager.QuoteItem({ + quoteType: QuoteTypes.FORWARD, quoteArgsHash: keccak256(abi.encode(args)), amount: 10 + }); + bytes memory signedQuote = + _encodeSignedQuote(items, block.timestamp + 1 days, address(0), 0, TRUSTED_SIGNER_PK, address(feeManager)); + + IFeeManager.FeeWithQuoteRequest[] memory requests = new IFeeManager.FeeWithQuoteRequest[](1); + requests[0] = IFeeManager.FeeWithQuoteRequest({quoteType: QuoteTypes.FORWARD, quoteArgs: args, required: true}); + + vm.deal(allowedCaller, 200); + vm.prank(allowedCaller); + vm.expectRevert(abi.encodeWithSelector(FeeManager.IncorrectNativePayment.selector, uint256(10), uint256(11))); + feeManager.collectFeesWithQuote{value: 11}(CORRELATION_HASH, requests, _claim(signedQuote)); + } + + function test_collectFeesWithQuote_revertsWithoutErc20Approval() public { + MockERC20 token = new MockERC20(); + string[] memory args = new string[](0); + IFeeManager.QuoteItem[] memory items = new IFeeManager.QuoteItem[](1); + items[0] = IFeeManager.QuoteItem({ + quoteType: QuoteTypes.FORWARD, quoteArgsHash: keccak256(abi.encode(args)), amount: 1e18 + }); + bytes memory signedQuote = _encodeSignedQuote( + items, block.timestamp + 1 days, address(token), 0, TRUSTED_SIGNER_PK, address(feeManager) + ); + + IFeeManager.FeeWithQuoteRequest[] memory requests = new IFeeManager.FeeWithQuoteRequest[](1); + requests[0] = IFeeManager.FeeWithQuoteRequest({quoteType: QuoteTypes.FORWARD, quoteArgs: args, required: true}); + + token.mint(allowedCaller, 10e18); + + vm.prank(allowedCaller); + vm.expectRevert(); + feeManager.collectFeesWithQuote(CORRELATION_HASH, requests, _claim(signedQuote)); + } + + function test_collectFeesWithQuote_revertsForBlacklistedRefundAddress() public { + // Non-zero refundAddress is screened against the configured USDC blacklist. + IFeeManager.FeeWithQuoteRequest[] memory requests = new IFeeManager.FeeWithQuoteRequest[](0); + address blocked = makeAddr("blockedRefund"); + mockUsdcBlacklist.setBlacklisted(blocked, true); + + vm.prank(allowedCaller); + vm.expectRevert(abi.encodeWithSelector(IFeeManager.DenylistedRefundAddress.selector, blocked)); + feeManager.collectFeesWithQuote(CORRELATION_HASH, requests, _claimWithRefund("", payable(blocked))); + } + + function test_collectFeesWithQuote_zeroRefundAddress_skipsBlacklistScreen() public { + // Sanity: a zero refundAddress must never trigger the blacklist screen, even if address(0) + // were to be marked blacklisted by a misconfigured backing contract. + IFeeManager.FeeWithQuoteRequest[] memory requests = new IFeeManager.FeeWithQuoteRequest[](0); + mockUsdcBlacklist.setBlacklisted(address(0), true); + + vm.prank(allowedCaller); + uint256 amount = + feeManager.collectFeesWithQuote(CORRELATION_HASH, requests, _claimWithRefund("", payable(address(0)))); + assertEq(amount, 0); + } + + function test_collectFeesWithQuote_revertsForNonAllowedCaller() public { + address stranger = makeAddr("stranger"); + IFeeManager.FeeWithQuoteRequest[] memory requests = new IFeeManager.FeeWithQuoteRequest[](0); + + vm.prank(stranger); + vm.expectRevert( + abi.encodeWithSelector( + IAccessControl.AccessControlUnauthorizedAccount.selector, stranger, Roles.ALLOWED_CALLER_ROLE + ) + ); + feeManager.collectFeesWithQuote(CORRELATION_HASH, requests, _claim("")); + } + + function test_collectFeesWithQuote_revertsTooManyQuoteItems() public { + IFeeManager.QuoteItem[] memory items = new IFeeManager.QuoteItem[](257); + for (uint256 i = 0; i < 257; ++i) { + items[i] = IFeeManager.QuoteItem({quoteType: "X", quoteArgsHash: bytes32(i), amount: 0}); + } + bytes memory signedQuote = + _encodeSignedQuote(items, block.timestamp + 1 days, address(0), 0, TRUSTED_SIGNER_PK, address(feeManager)); + string[] memory skipArgs = new string[](1); + skipArgs[0] = "n/a"; + IFeeManager.FeeWithQuoteRequest[] memory requests = new IFeeManager.FeeWithQuoteRequest[](1); + requests[0] = IFeeManager.FeeWithQuoteRequest({quoteType: "SKIP", quoteArgs: skipArgs, required: false}); + + vm.prank(allowedCaller); + vm.expectRevert(abi.encodeWithSelector(IFeeManager.TooManyQuoteItems.selector, uint256(257))); + feeManager.collectFeesWithQuote(CORRELATION_HASH, requests, _claim(signedQuote)); + } + + function test_collectFeesWithQuote_nonZeroNonce_reverts() public { + uint256 nonce = 7; + string[] memory args = new string[](0); + IFeeManager.QuoteItem[] memory items = new IFeeManager.QuoteItem[](1); + items[0] = IFeeManager.QuoteItem({ + quoteType: QuoteTypes.FORWARD, quoteArgsHash: keccak256(abi.encode(args)), amount: 5 + }); + bytes memory signedQuote = _encodeSignedQuote( + items, block.timestamp + 1 days, address(0), nonce, TRUSTED_SIGNER_PK, address(feeManager) + ); + IFeeManager.FeeWithQuoteRequest[] memory requests = new IFeeManager.FeeWithQuoteRequest[](1); + requests[0] = IFeeManager.FeeWithQuoteRequest({quoteType: QuoteTypes.FORWARD, quoteArgs: args, required: true}); + + vm.deal(allowedCaller, 20); + vm.prank(allowedCaller); + vm.expectRevert(abi.encodeWithSelector(QuoteLib.NonZeroNonce.selector, nonce)); + feeManager.collectFeesWithQuote{value: 5}(CORRELATION_HASH, requests, _claim(signedQuote)); + } + + function test_collectFeesWithQuote_nonceZero_allowsRepeatCollection() public { + string[] memory args = new string[](0); + IFeeManager.QuoteItem[] memory items = new IFeeManager.QuoteItem[](1); + items[0] = IFeeManager.QuoteItem({ + quoteType: QuoteTypes.FORWARD, quoteArgsHash: keccak256(abi.encode(args)), amount: 2 + }); + bytes memory signedQuote = + _encodeSignedQuote(items, block.timestamp + 1 days, address(0), 0, TRUSTED_SIGNER_PK, address(feeManager)); + IFeeManager.FeeWithQuoteRequest[] memory requests = new IFeeManager.FeeWithQuoteRequest[](1); + requests[0] = IFeeManager.FeeWithQuoteRequest({quoteType: QuoteTypes.FORWARD, quoteArgs: args, required: true}); + + vm.deal(allowedCaller, 10); + vm.prank(allowedCaller); + feeManager.collectFeesWithQuote{value: 2}(CORRELATION_HASH, requests, _claim(signedQuote)); + vm.prank(allowedCaller); + feeManager.collectFeesWithQuote{value: 2}(CORRELATION_HASH, requests, _claim(signedQuote)); + } + + // ───────────────────── AccessControl roles ───────────────────── + + function test_grantRole_callerAdminCanGrantAllowedCaller() public { + address newCaller = makeAddr("newCaller"); + vm.prank(callerAdmin); + feeManager.grantRole(Roles.ALLOWED_CALLER_ROLE, newCaller); + assertTrue(feeManager.hasRole(Roles.ALLOWED_CALLER_ROLE, newCaller)); + } + + function test_grantRole_callerAdminCanRevokeAllowedCaller() public { + vm.prank(callerAdmin); + feeManager.revokeRole(Roles.ALLOWED_CALLER_ROLE, allowedCaller); + assertFalse(feeManager.hasRole(Roles.ALLOWED_CALLER_ROLE, allowedCaller)); + } + + function test_grantRole_defaultAdminCanGrantCallerAdmin() public { + address newCallerAdmin = makeAddr("newCallerAdmin"); + vm.prank(owner); + feeManager.grantRole(Roles.CALLER_ADMIN_ROLE, newCallerAdmin); + assertTrue(feeManager.hasRole(Roles.CALLER_ADMIN_ROLE, newCallerAdmin)); + } + + function test_grantRole_defaultAdminCanGrantSignerAdmin() public { + address newSignerAdmin = makeAddr("newSignerAdmin"); + vm.prank(owner); + feeManager.grantRole(Roles.SIGNER_ADMIN_ROLE, newSignerAdmin); + assertTrue(feeManager.hasRole(Roles.SIGNER_ADMIN_ROLE, newSignerAdmin)); + } + + function test_grantRole_defaultAdminCanGrantPauserRole() public { + address newPauser = makeAddr("newPauser"); + vm.prank(owner); + feeManager.grantRole(Roles.PAUSER_ROLE, newPauser); + assertTrue(feeManager.hasRole(Roles.PAUSER_ROLE, newPauser)); + } + + function test_grantRole_strangerCannotGrantAllowedCaller() public { + address stranger = makeAddr("stranger"); + vm.prank(stranger); + vm.expectRevert( + abi.encodeWithSelector( + IAccessControl.AccessControlUnauthorizedAccount.selector, stranger, Roles.CALLER_ADMIN_ROLE + ) + ); + feeManager.grantRole(Roles.ALLOWED_CALLER_ROLE, stranger); + } + + function test_multipleHoldersOfSameRole() public { + address pauser2 = makeAddr("pauser2"); + vm.prank(owner); + feeManager.grantRole(Roles.PAUSER_ROLE, pauser2); + + assertTrue(feeManager.hasRole(Roles.PAUSER_ROLE, pauser_)); + assertTrue(feeManager.hasRole(Roles.PAUSER_ROLE, pauser2)); + + vm.prank(pauser2); + feeManager.pause(); + assertTrue(feeManager.paused()); + } + + function test_revokedCallerCannotCollectFees() public { + vm.prank(callerAdmin); + feeManager.revokeRole(Roles.ALLOWED_CALLER_ROLE, allowedCaller); + + IFeeManager.FeeWithQuoteRequest[] memory requests = new IFeeManager.FeeWithQuoteRequest[](0); + vm.prank(allowedCaller); + vm.expectRevert( + abi.encodeWithSelector( + IAccessControl.AccessControlUnauthorizedAccount.selector, allowedCaller, Roles.ALLOWED_CALLER_ROLE + ) + ); + feeManager.collectFeesWithQuote(CORRELATION_HASH, requests, _claim("")); + } + + // ───────────────────── trusted quote signers (integration) ───────────────────── + + function test_setTrustedQuoteSigner_onlySignerAdmin() public { + vm.prank(owner); + vm.expectRevert( + abi.encodeWithSelector( + IAccessControl.AccessControlUnauthorizedAccount.selector, owner, Roles.SIGNER_ADMIN_ROLE + ) + ); + feeManager.setTrustedQuoteSigner(trustedSigner, true); + + vm.prank(signerAdmin); + feeManager.setTrustedQuoteSigner(trustedSigner, true); + assertTrue(feeManager.isTrustedQuoteSigner(trustedSigner)); + } + + // ───────────────────── fee collection address (integration) ───────────────────── + + event FeeCollectionAddressSet(address indexed feeCollectionAddress); + + function test_feeCollectionAddress_zeroByDefault_andUsesProtocolFeeAddress() public { + assertEq(feeManager.feeCollectionAddress(), address(0)); + + string[] memory args = new string[](0); + IFeeManager.QuoteItem[] memory items = new IFeeManager.QuoteItem[](1); + items[0] = IFeeManager.QuoteItem({ + quoteType: QuoteTypes.FORWARD, quoteArgsHash: keccak256(abi.encode(args)), amount: 1 ether + }); + bytes memory signedQuote = + _encodeSignedQuote(items, block.timestamp + 1 days, address(0), 0, TRUSTED_SIGNER_PK, address(feeManager)); + + IFeeManager.FeeWithQuoteRequest[] memory requests = new IFeeManager.FeeWithQuoteRequest[](1); + requests[0] = IFeeManager.FeeWithQuoteRequest({quoteType: QuoteTypes.FORWARD, quoteArgs: args, required: true}); + + vm.deal(allowedCaller, 2 ether); + vm.prank(allowedCaller); + feeManager.collectFeesWithQuote{value: 1 ether}(CORRELATION_HASH, requests, _claim(signedQuote)); + + assertEq(mockFeeTaker.lastBeneficiary(), mockFeeTaker.protocolFeeAddress()); + } + + function test_setFeeCollectionAddress_onlyFeeCollectionAdmin() public { + address payable customCollector = payable(makeAddr("customCollector")); + + vm.prank(owner); + vm.expectRevert( + abi.encodeWithSelector( + IAccessControl.AccessControlUnauthorizedAccount.selector, owner, Roles.FEE_COLLECTION_ADMIN_ROLE + ) + ); + feeManager.setFeeCollectionAddress(customCollector); + + vm.prank(feeCollectionAdmin); + feeManager.setFeeCollectionAddress(customCollector); + assertEq(feeManager.feeCollectionAddress(), customCollector); + } + + function test_setFeeCollectionAddress_emitsEvent() public { + address payable customCollector = payable(makeAddr("customCollector")); + + vm.expectEmit(true, false, false, true); + emit FeeCollectionAddressSet(customCollector); + vm.prank(feeCollectionAdmin); + feeManager.setFeeCollectionAddress(customCollector); + } + + function test_grantRole_defaultAdminCanGrantFeeCollectionAdmin() public { + address newFeeCollectionAdmin = makeAddr("newFeeCollectionAdmin"); + vm.prank(owner); + feeManager.grantRole(Roles.FEE_COLLECTION_ADMIN_ROLE, newFeeCollectionAdmin); + assertTrue(feeManager.hasRole(Roles.FEE_COLLECTION_ADMIN_ROLE, newFeeCollectionAdmin)); + } + + function test_collectFeesWithQuote_native_usesConfiguredFeeCollectionAddress() public { + address payable customCollector = payable(makeAddr("customCollector")); + vm.prank(feeCollectionAdmin); + feeManager.setFeeCollectionAddress(customCollector); + + string[] memory args = new string[](0); + IFeeManager.QuoteItem[] memory items = new IFeeManager.QuoteItem[](1); + items[0] = IFeeManager.QuoteItem({ + quoteType: QuoteTypes.FORWARD, quoteArgsHash: keccak256(abi.encode(args)), amount: 0.5 ether + }); + bytes memory signedQuote = + _encodeSignedQuote(items, block.timestamp + 1 days, address(0), 0, TRUSTED_SIGNER_PK, address(feeManager)); + + IFeeManager.FeeWithQuoteRequest[] memory requests = new IFeeManager.FeeWithQuoteRequest[](1); + requests[0] = IFeeManager.FeeWithQuoteRequest({quoteType: QuoteTypes.FORWARD, quoteArgs: args, required: true}); + + vm.deal(allowedCaller, 1 ether); + vm.prank(allowedCaller); + feeManager.collectFeesWithQuote{value: 0.5 ether}(CORRELATION_HASH, requests, _claim(signedQuote)); + + assertEq( + mockFeeTaker.lastBeneficiary(), + customCollector, + "FeeTaker should receive the configured fee collection address" + ); + assertTrue( + mockFeeTaker.lastBeneficiary() != mockFeeTaker.protocolFeeAddress(), + "override must not equal protocolFeeAddress" + ); + } + + /// @dev Verifies the `feeRecipient` field on `FeeCollected` mirrors the resolved beneficiary: + /// the configured override when set, not the FeeTaker's `protocolFeeAddress()`. + function test_collectFeesWithQuote_native_feeCollectedEvent_emitsConfiguredFeeRecipient() public { + address payable customCollector = payable(makeAddr("customCollectorEvt")); + vm.prank(feeCollectionAdmin); + feeManager.setFeeCollectionAddress(customCollector); + + string[] memory args = new string[](0); + IFeeManager.QuoteItem[] memory items = new IFeeManager.QuoteItem[](1); + items[0] = IFeeManager.QuoteItem({ + quoteType: QuoteTypes.FORWARD, quoteArgsHash: keccak256(abi.encode(args)), amount: 0.1 ether + }); + bytes memory signedQuote = + _encodeSignedQuote(items, block.timestamp + 1 days, address(0), 0, TRUSTED_SIGNER_PK, address(feeManager)); + + IFeeManager.FeeWithQuoteRequest[] memory requests = new IFeeManager.FeeWithQuoteRequest[](1); + requests[0] = IFeeManager.FeeWithQuoteRequest({quoteType: QuoteTypes.FORWARD, quoteArgs: args, required: true}); + + vm.expectEmit(true, true, false, true); + emit FeeCollected( + allowedCaller, + CORRELATION_HASH, + QuoteTypes.FORWARD, + args, + payable(refundAddress), + customCollector, + address(0), + 0.1 ether, + 0 + ); + + vm.deal(allowedCaller, 1 ether); + vm.prank(allowedCaller); + feeManager.collectFeesWithQuote{value: 0.1 ether}(CORRELATION_HASH, requests, _claim(signedQuote)); + } + + function test_collectFeesWithQuote_erc20_usesConfiguredFeeCollectionAddress() public { + address payable customCollector = payable(makeAddr("customCollector")); + vm.prank(feeCollectionAdmin); + feeManager.setFeeCollectionAddress(customCollector); + + MockERC20 token = new MockERC20(); + string[] memory args = new string[](0); + IFeeManager.QuoteItem[] memory items = new IFeeManager.QuoteItem[](1); + items[0] = IFeeManager.QuoteItem({ + quoteType: QuoteTypes.FORWARD, quoteArgsHash: keccak256(abi.encode(args)), amount: 25e18 + }); + bytes memory signedQuote = _encodeSignedQuote( + items, block.timestamp + 1 days, address(token), 0, TRUSTED_SIGNER_PK, address(feeManager) + ); + + IFeeManager.FeeWithQuoteRequest[] memory requests = new IFeeManager.FeeWithQuoteRequest[](1); + requests[0] = IFeeManager.FeeWithQuoteRequest({quoteType: QuoteTypes.FORWARD, quoteArgs: args, required: true}); + + token.mint(allowedCaller, 25e18); + vm.prank(allowedCaller); + token.approve(address(feeManager), 25e18); + + vm.prank(allowedCaller); + feeManager.collectFeesWithQuote(CORRELATION_HASH, requests, _claim(signedQuote)); + + assertEq( + mockFeeTaker.lastBeneficiary(), + customCollector, + "FeeTaker should receive the configured fee collection address" + ); + } + + function test_collectFeesWithQuote_clearedOverride_revertsToProtocolFeeAddress() public { + address payable customCollector = payable(makeAddr("customCollector")); + vm.startPrank(feeCollectionAdmin); + feeManager.setFeeCollectionAddress(customCollector); + feeManager.setFeeCollectionAddress(payable(address(0))); + vm.stopPrank(); + assertEq(feeManager.feeCollectionAddress(), address(0)); + + string[] memory args = new string[](0); + IFeeManager.QuoteItem[] memory items = new IFeeManager.QuoteItem[](1); + items[0] = IFeeManager.QuoteItem({ + quoteType: QuoteTypes.FORWARD, quoteArgsHash: keccak256(abi.encode(args)), amount: 0.1 ether + }); + bytes memory signedQuote = + _encodeSignedQuote(items, block.timestamp + 1 days, address(0), 0, TRUSTED_SIGNER_PK, address(feeManager)); + + IFeeManager.FeeWithQuoteRequest[] memory requests = new IFeeManager.FeeWithQuoteRequest[](1); + requests[0] = IFeeManager.FeeWithQuoteRequest({quoteType: QuoteTypes.FORWARD, quoteArgs: args, required: true}); + + vm.deal(allowedCaller, 1 ether); + vm.prank(allowedCaller); + feeManager.collectFeesWithQuote{value: 0.1 ether}(CORRELATION_HASH, requests, _claim(signedQuote)); + + assertEq(mockFeeTaker.lastBeneficiary(), mockFeeTaker.protocolFeeAddress()); + } + + function test_collectFeesWithQuote_overrideUsedAcrossMultipleCalls() public { + address payable customCollector = payable(makeAddr("customCollector")); + vm.prank(feeCollectionAdmin); + feeManager.setFeeCollectionAddress(customCollector); + + string[] memory args = new string[](0); + IFeeManager.QuoteItem[] memory items = new IFeeManager.QuoteItem[](1); + items[0] = IFeeManager.QuoteItem({ + quoteType: QuoteTypes.FORWARD, quoteArgsHash: keccak256(abi.encode(args)), amount: 0.1 ether + }); + bytes memory signedQuote = + _encodeSignedQuote(items, block.timestamp + 1 days, address(0), 0, TRUSTED_SIGNER_PK, address(feeManager)); + + IFeeManager.FeeWithQuoteRequest[] memory requests = new IFeeManager.FeeWithQuoteRequest[](1); + requests[0] = IFeeManager.FeeWithQuoteRequest({quoteType: QuoteTypes.FORWARD, quoteArgs: args, required: true}); + + vm.deal(allowedCaller, 1 ether); + vm.prank(allowedCaller); + feeManager.collectFeesWithQuote{value: 0.1 ether}(CORRELATION_HASH, requests, _claim(signedQuote)); + assertEq(mockFeeTaker.lastBeneficiary(), customCollector); + + vm.prank(allowedCaller); + feeManager.collectFeesWithQuote{value: 0.1 ether}(CORRELATION_HASH, requests, _claim(signedQuote)); + assertEq(mockFeeTaker.lastBeneficiary(), customCollector); + } + + /// @dev Belt-and-suspenders: pause must not silently bypass the override path. + function test_collectFeesWithQuote_overrideRespectedAfterUnpause() public { + address payable customCollector = payable(makeAddr("customCollector")); + vm.prank(feeCollectionAdmin); + feeManager.setFeeCollectionAddress(customCollector); + + vm.prank(pauser_); + feeManager.pause(); + vm.prank(pauser_); + feeManager.unpause(); + + string[] memory args = new string[](0); + IFeeManager.QuoteItem[] memory items = new IFeeManager.QuoteItem[](1); + items[0] = IFeeManager.QuoteItem({ + quoteType: QuoteTypes.FORWARD, quoteArgsHash: keccak256(abi.encode(args)), amount: 0.1 ether + }); + bytes memory signedQuote = + _encodeSignedQuote(items, block.timestamp + 1 days, address(0), 0, TRUSTED_SIGNER_PK, address(feeManager)); + + IFeeManager.FeeWithQuoteRequest[] memory requests = new IFeeManager.FeeWithQuoteRequest[](1); + requests[0] = IFeeManager.FeeWithQuoteRequest({quoteType: QuoteTypes.FORWARD, quoteArgs: args, required: true}); + + vm.deal(allowedCaller, 1 ether); + vm.prank(allowedCaller); + feeManager.collectFeesWithQuote{value: 0.1 ether}(CORRELATION_HASH, requests, _claim(signedQuote)); + assertEq(mockFeeTaker.lastBeneficiary(), customCollector); + } + + // ───────────────────── decodeQuote (public endpoint) ───────────────────── + + function test_decodeQuote_validV1_returnsQuote() public view { + string[] memory args = new string[](1); + args[0] = "hello"; + IFeeManager.QuoteItem[] memory items = new IFeeManager.QuoteItem[](1); + items[0] = IFeeManager.QuoteItem({ + quoteType: QuoteTypes.FORWARD, quoteArgsHash: keccak256(abi.encode(args)), amount: 42 + }); + bytes memory sig65 = new bytes(65); + sig65[0] = 0xaa; + sig65[1] = 0xbb; + IFeeManager.Quote memory q = + IFeeManager.Quote({items: items, expiry: 999, feeToken: address(0), nonce: 7, signature: sig65}); + bytes memory signedQuote = abi.encodePacked(uint8(SignedQuoteLib.QUOTE_WIRE_FORMAT_VERSION), abi.encode(q)); + + IFeeManager.Quote memory decoded = feeManager.decodeQuote(signedQuote); + assertEq(decoded.items.length, 1); + assertEq(decoded.items[0].amount, 42); + assertEq(decoded.expiry, 999); + assertEq(decoded.nonce, 7); + assertEq(decoded.feeToken, address(0)); + assertEq(decoded.signature.length, 65); + assertEq(uint8(decoded.signature[0]), uint8(0xaa)); + assertEq(uint8(decoded.signature[1]), uint8(0xbb)); + } + + /// @dev `SignedQuoteLib` must stay byte-for-byte compatible with `FeeManager.decodeQuote` (used by TMWF in-process). + function test_signedQuoteLib_decode_matches_feeManager_decodeQuote() public view { + string[] memory args = new string[](1); + args[0] = "hello"; + IFeeManager.QuoteItem[] memory items = new IFeeManager.QuoteItem[](1); + items[0] = IFeeManager.QuoteItem({ + quoteType: QuoteTypes.FORWARD, quoteArgsHash: keccak256(abi.encode(args)), amount: 42 + }); + bytes memory sig65 = new bytes(65); + sig65[0] = 0xaa; + sig65[1] = 0xbb; + IFeeManager.Quote memory q = + IFeeManager.Quote({items: items, expiry: 999, feeToken: address(0), nonce: 7, signature: sig65}); + bytes memory signedQuote = abi.encodePacked(uint8(SignedQuoteLib.QUOTE_WIRE_FORMAT_VERSION), abi.encode(q)); + + IFeeManager.Quote memory fromLib = this._subjectSignedQuoteLibDecode(signedQuote); + IFeeManager.Quote memory fromFm = feeManager.decodeQuote(signedQuote); + assertEq(keccak256(abi.encode(fromLib)), keccak256(abi.encode(fromFm))); + } + + function _subjectSignedQuoteLibDecode(bytes calldata signedQuote) external pure returns (IFeeManager.Quote memory) { + return SignedQuoteLib.decodeQuote(signedQuote); + } + + function test_decodeQuote_revertsOnEmptyPayload() public { + vm.expectRevert(IFeeManager.SignedQuoteDecodeFailed.selector); + feeManager.decodeQuote(""); + } + + function test_decodeQuote_revertsUnsupportedQuoteVersion_zero() public { + vm.expectRevert(abi.encodeWithSelector(IFeeManager.UnsupportedQuoteVersion.selector, uint8(0))); + feeManager.decodeQuote(hex"00aabbccdd"); + } + + function test_decodeQuote_revertsUnsupportedQuoteVersion_future() public { + IFeeManager.QuoteItem[] memory items = new IFeeManager.QuoteItem[](0); + IFeeManager.Quote memory q = + IFeeManager.Quote({items: items, expiry: 0, feeToken: address(0), nonce: 0, signature: ""}); + bytes memory signedQuote = abi.encodePacked(uint8(2), abi.encode(q)); + + vm.expectRevert(abi.encodeWithSelector(IFeeManager.UnsupportedQuoteVersion.selector, uint8(2))); + feeManager.decodeQuote(signedQuote); + } + + function test_decodeQuote_revertsOnGarbageAfterValidVersion() public { + vm.expectRevert(IFeeManager.SignedQuoteDecodeFailed.selector); + feeManager.decodeQuote(hex"01deadbeef"); + } + + function test_decodeQuote_revertsWhenTailShorterThanMinimalAbiQuote() public { + // Version 1 + 351-byte tail: below MIN_ABI_ENCODED_QUOTE_TAIL_BYTES (352 for empty items + 65-byte sig). + bytes memory tooShort = bytes.concat(hex"01", new bytes(351)); + vm.expectRevert(IFeeManager.SignedQuoteDecodeFailed.selector); + feeManager.decodeQuote(tooShort); + } + + /// @dev Smallest ABI-encoded `Quote` that passes `SignedQuoteLib`: empty `items`, 65-byte `signature` (secp256k1). + function test_decodeQuote_minimalEncodedQuote_decodes() public view { + IFeeManager.QuoteItem[] memory items = new IFeeManager.QuoteItem[](0); + bytes memory sig65 = new bytes(65); + IFeeManager.Quote memory q = + IFeeManager.Quote({items: items, expiry: 0, feeToken: address(0), nonce: 0, signature: sig65}); + assertEq(abi.encode(q).length, 352, "sanity: minimal abi.encode(Quote) with 65-byte signature"); + bytes memory signedQuote = abi.encodePacked(uint8(SignedQuoteLib.QUOTE_WIRE_FORMAT_VERSION), abi.encode(q)); + assertEq(signedQuote.length, 353); + + IFeeManager.Quote memory decoded = feeManager.decodeQuote(signedQuote); + assertEq(decoded.items.length, 0); + assertEq(decoded.expiry, 0); + assertEq(decoded.feeToken, address(0)); + assertEq(decoded.nonce, 0); + assertEq(decoded.signature.length, 65); + } + + function test_decodeQuote_revertsWhenSignatureNot65Bytes() public { + IFeeManager.QuoteItem[] memory items = new IFeeManager.QuoteItem[](0); + // Same ABI tail size as a 65-byte signature (padded dynamic slot), but wrong length for `ECDSA.recover`. + bytes memory sig66 = new bytes(66); + IFeeManager.Quote memory q = + IFeeManager.Quote({items: items, expiry: 0, feeToken: address(0), nonce: 0, signature: sig66}); + assertEq(abi.encode(q).length, 352); + bytes memory signedQuote = abi.encodePacked(uint8(SignedQuoteLib.QUOTE_WIRE_FORMAT_VERSION), abi.encode(q)); + vm.expectRevert(IFeeManager.SignedQuoteDecodeFailed.selector); + feeManager.decodeQuote(signedQuote); + } + + // ───────────────────── getQuoteItem / getQuoteTotal ───────────────────── + + function test_getQuoteItem_emptyBytes_returnsNotFound() public view { + (IFeeManager.QuoteItem memory item, bool found) = feeManager.getQuoteItem("", QuoteTypes.FORWARD); + assertFalse(found); + assertEq(item.amount, 0); + } + + function test_getQuoteItem_findsFirstMatchingQuoteType() public view { + string[] memory a0 = new string[](1); + a0[0] = "x"; + IFeeManager.QuoteItem[] memory items = new IFeeManager.QuoteItem[](2); + items[0] = IFeeManager.QuoteItem({quoteType: "OTHER", quoteArgsHash: bytes32(0), amount: 1}); + items[1] = IFeeManager.QuoteItem({ + quoteType: QuoteTypes.FORWARD, quoteArgsHash: keccak256(abi.encode(a0)), amount: 99 + }); + IFeeManager.Quote memory q = + IFeeManager.Quote({items: items, expiry: 0, feeToken: address(0), nonce: 0, signature: new bytes(65)}); + bytes memory signedQuote = abi.encodePacked(uint8(SignedQuoteLib.QUOTE_WIRE_FORMAT_VERSION), abi.encode(q)); + + (IFeeManager.QuoteItem memory item, bool found) = feeManager.getQuoteItem(signedQuote, QuoteTypes.FORWARD); + assertTrue(found); + assertEq(item.amount, 99); + } + + function test_getQuoteItem_revertsUnsupportedQuoteVersion() public { + IFeeManager.QuoteItem[] memory items = new IFeeManager.QuoteItem[](1); + items[0] = IFeeManager.QuoteItem({quoteType: QuoteTypes.FORWARD, quoteArgsHash: bytes32(0), amount: 1}); + IFeeManager.Quote memory q = + IFeeManager.Quote({items: items, expiry: 0, feeToken: address(0), nonce: 0, signature: ""}); + bytes memory signedQuote = abi.encodePacked(uint8(2), abi.encode(q)); + + vm.expectRevert(abi.encodeWithSelector(IFeeManager.UnsupportedQuoteVersion.selector, uint8(2))); + feeManager.getQuoteItem(signedQuote, QuoteTypes.FORWARD); + } + + function test_getQuoteItem_emptyItems_returnsNotFound() public view { + IFeeManager.QuoteItem[] memory items = new IFeeManager.QuoteItem[](0); + IFeeManager.Quote memory q = + IFeeManager.Quote({items: items, expiry: 0, feeToken: address(0), nonce: 0, signature: new bytes(65)}); + bytes memory signedQuote = abi.encodePacked(uint8(SignedQuoteLib.QUOTE_WIRE_FORMAT_VERSION), abi.encode(q)); + + (IFeeManager.QuoteItem memory item, bool found) = feeManager.getQuoteItem(signedQuote, QuoteTypes.FORWARD); + assertFalse(found); + assertEq(item.amount, 0); + } + + function test_getQuoteItem_decodedQuote_findsFirstMatchingQuoteType() public view { + string[] memory a0 = new string[](1); + a0[0] = "x"; + IFeeManager.QuoteItem[] memory items = new IFeeManager.QuoteItem[](2); + items[0] = IFeeManager.QuoteItem({quoteType: "OTHER", quoteArgsHash: bytes32(0), amount: 1}); + items[1] = IFeeManager.QuoteItem({ + quoteType: QuoteTypes.FORWARD, quoteArgsHash: keccak256(abi.encode(a0)), amount: 99 + }); + IFeeManager.Quote memory decoded = + IFeeManager.Quote({items: items, expiry: 0, feeToken: address(0), nonce: 0, signature: new bytes(65)}); + + (IFeeManager.QuoteItem memory item, bool found) = feeManager.getQuoteItem(decoded, QuoteTypes.FORWARD); + assertTrue(found); + assertEq(item.amount, 99); + } + + function test_getQuoteItem_decodedQuote_returnsNotFoundForMissingType() public view { + IFeeManager.QuoteItem[] memory items = new IFeeManager.QuoteItem[](1); + items[0] = IFeeManager.QuoteItem({quoteType: "OTHER", quoteArgsHash: bytes32(0), amount: 1}); + IFeeManager.Quote memory decoded = + IFeeManager.Quote({items: items, expiry: 0, feeToken: address(0), nonce: 0, signature: new bytes(65)}); + + (IFeeManager.QuoteItem memory item, bool found) = feeManager.getQuoteItem(decoded, QuoteTypes.FORWARD); + assertFalse(found); + assertEq(item.amount, 0); + } + + function test_getQuoteItem_decodedQuote_emptyItems_returnsNotFound() public view { + IFeeManager.QuoteItem[] memory items = new IFeeManager.QuoteItem[](0); + IFeeManager.Quote memory decoded = + IFeeManager.Quote({items: items, expiry: 0, feeToken: address(0), nonce: 0, signature: new bytes(65)}); + + (IFeeManager.QuoteItem memory item, bool found) = feeManager.getQuoteItem(decoded, QuoteTypes.FORWARD); + assertFalse(found); + assertEq(item.amount, 0); + } + + function test_getQuoteTotal_emptyBytes_returnsZero() public view { + (uint256 totalFee, address feeToken) = feeManager.getQuoteTotal(""); + assertEq(totalFee, 0); + assertEq(feeToken, address(0)); + } + + function test_getQuoteTotal_sumsItemsAndReturnsFeeToken() public view { + IFeeManager.QuoteItem[] memory items = new IFeeManager.QuoteItem[](2); + items[0] = IFeeManager.QuoteItem({quoteType: "A", quoteArgsHash: bytes32(0), amount: 3}); + items[1] = IFeeManager.QuoteItem({quoteType: "B", quoteArgsHash: bytes32(0), amount: 7}); + address token = address(uint160(uint256(keccak256(bytes("token"))))); + IFeeManager.Quote memory q = + IFeeManager.Quote({items: items, expiry: 1, feeToken: token, nonce: 0, signature: new bytes(65)}); + bytes memory signedQuote = abi.encodePacked(uint8(SignedQuoteLib.QUOTE_WIRE_FORMAT_VERSION), abi.encode(q)); + + (uint256 totalFee, address feeToken) = feeManager.getQuoteTotal(signedQuote); + assertEq(totalFee, 10); + assertEq(feeToken, token); + } + + function test_getQuoteTotal_revertsUnsupportedQuoteVersion() public { + IFeeManager.QuoteItem[] memory items = new IFeeManager.QuoteItem[](1); + items[0] = IFeeManager.QuoteItem({quoteType: "A", quoteArgsHash: bytes32(0), amount: 5}); + IFeeManager.Quote memory q = + IFeeManager.Quote({items: items, expiry: 1, feeToken: address(0), nonce: 0, signature: ""}); + bytes memory signedQuote = abi.encodePacked(uint8(255), abi.encode(q)); + + vm.expectRevert(abi.encodeWithSelector(IFeeManager.UnsupportedQuoteVersion.selector, uint8(255))); + feeManager.getQuoteTotal(signedQuote); + } + + function test_getQuoteTotal_decodedQuote_sumsItemsAndReturnsFeeToken() public view { + IFeeManager.QuoteItem[] memory items = new IFeeManager.QuoteItem[](2); + items[0] = IFeeManager.QuoteItem({quoteType: "A", quoteArgsHash: bytes32(0), amount: 3}); + items[1] = IFeeManager.QuoteItem({quoteType: "B", quoteArgsHash: bytes32(0), amount: 7}); + address token = address(uint160(uint256(keccak256(bytes("token"))))); + IFeeManager.Quote memory decoded = + IFeeManager.Quote({items: items, expiry: 1, feeToken: token, nonce: 0, signature: new bytes(65)}); + + (uint256 totalFee, address feeToken) = feeManager.getQuoteTotal(decoded); + assertEq(totalFee, 10); + assertEq(feeToken, token); + } + + function test_sumFees_signedQuote_sumsOnlyRequestedQuoteTypes() public view { + string[] memory forwardArgs = new string[](1); + forwardArgs[0] = "forward"; + string[] memory preFinArgs = new string[](1); + preFinArgs[0] = "pre"; + string[] memory settlementArgs = new string[](1); + settlementArgs[0] = "settle"; + + IFeeManager.QuoteItem[] memory items = new IFeeManager.QuoteItem[](3); + items[0] = IFeeManager.QuoteItem({ + quoteType: QuoteTypes.FORWARD, quoteArgsHash: keccak256(abi.encode(forwardArgs)), amount: 3 + }); + items[1] = IFeeManager.QuoteItem({ + quoteType: QuoteTypes.PRE_FINALITY, quoteArgsHash: keccak256(abi.encode(preFinArgs)), amount: 7 + }); + items[2] = IFeeManager.QuoteItem({ + quoteType: "SETTLEMENT", quoteArgsHash: keccak256(abi.encode(settlementArgs)), amount: 100 + }); + address token = address(uint160(uint256(keccak256(bytes("token"))))); + bytes memory signedQuote = _encodeLookupQuote(items, token, 0); + + string[] memory quoteTypes = new string[](2); + quoteTypes[0] = QuoteTypes.FORWARD; + quoteTypes[1] = QuoteTypes.PRE_FINALITY; + + (uint256 totalFee, address feeToken) = feeManager.sumFees(signedQuote, quoteTypes); + assertEq(totalFee, 10); + assertEq(feeToken, token); + } + + function test_sumFees_decodedQuote_missingType_returnsZeroWithFeeToken() public view { + IFeeManager.QuoteItem[] memory items = new IFeeManager.QuoteItem[](0); + address token = address(uint160(uint256(keccak256(bytes("token"))))); + IFeeManager.Quote memory decoded = + IFeeManager.Quote({items: items, expiry: 0, feeToken: token, nonce: 0, signature: new bytes(65)}); + + string[] memory quoteTypes = new string[](1); + quoteTypes[0] = QuoteTypes.FORWARD; + + (uint256 totalFee, address feeToken) = feeManager.sumFees(decoded, quoteTypes); + assertEq(totalFee, 0); + assertEq(feeToken, token); + } + + function test_sumFees_emptyBytes_returnsZero() public view { + string[] memory withTypes = new string[](1); + withTypes[0] = QuoteTypes.FORWARD; + + (uint256 totalFee, address feeToken) = feeManager.sumFees("", withTypes); + assertEq(totalFee, 0); + assertEq(feeToken, address(0)); + + string[] memory withoutTypes = new string[](0); + (totalFee, feeToken) = feeManager.sumFees("", withoutTypes); + assertEq(totalFee, 0); + assertEq(feeToken, address(0)); + } + + function test_sumFees_ignoresQuoteArgsAndSumsByType() public view { + string[] memory quotedArgs = new string[](1); + quotedArgs[0] = "quoted"; + IFeeManager.QuoteItem[] memory items = new IFeeManager.QuoteItem[](1); + items[0] = IFeeManager.QuoteItem({ + quoteType: QuoteTypes.FORWARD, quoteArgsHash: keccak256(abi.encode(quotedArgs)), amount: 3 + }); + IFeeManager.Quote memory decoded = + IFeeManager.Quote({items: items, expiry: 0, feeToken: address(0), nonce: 0, signature: new bytes(65)}); + + string[] memory quoteTypes = new string[](1); + quoteTypes[0] = QuoteTypes.FORWARD; + + (uint256 totalFee, address feeToken) = feeManager.sumFees(decoded, quoteTypes); + assertEq(totalFee, 3); + assertEq(feeToken, address(0)); + } + + function test_sumFees_revertsTooManyQuoteItems() public { + IFeeManager.QuoteItem[] memory items = new IFeeManager.QuoteItem[](257); + for (uint256 i = 0; i < 257; ++i) { + items[i] = IFeeManager.QuoteItem({quoteType: "X", quoteArgsHash: bytes32(i), amount: 0}); + } + IFeeManager.Quote memory decoded = + IFeeManager.Quote({items: items, expiry: 0, feeToken: address(0), nonce: 0, signature: new bytes(65)}); + + string[] memory quoteTypes = new string[](1); + quoteTypes[0] = QuoteTypes.FORWARD; + + vm.expectRevert(abi.encodeWithSelector(IFeeManager.TooManyQuoteItems.selector, uint256(257))); + feeManager.sumFees(decoded, quoteTypes); + } + + function test_sumFees_dedupsDuplicateQuoteTypes() public view { + string[] memory forwardArgs = new string[](1); + forwardArgs[0] = "forward"; + string[] memory preFinArgs = new string[](1); + preFinArgs[0] = "pre"; + + IFeeManager.QuoteItem[] memory items = new IFeeManager.QuoteItem[](2); + items[0] = IFeeManager.QuoteItem({ + quoteType: QuoteTypes.FORWARD, quoteArgsHash: keccak256(abi.encode(forwardArgs)), amount: 3 + }); + items[1] = IFeeManager.QuoteItem({ + quoteType: QuoteTypes.PRE_FINALITY, quoteArgsHash: keccak256(abi.encode(preFinArgs)), amount: 7 + }); + address token = address(uint160(uint256(keccak256(bytes("token"))))); + IFeeManager.Quote memory decoded = + IFeeManager.Quote({items: items, expiry: 0, feeToken: token, nonce: 0, signature: new bytes(65)}); + + // FORWARD appears three times, PRE_FINALITY once. Mirrors the + // collectFeesWithQuote path which would revert DuplicateRequestType + // rather than double-count. + string[] memory quoteTypes = new string[](4); + quoteTypes[0] = QuoteTypes.FORWARD; + quoteTypes[1] = QuoteTypes.PRE_FINALITY; + quoteTypes[2] = QuoteTypes.FORWARD; + quoteTypes[3] = QuoteTypes.FORWARD; + + (uint256 totalFee, address feeToken) = feeManager.sumFees(decoded, quoteTypes); + assertEq(totalFee, 10); + assertEq(feeToken, token); + } + + function test_sumFees_nonZeroNonceDoesNotRevert() public view { + uint256 nonce = 7; + string[] memory args = new string[](0); + IFeeManager.QuoteItem[] memory items = new IFeeManager.QuoteItem[](1); + items[0] = IFeeManager.QuoteItem({ + quoteType: QuoteTypes.FORWARD, quoteArgsHash: keccak256(abi.encode(args)), amount: 3 + }); + IFeeManager.Quote memory decoded = + IFeeManager.Quote({items: items, expiry: 0, feeToken: address(0), nonce: nonce, signature: new bytes(65)}); + + string[] memory quoteTypes = new string[](1); + quoteTypes[0] = QuoteTypes.FORWARD; + + (uint256 totalFee,) = feeManager.sumFees(decoded, quoteTypes); + assertEq(totalFee, 3); + } + + // ───────────────────── feeTaker ───────────────────── + + function test_feeTaker_returnsImmutableAddress() public view { + assertEq(feeManager.feeTaker(), address(mockFeeTaker)); + } + + function test_usdcBlacklist_returnsImmutableAddress() public view { + assertEq(feeManager.usdcBlacklist(), address(mockUsdcBlacklist)); + } + + function test_isDenylisted_delegatesToUsdc() public { + address account = makeAddr("maybeBlacklisted"); + assertFalse(feeManager.isDenylisted(account)); + mockUsdcBlacklist.setBlacklisted(account, true); + assertTrue(feeManager.isDenylisted(account)); + } + + function test_constructor_revertsOnZeroFeeTaker() public { + vm.expectRevert(FeeManager.ZeroFeeTakerAddress.selector); + new FeeManager(address(0), address(mockUsdcBlacklist)); + } + + function test_constructor_revertsOnZeroUsdcBlacklist() public { + vm.expectRevert(FeeManager.ZeroUsdcBlacklistAddress.selector); + new FeeManager(address(mockFeeTaker), address(0)); + } + + function test_collectFeesWithQuote_native_feeTakerReverts_entireCallReverts() public { + RevertingFeeTaker revertingFt = new RevertingFeeTaker(); + FeeManager fm = _deployFeeManagerProxyWithFeeTaker(owner, address(revertingFt)); + vm.startPrank(owner); + fm.grantRole(Roles.CALLER_ADMIN_ROLE, owner); + fm.grantRole(Roles.SIGNER_ADMIN_ROLE, owner); + fm.grantRole(Roles.ALLOWED_CALLER_ROLE, allowedCaller); + fm.setTrustedQuoteSigner(trustedSigner, true); + vm.stopPrank(); + + string[] memory args = new string[](0); + IFeeManager.QuoteItem[] memory items = new IFeeManager.QuoteItem[](1); + items[0] = IFeeManager.QuoteItem({ + quoteType: QuoteTypes.FORWARD, quoteArgsHash: keccak256(abi.encode(args)), amount: 1 ether + }); + bytes memory signedQuote = + _encodeSignedQuote(items, block.timestamp + 1 days, address(0), 0, TRUSTED_SIGNER_PK, address(fm)); + + IFeeManager.FeeWithQuoteRequest[] memory requests = new IFeeManager.FeeWithQuoteRequest[](1); + requests[0] = IFeeManager.FeeWithQuoteRequest({quoteType: QuoteTypes.FORWARD, quoteArgs: args, required: true}); + + vm.deal(allowedCaller, 2 ether); + vm.prank(allowedCaller); + vm.expectRevert("FeeTaker: reverted"); + fm.collectFeesWithQuote{value: 1 ether}(CORRELATION_HASH, requests, _claim(signedQuote)); + } + + function test_collectFeesWithQuote_erc20_feeTakerReverts_entireCallReverts() public { + RevertingFeeTaker revertingFt = new RevertingFeeTaker(); + FeeManager fm = _deployFeeManagerProxyWithFeeTaker(owner, address(revertingFt)); + vm.startPrank(owner); + fm.grantRole(Roles.CALLER_ADMIN_ROLE, owner); + fm.grantRole(Roles.SIGNER_ADMIN_ROLE, owner); + fm.grantRole(Roles.ALLOWED_CALLER_ROLE, allowedCaller); + fm.setTrustedQuoteSigner(trustedSigner, true); + vm.stopPrank(); + + MockERC20 token = new MockERC20(); + string[] memory args = new string[](0); + IFeeManager.QuoteItem[] memory items = new IFeeManager.QuoteItem[](1); + items[0] = IFeeManager.QuoteItem({ + quoteType: QuoteTypes.FORWARD, quoteArgsHash: keccak256(abi.encode(args)), amount: 50e18 + }); + bytes memory signedQuote = + _encodeSignedQuote(items, block.timestamp + 1 days, address(token), 0, TRUSTED_SIGNER_PK, address(fm)); + + IFeeManager.FeeWithQuoteRequest[] memory requests = new IFeeManager.FeeWithQuoteRequest[](1); + requests[0] = IFeeManager.FeeWithQuoteRequest({quoteType: QuoteTypes.FORWARD, quoteArgs: args, required: true}); + + token.mint(allowedCaller, 50e18); + vm.prank(allowedCaller); + token.approve(address(fm), 50e18); + + vm.prank(allowedCaller); + vm.expectRevert("FeeTaker: reverted"); + fm.collectFeesWithQuote(CORRELATION_HASH, requests, _claim(signedQuote)); + } + + function test_collectFeesWithQuote_native_multipleItems_forwardsTotal() public { + string[] memory args0 = new string[](1); + args0[0] = "arg0"; + string[] memory args1 = new string[](1); + args1[0] = "arg1"; + + IFeeManager.QuoteItem[] memory items = new IFeeManager.QuoteItem[](2); + items[0] = IFeeManager.QuoteItem({ + quoteType: QuoteTypes.FORWARD, quoteArgsHash: keccak256(abi.encode(args0)), amount: 0.3 ether + }); + items[1] = IFeeManager.QuoteItem({ + quoteType: QuoteTypes.PRE_FINALITY, quoteArgsHash: keccak256(abi.encode(args1)), amount: 0.2 ether + }); + bytes memory signedQuote = + _encodeSignedQuote(items, block.timestamp + 1 days, address(0), 0, TRUSTED_SIGNER_PK, address(feeManager)); + + IFeeManager.FeeWithQuoteRequest[] memory requests = new IFeeManager.FeeWithQuoteRequest[](2); + requests[0] = IFeeManager.FeeWithQuoteRequest({quoteType: QuoteTypes.FORWARD, quoteArgs: args0, required: true}); + requests[1] = + IFeeManager.FeeWithQuoteRequest({quoteType: QuoteTypes.PRE_FINALITY, quoteArgs: args1, required: true}); + + vm.deal(allowedCaller, 1 ether); + vm.prank(allowedCaller); + uint256 amount = + feeManager.collectFeesWithQuote{value: 0.5 ether}(CORRELATION_HASH, requests, _claim(signedQuote)); + + assertEq(amount, 0.5 ether); + assertEq(address(feeManager).balance, 0); + assertEq(address(mockFeeTaker).balance, 0.5 ether); + assertEq(mockFeeTaker.nativeFeeReceived(), 0.5 ether); + } + + function test_collectFeesWithQuote_erc20_multipleItems_forwardsTotal() public { + MockERC20 token = new MockERC20(); + string[] memory args0 = new string[](1); + args0[0] = "arg0"; + string[] memory args1 = new string[](1); + args1[0] = "arg1"; + + IFeeManager.QuoteItem[] memory items = new IFeeManager.QuoteItem[](2); + items[0] = IFeeManager.QuoteItem({ + quoteType: QuoteTypes.FORWARD, quoteArgsHash: keccak256(abi.encode(args0)), amount: 50e18 + }); + items[1] = IFeeManager.QuoteItem({ + quoteType: QuoteTypes.PRE_FINALITY, quoteArgsHash: keccak256(abi.encode(args1)), amount: 30e18 + }); + bytes memory signedQuote = _encodeSignedQuote( + items, block.timestamp + 1 days, address(token), 0, TRUSTED_SIGNER_PK, address(feeManager) + ); + + IFeeManager.FeeWithQuoteRequest[] memory requests = new IFeeManager.FeeWithQuoteRequest[](2); + requests[0] = IFeeManager.FeeWithQuoteRequest({quoteType: QuoteTypes.FORWARD, quoteArgs: args0, required: true}); + requests[1] = + IFeeManager.FeeWithQuoteRequest({quoteType: QuoteTypes.PRE_FINALITY, quoteArgs: args1, required: true}); + + token.mint(allowedCaller, 80e18); + vm.prank(allowedCaller); + token.approve(address(feeManager), 80e18); + + vm.prank(allowedCaller); + uint256 amount = feeManager.collectFeesWithQuote(CORRELATION_HASH, requests, _claim(signedQuote)); + assertEq(amount, 80e18); + assertEq(token.balanceOf(address(feeManager)), 0, "FeeManager should not hold ERC-20 funds"); + assertEq(token.balanceOf(address(mockFeeTaker)), 80e18, "FeeTaker should receive ERC-20 fees"); + } + + function test_collectFeesWithQuote_erc20_revertsIfNativeSent() public { + MockERC20 token = new MockERC20(); + string[] memory args = new string[](0); + IFeeManager.QuoteItem[] memory items = new IFeeManager.QuoteItem[](1); + items[0] = IFeeManager.QuoteItem({ + quoteType: QuoteTypes.FORWARD, quoteArgsHash: keccak256(abi.encode(args)), amount: 10e18 + }); + bytes memory signedQuote = _encodeSignedQuote( + items, block.timestamp + 1 days, address(token), 0, TRUSTED_SIGNER_PK, address(feeManager) + ); + + IFeeManager.FeeWithQuoteRequest[] memory requests = new IFeeManager.FeeWithQuoteRequest[](1); + requests[0] = IFeeManager.FeeWithQuoteRequest({quoteType: QuoteTypes.FORWARD, quoteArgs: args, required: true}); + + token.mint(allowedCaller, 10e18); + vm.prank(allowedCaller); + token.approve(address(feeManager), 10e18); + + vm.deal(allowedCaller, 1 ether); + vm.prank(allowedCaller); + vm.expectRevert( + abi.encodeWithSelector(FeeManager.IncorrectNativePayment.selector, uint256(0), uint256(1 ether)) + ); + feeManager.collectFeesWithQuote{value: 1 ether}(CORRELATION_HASH, requests, _claim(signedQuote)); + } + + // ───────────────────── UUPS upgrade ───────────────────── + + function test_upgrade_success_preservesState() public { + address payable customCollector = payable(makeAddr("customCollector")); + vm.prank(feeCollectionAdmin); + feeManager.setFeeCollectionAddress(customCollector); + + FeeManager newImpl = new FeeManager(address(mockFeeTaker), address(mockUsdcBlacklist)); + vm.prank(owner); + feeManager.upgradeToAndCall(address(newImpl), ""); + + assertEq(feeManager.owner(), owner); + assertTrue(feeManager.hasRole(Roles.ALLOWED_CALLER_ROLE, allowedCaller)); + assertTrue(feeManager.isTrustedQuoteSigner(trustedSigner)); + assertTrue(feeManager.hasRole(Roles.PAUSER_ROLE, pauser_)); + assertTrue(feeManager.hasRole(Roles.FEE_COLLECTION_ADMIN_ROLE, feeCollectionAdmin)); + assertEq(feeManager.feeCollectionAddress(), customCollector); + assertEq(feeManager.feeTaker(), address(mockFeeTaker)); + } + + function test_upgrade_revertsForNonOwner() public { + address stranger = makeAddr("stranger"); + FeeManager newImpl = new FeeManager(address(mockFeeTaker), address(mockUsdcBlacklist)); + + vm.prank(stranger); + vm.expectRevert(abi.encodeWithSelector(Ownable.OwnableUnauthorizedAccount.selector, stranger)); + feeManager.upgradeToAndCall(address(newImpl), ""); + } + + function test_renounceOwnership_reverts() public { + vm.prank(owner); + vm.expectRevert(FeeManager.RenounceDisabled.selector); + feeManager.renounceOwnership(); + + assertEq(feeManager.owner(), owner); + } + + function test_renounceRole_revertsForDefaultAdminRole() public { + bytes32 adminRole = feeManager.DEFAULT_ADMIN_ROLE(); + vm.prank(owner); + vm.expectRevert(FeeManager.RenounceDisabled.selector); + feeManager.renounceRole(adminRole, owner); + + assertTrue(feeManager.hasRole(adminRole, owner)); + } + + function test_renounceRole_allowsNonAdminRoles() public { + vm.prank(pauser_); + feeManager.renounceRole(Roles.PAUSER_ROLE, pauser_); + + assertFalse(feeManager.hasRole(Roles.PAUSER_ROLE, pauser_)); + } + + function test_grantRole_revertsForDefaultAdminRole() public { + bytes32 adminRole = feeManager.DEFAULT_ADMIN_ROLE(); + address extra = makeAddr("extra"); + vm.prank(owner); + vm.expectRevert(FeeManager.DefaultAdminRoleChangeDisabled.selector); + feeManager.grantRole(adminRole, extra); + } + + function test_revokeRole_revertsForDefaultAdminRole() public { + bytes32 adminRole = feeManager.DEFAULT_ADMIN_ROLE(); + vm.prank(owner); + vm.expectRevert(FeeManager.DefaultAdminRoleChangeDisabled.selector); + feeManager.revokeRole(adminRole, owner); + } + + function test_grantRole_revertsForZeroRoleAccount() public { + vm.prank(owner); + vm.expectRevert(FeeManager.ZeroRoleAccount.selector); + feeManager.grantRole(Roles.PAUSER_ROLE, address(0)); + } + + function test_revokeRole_revertsForZeroRoleAccount() public { + vm.prank(owner); + vm.expectRevert(FeeManager.ZeroRoleAccount.selector); + feeManager.revokeRole(Roles.PAUSER_ROLE, address(0)); + } + + function test_implementation_initializeRevertsWhenCalledDirectly() public { + FeeManager impl = new FeeManager(address(mockFeeTaker), address(mockUsdcBlacklist)); + vm.expectRevert(Initializable.InvalidInitialization.selector); + impl.initialize(owner); + } + + function test_transferOwnership_syncsDefaultAdminRole() public { + address newOwner = makeAddr("newOwner"); + vm.prank(owner); + feeManager.transferOwnership(newOwner); + vm.prank(newOwner); + feeManager.acceptOwnership(); + + assertEq(feeManager.owner(), newOwner); + assertTrue(feeManager.hasRole(feeManager.DEFAULT_ADMIN_ROLE(), newOwner)); + assertFalse(feeManager.hasRole(feeManager.DEFAULT_ADMIN_ROLE(), owner)); + } + + // ───────────────────── pause / unpause ───────────────────── + + function test_pause_blocksCollectFeesWithQuote() public { + vm.prank(pauser_); + feeManager.pause(); + + IFeeManager.FeeWithQuoteRequest[] memory requests = new IFeeManager.FeeWithQuoteRequest[](0); + vm.prank(allowedCaller); + vm.expectRevert(PausableUpgradeable.EnforcedPause.selector); + feeManager.collectFeesWithQuote(CORRELATION_HASH, requests, _claim("")); + } + + function test_unpause_resumesCollectFeesWithQuote() public { + vm.prank(pauser_); + feeManager.pause(); + + vm.prank(pauser_); + feeManager.unpause(); + + IFeeManager.FeeWithQuoteRequest[] memory requests = new IFeeManager.FeeWithQuoteRequest[](0); + vm.prank(allowedCaller); + uint256 amount = feeManager.collectFeesWithQuote(CORRELATION_HASH, requests, _claim("")); + assertEq(amount, 0); + } + + function test_pause_emitsPausedEvent() public { + vm.expectEmit(true, false, false, true); + emit Paused(pauser_); + + vm.prank(pauser_); + feeManager.pause(); + } + + function test_unpause_emitsUnpausedEvent() public { + vm.prank(pauser_); + feeManager.pause(); + + vm.expectEmit(true, false, false, true); + emit Unpaused(pauser_); + + vm.prank(pauser_); + feeManager.unpause(); + } + + function test_pause_revertsForNonPauser() public { + vm.prank(allowedCaller); + vm.expectRevert( + abi.encodeWithSelector( + IAccessControl.AccessControlUnauthorizedAccount.selector, allowedCaller, Roles.PAUSER_ROLE + ) + ); + feeManager.pause(); + } + + function test_unpause_revertsForNonPauser() public { + vm.prank(pauser_); + feeManager.pause(); + + vm.prank(allowedCaller); + vm.expectRevert( + abi.encodeWithSelector( + IAccessControl.AccessControlUnauthorizedAccount.selector, allowedCaller, Roles.PAUSER_ROLE + ) + ); + feeManager.unpause(); + } + + function test_pause_doesNotBlockGrantRole() public { + vm.prank(pauser_); + feeManager.pause(); + + address newCaller = makeAddr("newCaller"); + vm.prank(callerAdmin); + feeManager.grantRole(Roles.ALLOWED_CALLER_ROLE, newCaller); + assertTrue(feeManager.hasRole(Roles.ALLOWED_CALLER_ROLE, newCaller)); + } + + function test_newPauserCanPause() public { + address newPauser = makeAddr("newPauser"); + vm.prank(owner); + feeManager.grantRole(Roles.PAUSER_ROLE, newPauser); + + vm.prank(newPauser); + feeManager.pause(); + assertTrue(feeManager.paused()); + } +} diff --git a/test/HookDataLib.t.sol b/test/HookDataLib.t.sol new file mode 100644 index 0000000..9d3333e --- /dev/null +++ b/test/HookDataLib.t.sol @@ -0,0 +1,180 @@ +/** + * Copyright 2026 Circle Internet Group, Inc. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + */ +pragma solidity ^0.8.20; + +import {HookDataLib} from "../src/libraries/HookDataLib.sol"; +import {Test} from "forge-std/Test.sol"; + +contract HookDataHarness { + function getForwardHook(bytes calldata hookData) external pure returns (HookDataLib.Hook memory hook, bool found) { + return HookDataLib.getForwardHook(hookData); + } +} + +/// @dev Reference hook data from Circle docs: +/// https://developers.circle.com/cctp/concepts/forwarding-service#hook-format +contract HookDataLibTest is Test { + HookDataHarness internal harness; + + function setUp() public { + harness = new HookDataHarness(); + } + + /// @dev Static forward hook with version 0 and no Circle data, as documented. + bytes internal constant STATIC_FORWARD_HOOK = hex"636374702d666f72776172640000000000000000000000000000000000000000"; + + // ─────────────── helpers ─────────────── + + function _buildHook(bytes24 name, uint32 version, bytes memory circleData) internal pure returns (bytes memory) { + return abi.encodePacked(name, version, uint32(circleData.length), circleData); + } + + // ───────────────────── single hook ───────────────────── + + function test_getForwardHook_staticHook_found() public view { + (HookDataLib.Hook memory hook, bool found) = harness.getForwardHook(STATIC_FORWARD_HOOK); + assertTrue(found); + assertEq(hook.name, HookDataLib.FORWARD_MAGIC); + assertEq(hook.version, 0); + assertEq(hook.circleData.length, 0); + } + + function test_getForwardHook_withCircleData_returnsData() public view { + bytes memory circleData = hex"aabbccdd"; + bytes memory hookData = _buildHook(HookDataLib.FORWARD_MAGIC, 0, circleData); + (HookDataLib.Hook memory hook, bool found) = harness.getForwardHook(hookData); + assertTrue(found); + assertEq(hook.circleData, circleData); + } + + function test_getForwardHook_withVersion_parsesVersion() public view { + bytes memory hookData = _buildHook(HookDataLib.FORWARD_MAGIC, 3, ""); + (HookDataLib.Hook memory hook, bool found) = harness.getForwardHook(hookData); + assertTrue(found); + assertEq(hook.version, 3); + } + + function test_getForwardHook_emptyData_notFound() public view { + (, bool found) = harness.getForwardHook(""); + assertFalse(found); + } + + function test_getForwardHook_tooShort_notFound() public view { + (, bool found) = harness.getForwardHook(hex"636374702d666f7277617264000000000000000000000000000000000000"); + assertFalse(found); + } + + function test_getForwardHook_wrongMagic_notFound() public view { + // forge-lint: disable-next-line(unsafe-typecast) + bytes memory hookData = _buildHook(bytes24("not-forward"), 0, ""); + (, bool found) = harness.getForwardHook(hookData); + assertFalse(found); + } + + function test_getForwardHook_truncatedCircleData_notFound() public view { + // Header claims 10 bytes of circle data but only 2 are present + bytes memory hookData = abi.encodePacked(HookDataLib.FORWARD_MAGIC, uint32(0), uint32(10), hex"aabb"); + (, bool found) = harness.getForwardHook(hookData); + assertFalse(found); + } + + // ───────────────────── chained hooks ───────────────────── + + function test_getForwardHook_secondHook_found() public view { + // forge-lint: disable-next-line(unsafe-typecast) + bytes memory otherHook = _buildHook(bytes24("some-other-hook"), 1, hex"ff"); + bytes memory forwardHook = _buildHook(HookDataLib.FORWARD_MAGIC, 2, hex"cafe"); + bytes memory hookData = abi.encodePacked(otherHook, forwardHook); + + (HookDataLib.Hook memory hook, bool found) = harness.getForwardHook(hookData); + assertTrue(found); + assertEq(hook.name, HookDataLib.FORWARD_MAGIC); + assertEq(hook.version, 2); + assertEq(hook.circleData, hex"cafe"); + } + + function test_getForwardHook_thirdHook_found() public view { + // forge-lint: disable-next-line(unsafe-typecast) + bytes memory hookA = _buildHook(bytes24("hook-a"), 0, hex"01"); + // forge-lint: disable-next-line(unsafe-typecast) + bytes memory hookB = _buildHook(bytes24("hook-b"), 0, hex"0203"); + bytes memory forwardHook = _buildHook(HookDataLib.FORWARD_MAGIC, 0, ""); + bytes memory hookData = abi.encodePacked(hookA, hookB, forwardHook); + + (, bool found) = harness.getForwardHook(hookData); + assertTrue(found); + } + + function test_getForwardHook_firstOfMultiple_returnsFirst() public view { + bytes memory forwardHook = _buildHook(HookDataLib.FORWARD_MAGIC, 5, hex"ab"); + // forge-lint: disable-next-line(unsafe-typecast) + bytes memory otherHook = _buildHook(bytes24("other"), 0, ""); + bytes memory hookData = abi.encodePacked(forwardHook, otherHook); + + (HookDataLib.Hook memory hook, bool found) = harness.getForwardHook(hookData); + assertTrue(found); + assertEq(hook.version, 5); + } + + function test_getForwardHook_noForwardAmongOthers_notFound() public view { + // forge-lint: disable-next-line(unsafe-typecast) + bytes memory hookA = _buildHook(bytes24("hook-a"), 0, ""); + // forge-lint: disable-next-line(unsafe-typecast) + bytes memory hookB = _buildHook(bytes24("hook-b"), 0, hex"dead"); + bytes memory hookData = abi.encodePacked(hookA, hookB); + + (, bool found) = harness.getForwardHook(hookData); + assertFalse(found); + } + + function test_getForwardHook_backwardsCompatible_singleHookStillWorks() public view { + // Single hook with no chaining — the original format + (HookDataLib.Hook memory hook, bool found) = harness.getForwardHook(STATIC_FORWARD_HOOK); + assertTrue(found); + assertEq(hook.name, HookDataLib.FORWARD_MAGIC); + assertEq(hook.version, 0); + assertEq(hook.circleData.length, 0); + } + + // ───────────────────── fuzz ───────────────────── + + function testFuzz_getForwardHook_validForward_roundtrips(uint32 version, bytes memory circleData) public view { + bytes memory hookData = _buildHook(HookDataLib.FORWARD_MAGIC, version, circleData); + (HookDataLib.Hook memory hook, bool found) = harness.getForwardHook(hookData); + assertTrue(found); + assertEq(hook.name, HookDataLib.FORWARD_MAGIC); + assertEq(hook.version, version); + assertEq(hook.circleData, circleData); + } + + function testFuzz_getForwardHook_afterOtherHook_found( + bytes24 otherName, + uint32 otherVersion, + bytes memory otherCircleData, + uint32 fwdVersion + ) public view { + vm.assume(otherName != HookDataLib.FORWARD_MAGIC); + bytes memory hookData = abi.encodePacked( + _buildHook(otherName, otherVersion, otherCircleData), _buildHook(HookDataLib.FORWARD_MAGIC, fwdVersion, "") + ); + + (HookDataLib.Hook memory hook, bool found) = harness.getForwardHook(hookData); + assertTrue(found); + assertEq(hook.version, fwdVersion); + } +} diff --git a/test/MessageV2Lib.t.sol b/test/MessageV2Lib.t.sol new file mode 100644 index 0000000..2cd42b9 --- /dev/null +++ b/test/MessageV2Lib.t.sol @@ -0,0 +1,203 @@ +/** + * Copyright 2026 Circle Internet Group, Inc. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + */ +pragma solidity ^0.8.20; + +import {MessageV2Lib} from "../src/libraries/MessageV2Lib.sol"; +import {Test} from "forge-std/Test.sol"; + +/// @dev Test vectors derived from a real Base mainnet transaction: +/// https://basescan.org/tx/0x52b96e0f5a0459a830c590a31f8b057847356ff4d7b54a45f3394a5d684ba2c4 +/// MessageSent event (log #5), DepositForBurn event (log #6). +/// +/// The message bytes were extracted from the event data returned by +/// `cast receipt ... --rpc-url https://mainnet.base.org --json` and +/// ABI-decoded (offset 0x40, length 0x178 = 376 bytes). +/// +/// keccak256 was verified independently via `cast keccak `. +contract MessageV2LibTest is Test { + // --- relay-formatted message (nonce = 0, as emitted in MessageSent) --- + + /// @dev keccak256 of the 376-byte relay-formatted message from the MessageSent event. + bytes32 internal constant EXPECTED_HASH = 0xb91aacdec0b76448a882084c57a193108bb8679770a836028894509ab328d06a; + + // --- attested message (nonce assigned by the CCTP attestation service) --- + + /// @dev Nonce assigned by the Iris attestation service for the same message. + /// Queried via: + /// https://iris-api.circle.com/v2/messages/6?transactionHash=0x52b96e0f5a0459a830c590a31f8b057847356ff4d7b54a45f3394a5d684ba2c4 + bytes32 internal constant ATTESTED_NONCE = 0xd866772989845774d5cac444b530ed02e16683c5d185bd0c16c824bbbe24243b; + + /// @dev keccak256 of the message with ATTESTED_NONCE spliced into the nonce + /// field (bytes 12..43). This is the hash the attestation service signs. + bytes32 internal constant EXPECTED_ATTESTED_HASH = + 0x46b34fe9b711a08e366535049dcdeca2b616d2f0c1d1541d3a33dca7f2715a3f; + + // --- decoded header fields --- + + uint32 internal constant SOURCE_DOMAIN = 6; // Base + uint32 internal constant DESTINATION_DOMAIN = 2; + bytes32 internal constant SENDER = bytes32(uint256(uint160(0x28b5a0e9C621a5BadaA536219b3a228C8168cf5d))); + bytes32 internal constant RECIPIENT = bytes32(uint256(uint160(0x28b5a0e9C621a5BadaA536219b3a228C8168cf5d))); + bytes32 internal constant DESTINATION_CALLER = bytes32(0); + uint32 internal constant MIN_FINALITY_THRESHOLD = 0; + + /// @dev Returns the full 376-byte message from the MessageSent event. + function _rawMessage() internal pure returns (bytes memory) { + return + // header (148 bytes) + hex"00000001" // version = 1 + hex"00000006" // sourceDomain = 6 + hex"00000002" // destinationDomain = 2 + hex"0000000000000000000000000000000000000000000000000000000000000000" // nonce + hex"00000000000000000000000028b5a0e9c621a5badaa536219b3a228c8168cf5d" // sender + hex"00000000000000000000000028b5a0e9c621a5badaa536219b3a228c8168cf5d" // recipient + hex"0000000000000000000000000000000000000000000000000000000000000000" // destinationCaller + hex"00000000" // minFinalityThreshold = 0 + hex"00000000" // finalityThresholdExecuted = 0 + // BurnMessageV2 body (228 bytes) + hex"00000001" // burnMessage version = 1 + hex"000000000000000000000000833589fcd6edb6e08f4c7c32d4f71b54bda02913" // burnToken (USDC) + hex"000000000000000000000000c01f5e3dcde4f1e8f1dc8cf1232123daeb917b8d" // mintRecipient + hex"0000000000000000000000000000000000000000000000000000000001cab499" // amount = 30_061_721 + hex"000000000000000000000000c01f5e3dcde4f1e8f1dc8cf1232123daeb917b8d" // messageSender + hex"0000000000000000000000000000000000000000000000000000000000000000" // maxFee = 0 + hex"0000000000000000000000000000000000000000000000000000000000000000" // feeExecuted = 0 + hex"0000000000000000000000000000000000000000000000000000000000000000"; // expirationBlock = 0 + } + + /// @dev Slices the message body (offset 148+) from the raw message. + function _messageBody() internal pure returns (bytes memory) { + bytes memory raw = _rawMessage(); + uint256 bodyLen = raw.length - 148; + bytes memory body = new bytes(bodyLen); + for (uint256 i = 0; i < bodyLen; i++) { + body[i] = raw[148 + i]; + } + return body; + } + + // ───────────────────── real transaction verification ───────────────────── + + function test_rawMessageHash_matchesKnownTransaction() public pure { + assertEq(keccak256(_rawMessage()), EXPECTED_HASH); + } + + function test_messageHash_matchesKnownTransaction() public pure { + bytes32 hash = MessageV2Lib.messageHash( + SOURCE_DOMAIN, + DESTINATION_DOMAIN, + SENDER, + RECIPIENT, + DESTINATION_CALLER, + MIN_FINALITY_THRESHOLD, + _messageBody() + ); + assertEq(hash, EXPECTED_HASH); + } + + function test_formatMessage_matchesRawBytes() public pure { + bytes memory formatted = MessageV2Lib.formatMessage( + SOURCE_DOMAIN, + DESTINATION_DOMAIN, + SENDER, + RECIPIENT, + DESTINATION_CALLER, + MIN_FINALITY_THRESHOLD, + _messageBody() + ); + assertEq(keccak256(formatted), keccak256(_rawMessage())); + } + + // ───────────────────── attested message verification ───────────────────── + + /// @dev The relay-formatted message (from MessageSent) carries EMPTY_NONCE. + /// The CCTP attestation service (Iris) assigns a real nonce before + /// signing. Splicing ATTESTED_NONCE into the nonce field must produce + /// EXPECTED_ATTESTED_HASH — the hash the attestation service signs and + /// that `receiveMessage` on the destination chain verifies. + function test_attestedMessageHash_matchesIrisApi() public pure { + bytes memory raw = _rawMessage(); + bytes32 nonce = ATTESTED_NONCE; + assembly { + mstore(add(raw, 44), nonce) // offset 32 (mem prefix) + 12 (nonce index) + } + assertEq(keccak256(raw), EXPECTED_ATTESTED_HASH); + } + + // ───────────────────── encoding properties ────────────────────────────── + + function test_formatMessage_length() public pure { + bytes memory body = hex"deadbeef"; + bytes memory encoded = + MessageV2Lib.formatMessage(1, 2, bytes32(uint256(0xAA)), bytes32(uint256(0xBB)), bytes32(0), 1000, body); + assertEq(encoded.length, 148 + body.length); + } + + function test_formatMessage_emptyBody() public pure { + bytes memory encoded = + MessageV2Lib.formatMessage(1, 2, bytes32(uint256(0xAA)), bytes32(uint256(0xBB)), bytes32(0), 0, ""); + assertEq(encoded.length, 148); + } + + function test_messageHash_isKeccakOfFormatMessage() public pure { + bytes memory body = hex"cafebabe"; + bytes memory encoded = + MessageV2Lib.formatMessage(6, 2, bytes32(uint256(0xAA)), bytes32(uint256(0xBB)), bytes32(0), 0, body); + bytes32 hash = + MessageV2Lib.messageHash(6, 2, bytes32(uint256(0xAA)), bytes32(uint256(0xBB)), bytes32(0), 0, body); + assertEq(hash, keccak256(encoded)); + } + + function testFuzz_messageHash_isKeccakOfFormatMessage( + uint32 sourceDomain, + uint32 destinationDomain, + bytes32 sender, + bytes32 recipient, + bytes32 destinationCaller, + uint32 minFinalityThreshold, + bytes memory messageBody + ) public pure { + bytes memory encoded = MessageV2Lib.formatMessage( + sourceDomain, destinationDomain, sender, recipient, destinationCaller, minFinalityThreshold, messageBody + ); + bytes32 hash = MessageV2Lib.messageHash( + sourceDomain, destinationDomain, sender, recipient, destinationCaller, minFinalityThreshold, messageBody + ); + assertEq(hash, keccak256(encoded)); + } + + function test_formatMessage_differentInputs_differentHashes() public pure { + bytes memory body = hex"00"; + bytes32 h1 = MessageV2Lib.messageHash(1, 2, bytes32(uint256(1)), bytes32(uint256(2)), bytes32(0), 0, body); + bytes32 h2 = MessageV2Lib.messageHash(1, 3, bytes32(uint256(1)), bytes32(uint256(2)), bytes32(0), 0, body); + assertTrue(h1 != h2); + } + + function test_formatMessage_senderAffectsHash() public pure { + bytes memory body = hex"00"; + bytes32 h1 = MessageV2Lib.messageHash(1, 2, bytes32(uint256(0xAA)), bytes32(uint256(2)), bytes32(0), 0, body); + bytes32 h2 = MessageV2Lib.messageHash(1, 2, bytes32(uint256(0xBB)), bytes32(uint256(2)), bytes32(0), 0, body); + assertTrue(h1 != h2); + } + + function test_formatMessage_bodyAffectsHash() public pure { + bytes32 h1 = MessageV2Lib.messageHash(1, 2, bytes32(uint256(1)), bytes32(uint256(2)), bytes32(0), 0, hex"aa"); + bytes32 h2 = MessageV2Lib.messageHash(1, 2, bytes32(uint256(1)), bytes32(uint256(2)), bytes32(0), 0, hex"bb"); + assertTrue(h1 != h2); + } +} diff --git a/test/QuoteLib.t.sol b/test/QuoteLib.t.sol new file mode 100644 index 0000000..7205b90 --- /dev/null +++ b/test/QuoteLib.t.sol @@ -0,0 +1,392 @@ +/** + * Copyright 2026 Circle Internet Group, Inc. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + */ +pragma solidity ^0.8.20; + +import {IFeeManager} from "../src/interfaces/IFeeManager.sol"; +import {QuoteLib} from "../src/libraries/QuoteLib.sol"; +import {Test} from "forge-std/Test.sol"; + +/// @dev Exposes internal library helpers for testing. +contract QuoteLibHarness { + using QuoteLib for uint256; + using QuoteLib for IFeeManager.Quote; + using QuoteLib for IFeeManager.QuoteItem; + + function validateExpiry(uint256 encodedExpiry) external view { + encodedExpiry.validateExpiry(); + } + + function mustNotExpire(IFeeManager.Quote calldata quote) external view { + IFeeManager.Quote memory q = quote; + q.mustNotExpire(); + } + + function findItemByType(IFeeManager.Quote calldata quote, string calldata qType) + external + pure + returns (IFeeManager.QuoteItem memory item, uint256 itemIndex, bool matched) + { + IFeeManager.Quote memory q = quote; + return q.findItemByType(qType); + } + + function matchesRequest(IFeeManager.QuoteItem calldata item, string[] calldata quoteArgs) + external + pure + returns (bool) + { + IFeeManager.QuoteItem memory it = item; + return it.matchesRequest(quoteArgs); + } + + function hashQuoteArgs(string[] calldata quoteArgs) external pure returns (bytes32) { + return QuoteLib.hashQuoteArgs(quoteArgs); + } + + function totalFeeAndFeeToken(IFeeManager.Quote calldata quote) + external + pure + returns (uint256 totalFee, address feeToken) + { + IFeeManager.Quote memory q = quote; + return q.totalFeeAndFeeToken(); + } + + function hasItemType(IFeeManager.Quote calldata quote, string calldata quoteType) external pure returns (bool) { + IFeeManager.Quote memory q = quote; + return q.hasItemType(quoteType); + } + + function requireSupportedItemCount(IFeeManager.Quote calldata quote) external pure { + IFeeManager.Quote memory q = quote; + q.requireSupportedItemCount(); + } + + function findItemForRequest(IFeeManager.Quote calldata quote, IFeeManager.FeeWithQuoteRequest calldata request) + external + pure + returns (IFeeManager.QuoteItem memory item, uint256 itemIndex, bool matched) + { + IFeeManager.Quote memory q = quote; + return q.findItemForRequest(request); + } + + function sumFees(IFeeManager.Quote calldata quote, string[] calldata quoteTypes) + external + pure + returns (uint256 totalFee, address feeToken) + { + IFeeManager.Quote memory q = quote; + return q.sumFees(quoteTypes); + } +} + +contract QuoteLibTest is Test { + QuoteLibHarness internal harness; + + function setUp() public { + harness = new QuoteLibHarness(); + } + + function test_validate_timestampMode_succeedsAtDeadline() public view { + uint256 encoded = block.timestamp; // mode 0, deadline == now + harness.validateExpiry(encoded); + } + + function test_validate_timestampMode_succeedsBeforeDeadline() public view { + uint256 encoded = block.timestamp + 1 days; + harness.validateExpiry(encoded); + } + + function test_validate_timestampMode_revertsAfterDeadline() public { + uint256 deadline = block.timestamp - 1; + uint256 encoded = deadline; // mode 0 + vm.expectRevert(abi.encodeWithSelector(QuoteLib.QuoteExpired.selector, uint8(0), deadline, block.timestamp)); + harness.validateExpiry(encoded); + } + + function test_validate_blockNumberMode_succeedsAtDeadline() public view { + uint256 encoded = (uint256(1) << 248) | block.number; + harness.validateExpiry(encoded); + } + + function test_validate_blockNumberMode_succeedsBeforeDeadline() public view { + uint256 encoded = (uint256(1) << 248) | (block.number + 100); + harness.validateExpiry(encoded); + } + + function test_validate_blockNumberMode_revertsAfterDeadline() public { + uint256 deadline = block.number - 1; + uint256 encoded = (uint256(1) << 248) | deadline; + vm.expectRevert(abi.encodeWithSelector(QuoteLib.QuoteExpired.selector, uint8(1), deadline, block.number)); + harness.validateExpiry(encoded); + } + + function test_validate_unsupportedMode_reverts(uint8 modeRaw) public { + uint8 mode = uint8(bound(uint256(modeRaw), 2, type(uint8).max)); + uint256 encoded = (uint256(mode) << 248) | uint256(block.timestamp + 1 days); + vm.expectRevert(abi.encodeWithSelector(QuoteLib.UnsupportedExpiryMode.selector, mode)); + harness.validateExpiry(encoded); + } + + function test_mustNotExpire_matchesValidate() public view { + uint256 deadline = block.timestamp + 1; + IFeeManager.QuoteItem[] memory items; + IFeeManager.Quote memory quote = + IFeeManager.Quote({items: items, expiry: deadline, feeToken: address(0), nonce: 0, signature: ""}); + harness.mustNotExpire(quote); + } + + function test_mustNotExpire_revertsWhenValidateWould() public { + uint256 deadline = block.timestamp - 1; + IFeeManager.QuoteItem[] memory items; + IFeeManager.Quote memory quote = + IFeeManager.Quote({items: items, expiry: deadline, feeToken: address(0), nonce: 0, signature: ""}); + vm.expectRevert(abi.encodeWithSelector(QuoteLib.QuoteExpired.selector, uint8(0), deadline, block.timestamp)); + harness.mustNotExpire(quote); + } + + function test_validate_timestampMode_warpAffectsExpiry() public { + uint256 deadline = 1000; + uint256 encoded = deadline; + vm.warp(999); + harness.validateExpiry(encoded); + vm.warp(1001); + vm.expectRevert(abi.encodeWithSelector(QuoteLib.QuoteExpired.selector, uint8(0), deadline, uint256(1001))); + harness.validateExpiry(encoded); + } + + function test_validate_blockNumberMode_rollAffectsExpiry() public { + uint256 deadline = 10; + uint256 encoded = (uint256(1) << 248) | deadline; + vm.roll(9); + harness.validateExpiry(encoded); + vm.roll(11); + vm.expectRevert(abi.encodeWithSelector(QuoteLib.QuoteExpired.selector, uint8(1), deadline, uint256(11))); + harness.validateExpiry(encoded); + } + + function test_findItemByType_emptyItems_notFound() public view { + IFeeManager.QuoteItem[] memory items = new IFeeManager.QuoteItem[](0); + IFeeManager.Quote memory quote = + IFeeManager.Quote({items: items, expiry: 0, feeToken: address(0), nonce: 0, signature: ""}); + (IFeeManager.QuoteItem memory item, uint256 itemIndex, bool matched) = harness.findItemByType(quote, "FORWARD"); + assertFalse(matched); + assertEq(itemIndex, 0); + assertEq(item.amount, 0); + } + + function test_findItemByType_returnsFirstOfType() public view { + IFeeManager.QuoteItem[] memory items = new IFeeManager.QuoteItem[](2); + items[0] = IFeeManager.QuoteItem({quoteType: "A", quoteArgsHash: bytes32(uint256(1)), amount: 10}); + items[1] = IFeeManager.QuoteItem({quoteType: "A", quoteArgsHash: bytes32(uint256(2)), amount: 20}); + IFeeManager.Quote memory quote = + IFeeManager.Quote({items: items, expiry: 0, feeToken: address(0), nonce: 0, signature: ""}); + (IFeeManager.QuoteItem memory item, uint256 itemIndex, bool matched) = harness.findItemByType(quote, "A"); + assertTrue(matched); + assertEq(itemIndex, 0); + assertEq(item.amount, 10); + assertEq(item.quoteArgsHash, bytes32(uint256(1))); + } + + function test_findItemByType_noSuchType() public view { + IFeeManager.QuoteItem[] memory items = new IFeeManager.QuoteItem[](1); + items[0] = IFeeManager.QuoteItem({quoteType: "B", quoteArgsHash: bytes32(0), amount: 1}); + IFeeManager.Quote memory quote = + IFeeManager.Quote({items: items, expiry: 0, feeToken: address(0), nonce: 0, signature: ""}); + (,, bool matched) = harness.findItemByType(quote, "A"); + assertFalse(matched); + } + + function test_hashQuoteArgs_matchesRequest_consistent() public view { + string[] memory args = new string[](1); + args[0] = "x"; + bytes32 h = harness.hashQuoteArgs(args); + IFeeManager.QuoteItem memory item = IFeeManager.QuoteItem({quoteType: "T", quoteArgsHash: h, amount: 1}); + assertTrue(harness.matchesRequest(item, args)); + string[] memory other = new string[](1); + other[0] = "y"; + assertFalse(harness.matchesRequest(item, other)); + } + + function test_totalFeeAndFeeToken_emptyItems() public view { + IFeeManager.QuoteItem[] memory items = new IFeeManager.QuoteItem[](0); + address token = address(uint160(0xBEEF)); + IFeeManager.Quote memory quote = + IFeeManager.Quote({items: items, expiry: 0, feeToken: token, nonce: 0, signature: ""}); + (uint256 total, address ft) = harness.totalFeeAndFeeToken(quote); + assertEq(total, 0); + assertEq(ft, token); + } + + function test_hasItemType_findsType() public view { + IFeeManager.QuoteItem[] memory items = new IFeeManager.QuoteItem[](1); + items[0] = IFeeManager.QuoteItem({quoteType: "FORWARD", quoteArgsHash: bytes32(0), amount: 1}); + IFeeManager.Quote memory quote = + IFeeManager.Quote({items: items, expiry: 0, feeToken: address(0), nonce: 0, signature: ""}); + assertTrue(harness.hasItemType(quote, "FORWARD")); + assertFalse(harness.hasItemType(quote, "PRE_FINALITY")); + } + + function test_requireSupportedItemCount_revertsTooManyQuoteItems() public { + IFeeManager.QuoteItem[] memory items = new IFeeManager.QuoteItem[](257); + IFeeManager.Quote memory quote = + IFeeManager.Quote({items: items, expiry: 0, feeToken: address(0), nonce: 0, signature: ""}); + + vm.expectRevert(abi.encodeWithSelector(IFeeManager.TooManyQuoteItems.selector, uint256(257))); + harness.requireSupportedItemCount(quote); + } + + function test_totalFeeAndFeeToken_sumsAmounts() public view { + IFeeManager.QuoteItem[] memory items = new IFeeManager.QuoteItem[](2); + items[0] = IFeeManager.QuoteItem({quoteType: "A", quoteArgsHash: bytes32(0), amount: 100}); + items[1] = IFeeManager.QuoteItem({quoteType: "B", quoteArgsHash: bytes32(0), amount: 5}); + address token = address(uint160(0xCAFE)); + IFeeManager.Quote memory quote = + IFeeManager.Quote({items: items, expiry: 0, feeToken: token, nonce: 0, signature: ""}); + (uint256 total, address ft) = harness.totalFeeAndFeeToken(quote); + assertEq(total, 105); + assertEq(ft, token); + } + + function test_findItemForRequest_optionalMissing_returnsUnmatched() public view { + IFeeManager.QuoteItem[] memory items = new IFeeManager.QuoteItem[](0); + IFeeManager.Quote memory quote = + IFeeManager.Quote({items: items, expiry: 0, feeToken: address(0), nonce: 0, signature: ""}); + + string[] memory args = new string[](1); + args[0] = "x"; + IFeeManager.FeeWithQuoteRequest memory request = + IFeeManager.FeeWithQuoteRequest({quoteType: "A", quoteArgs: args, required: false}); + + (IFeeManager.QuoteItem memory item, uint256 itemIndex, bool matched) = + harness.findItemForRequest(quote, request); + assertFalse(matched); + assertEq(itemIndex, 0); + assertEq(item.amount, 0); + } + + function test_findItemForRequest_requiredMissing_revertsRequiredFeeNotInQuote() public { + IFeeManager.QuoteItem[] memory items = new IFeeManager.QuoteItem[](0); + IFeeManager.Quote memory quote = + IFeeManager.Quote({items: items, expiry: 0, feeToken: address(0), nonce: 0, signature: ""}); + + string[] memory args = new string[](1); + args[0] = "x"; + IFeeManager.FeeWithQuoteRequest memory request = + IFeeManager.FeeWithQuoteRequest({quoteType: "A", quoteArgs: args, required: true}); + + bytes32 wantedHash = keccak256(abi.encode(args)); + vm.expectRevert(abi.encodeWithSelector(IFeeManager.RequiredFeeNotInQuote.selector, "A", wantedHash)); + harness.findItemForRequest(quote, request); + } + + function test_findItemForRequest_argsMismatch_revertsQuoteArgsMismatch() public { + string[] memory quotedArgs = new string[](1); + quotedArgs[0] = "quoted"; + string[] memory wantedArgs = new string[](1); + wantedArgs[0] = "wanted"; + + IFeeManager.QuoteItem[] memory items = new IFeeManager.QuoteItem[](1); + items[0] = IFeeManager.QuoteItem({quoteType: "A", quoteArgsHash: keccak256(abi.encode(quotedArgs)), amount: 10}); + IFeeManager.Quote memory quote = + IFeeManager.Quote({items: items, expiry: 0, feeToken: address(0), nonce: 0, signature: ""}); + + IFeeManager.FeeWithQuoteRequest memory request = + IFeeManager.FeeWithQuoteRequest({quoteType: "A", quoteArgs: wantedArgs, required: false}); + + bytes32 wantedHash = keccak256(abi.encode(wantedArgs)); + vm.expectRevert(abi.encodeWithSelector(IFeeManager.QuoteArgsMismatch.selector, "A", wantedHash)); + harness.findItemForRequest(quote, request); + } + + function test_findItemForRequest_argsMatch_returnsItem() public view { + string[] memory args = new string[](1); + args[0] = "a"; + + IFeeManager.QuoteItem[] memory items = new IFeeManager.QuoteItem[](1); + items[0] = IFeeManager.QuoteItem({quoteType: "A", quoteArgsHash: keccak256(abi.encode(args)), amount: 7}); + IFeeManager.Quote memory quote = + IFeeManager.Quote({items: items, expiry: 0, feeToken: address(0), nonce: 0, signature: ""}); + + IFeeManager.FeeWithQuoteRequest memory request = + IFeeManager.FeeWithQuoteRequest({quoteType: "A", quoteArgs: args, required: true}); + + (IFeeManager.QuoteItem memory item, uint256 itemIndex, bool matched) = + harness.findItemForRequest(quote, request); + assertTrue(matched); + assertEq(itemIndex, 0); + assertEq(item.amount, 7); + } + + function test_sumFees_sumsRequestedQuoteTypesOnly() public view { + string[] memory args = new string[](1); + args[0] = "a"; + + IFeeManager.QuoteItem[] memory items = new IFeeManager.QuoteItem[](2); + items[0] = IFeeManager.QuoteItem({quoteType: "A", quoteArgsHash: keccak256(abi.encode(args)), amount: 10}); + items[1] = IFeeManager.QuoteItem({quoteType: "B", quoteArgsHash: bytes32(0), amount: 20}); + address token = address(uint160(0xCAFE)); + IFeeManager.Quote memory quote = + IFeeManager.Quote({items: items, expiry: 0, feeToken: token, nonce: 0, signature: ""}); + + string[] memory quoteTypes = new string[](1); + quoteTypes[0] = "A"; + + (uint256 totalFee, address feeToken) = harness.sumFees(quote, quoteTypes); + assertEq(totalFee, 10); + assertEq(feeToken, token); + } + + function test_sumFees_dedupsDuplicateQuoteTypes() public view { + IFeeManager.QuoteItem[] memory items = new IFeeManager.QuoteItem[](2); + items[0] = IFeeManager.QuoteItem({quoteType: "A", quoteArgsHash: bytes32(0), amount: 10}); + items[1] = IFeeManager.QuoteItem({quoteType: "B", quoteArgsHash: bytes32(0), amount: 20}); + address token = address(uint160(0xCAFE)); + IFeeManager.Quote memory quote = + IFeeManager.Quote({items: items, expiry: 0, feeToken: token, nonce: 0, signature: ""}); + + // "A" appears twice, "B" once: the duplicate "A" must not be double-counted. + string[] memory quoteTypes = new string[](3); + quoteTypes[0] = "A"; + quoteTypes[1] = "B"; + quoteTypes[2] = "A"; + + (uint256 totalFee, address feeToken) = harness.sumFees(quote, quoteTypes); + assertEq(totalFee, 30); + assertEq(feeToken, token); + } + + function test_sumFees_dedupOnlyMatchedTypes() public view { + IFeeManager.QuoteItem[] memory items = new IFeeManager.QuoteItem[](1); + items[0] = IFeeManager.QuoteItem({quoteType: "A", quoteArgsHash: bytes32(0), amount: 10}); + address token = address(uint160(0xCAFE)); + IFeeManager.Quote memory quote = + IFeeManager.Quote({items: items, expiry: 0, feeToken: token, nonce: 0, signature: ""}); + + // Repeated unmatched types do not contribute regardless of dedup. + string[] memory quoteTypes = new string[](3); + quoteTypes[0] = "MISSING"; + quoteTypes[1] = "A"; + quoteTypes[2] = "MISSING"; + + (uint256 totalFee, address feeToken) = harness.sumFees(quote, quoteTypes); + assertEq(totalFee, 10); + assertEq(feeToken, token); + } +} diff --git a/test/TestConstants.sol b/test/TestConstants.sol new file mode 100644 index 0000000..e1df532 --- /dev/null +++ b/test/TestConstants.sol @@ -0,0 +1,21 @@ +/** + * Copyright 2026 Circle Internet Group, Inc. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + */ +pragma solidity ^0.8.20; + +/// @dev Anvil default account #0 — shared across all deploy/upgrade script tests. +uint256 constant ANVIL_DEFAULT_PK = 0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80; diff --git a/test/TokenMessengerWithFees.t.sol b/test/TokenMessengerWithFees.t.sol new file mode 100644 index 0000000..7129f2b --- /dev/null +++ b/test/TokenMessengerWithFees.t.sol @@ -0,0 +1,1535 @@ +/** + * Copyright 2026 Circle Internet Group, Inc. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + */ +pragma solidity ^0.8.20; + +import {EIP712TypeHashes} from "../src/constants/EIP712TypeHashes.sol"; +import {QuoteTypes} from "../src/constants/QuoteTypes.sol"; +import {Roles} from "../src/constants/Roles.sol"; +import {FeeManager} from "../src/contracts/FeeManager.sol"; +import {TokenMessengerWithFees} from "../src/contracts/TokenMessengerWithFees.sol"; +import {IFeeManager} from "../src/interfaces/IFeeManager.sol"; +import {ITokenMessengerWithFees} from "../src/interfaces/ITokenMessengerWithFees.sol"; +import {BurnMessageV2Lib} from "../src/libraries/BurnMessageV2Lib.sol"; +import {MessageV2Lib} from "../src/libraries/MessageV2Lib.sol"; +import {SignedQuoteLib} from "../src/libraries/SignedQuoteLib.sol"; +import {Initializable} from "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol"; +import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol"; +import {ERC1967Proxy} from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol"; +import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol"; +import {Strings} from "@openzeppelin/contracts/utils/Strings.sol"; +import {Test, Vm} from "forge-std/Test.sol"; + +import {IFeeTaker} from "../src/interfaces/IFeeTaker.sol"; +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; + +import {MockUSDCBlacklist} from "./mocks/MockUSDCBlacklist.sol"; +import {MockUsdcAwareBurnToken} from "./mocks/MockUsdcAwareBurnToken.sol"; + +// ─────────────────────────────── Mocks ─────────────────────────────── + +contract MockBurnToken is ERC20 { + constructor() ERC20("MockUSDC", "USDC") {} + + function mint(address to, uint256 amount) external { + _mint(to, amount); + } +} + +contract MockFeeToken is ERC20 { + constructor() ERC20("MockFeeToken", "FEE") {} + + function mint(address to, uint256 amount) external { + _mint(to, amount); + } +} + +contract MockFeeTakerForWrapper is IFeeTaker { + function protocolFeeAddress() external view override returns (address) { + return address(this); + } + + // solhint-disable-next-line no-empty-blocks + function takeFeeNative(address payable, bytes8) external payable override {} + + function takeFeeERC20(address token, address, uint256 fee, bytes8) external override { + // forge-lint: disable-next-line(erc20-unchecked-transfer) + IERC20(token).transferFrom(msg.sender, address(this), fee); + } +} + +contract MockMessageTransmitter { + uint32 public localDomain; + + constructor(uint32 domain_) { + localDomain = domain_; + } +} + +/* solhint-disable avoid-tx-origin */ +contract MockTokenMessengerV2 { + address public localMessageTransmitter; + uint32 public messageBodyVersion; + mapping(uint32 => bytes32) public remoteTokenMessengers; + mapping(address => bool) public isDenylisted; + + uint256 public lastAmount; + uint32 public lastDestinationDomain; + bytes32 public lastMintRecipient; + address public lastBurnToken; + bytes32 public lastDestinationCaller; + uint256 public lastMaxFee; + uint32 public lastMinFinalityThreshold; + bytes public lastHookData; + bool public lastCallWasHook; + + constructor(address messageTransmitter_, uint32 bodyVersion_) { + localMessageTransmitter = messageTransmitter_; + messageBodyVersion = bodyVersion_; + } + + function setRemoteTokenMessenger(uint32 domain, bytes32 messenger) external { + remoteTokenMessengers[domain] = messenger; + } + + function setDenylisted(address addr, bool denied) external { + isDenylisted[addr] = denied; + } + + function depositForBurn( + uint256 amount, + uint32 destinationDomain, + bytes32 mintRecipient, + address burnToken, + bytes32 destinationCaller, + uint256 maxFee, + uint32 minFinalityThreshold + ) external { + if (isDenylisted[tx.origin]) { + revert ITokenMessengerWithFees.DenylistedCaller(tx.origin); + } + // forge-lint: disable-next-line(erc20-unchecked-transfer) + ERC20(burnToken).transferFrom(msg.sender, address(this), amount); + lastAmount = amount; + lastDestinationDomain = destinationDomain; + lastMintRecipient = mintRecipient; + lastBurnToken = burnToken; + lastDestinationCaller = destinationCaller; + lastMaxFee = maxFee; + lastMinFinalityThreshold = minFinalityThreshold; + lastCallWasHook = false; + } + + function depositForBurnWithHook( + uint256 amount, + uint32 destinationDomain, + bytes32 mintRecipient, + address burnToken, + bytes32 destinationCaller, + uint256 maxFee, + uint32 minFinalityThreshold, + bytes calldata hookData + ) external { + if (isDenylisted[tx.origin]) { + revert ITokenMessengerWithFees.DenylistedCaller(tx.origin); + } + // forge-lint: disable-next-line(erc20-unchecked-transfer) + ERC20(burnToken).transferFrom(msg.sender, address(this), amount); + lastAmount = amount; + lastDestinationDomain = destinationDomain; + lastMintRecipient = mintRecipient; + lastBurnToken = burnToken; + lastDestinationCaller = destinationCaller; + lastMaxFee = maxFee; + lastMinFinalityThreshold = minFinalityThreshold; + lastHookData = hookData; + lastCallWasHook = true; + } +} + +/* solhint-enable avoid-tx-origin */ + +// ─────────────────────────── Test Contract ─────────────────────────── + +contract TokenMessengerWithFeesTest is Test { + TokenMessengerWithFees internal wrapper; + FeeManager internal feeManager; + MockTokenMessengerV2 internal mockTm; + MockMessageTransmitter internal mockMt; + MockBurnToken internal burnToken; + MockFeeToken internal mockFeeToken; + MockFeeTakerForWrapper internal mockFeeTaker; + MockUSDCBlacklist internal mockUsdcBlacklist; + + address internal owner = makeAddr("owner"); + address internal user = makeAddr("user"); + + uint32 internal constant SOURCE_DOMAIN = 0; + uint32 internal constant DEST_DOMAIN = 6; + uint32 internal constant BODY_VERSION = 1; + bytes32 internal constant REMOTE_TM = bytes32(uint256(uint160(0xDeaDBeef))); + bytes32 internal constant MINT_RECIPIENT = bytes32(uint256(uint160(0xCafe))); + bytes32 internal constant DEST_CALLER = bytes32(0); + + uint256 internal constant BURN_AMOUNT = 100e6; + uint256 internal constant TRUSTED_SIGNER_PK = 0xa11ce; + + event FeeCollected( + address indexed caller, + bytes32 indexed correlationHash, + string quoteType, + string[] quoteArgs, + address payable refundAddress, + address payable feeRecipient, + address feeToken, + uint256 amount, + uint256 nonce + ); + + // ──────────────── Helpers ──────────────── + + /// @dev Builds a `QuoteClaim` whose refund target is the test's `user` EOA. + /// Used by the bulk of bytes-overload entry-point call sites in this file. + function _userClaim(bytes memory signedQuote) internal view returns (IFeeManager.QuoteClaim memory) { + return IFeeManager.QuoteClaim({signedQuote: signedQuote, refundAddress: payable(user)}); + } + + /// @dev Builds a `QuoteClaim` with a caller-supplied refund target. Used by the few tests that + /// need to exercise non-default refund attribution (e.g. `address(0)` or a non-`user` target). + function _claim(bytes memory signedQuote, address payable refundAddress) + internal + pure + returns (IFeeManager.QuoteClaim memory) + { + return IFeeManager.QuoteClaim({signedQuote: signedQuote, refundAddress: refundAddress}); + } + + /// @dev Decoded counterpart to `_userClaim` — bundles a decoded `Quote` with `user` as refund target. + function _userDecodedClaim(IFeeManager.Quote memory decoded) + internal + view + returns (IFeeManager.DecodedQuoteClaim memory) + { + return IFeeManager.DecodedQuoteClaim({decodedQuote: decoded, refundAddress: payable(user)}); + } + + function _deployFeeManagerProxy(address initialOwner) internal returns (FeeManager) { + FeeManager impl = new FeeManager(address(mockFeeTaker), address(mockUsdcBlacklist)); + bytes memory initData = abi.encodeCall(FeeManager.initialize, (initialOwner)); + ERC1967Proxy proxy = new ERC1967Proxy(address(impl), initData); + return FeeManager(payable(address(proxy))); + } + + function _deployWrapperProxy(address owner_, address tokenMessenger_, address feeManager_) + internal + returns (TokenMessengerWithFees) + { + TokenMessengerWithFees impl = new TokenMessengerWithFees(tokenMessenger_, feeManager_); + bytes memory initData = abi.encodeCall(TokenMessengerWithFees.initialize, (owner_)); + ERC1967Proxy proxy = new ERC1967Proxy(address(impl), initData); + return TokenMessengerWithFees(payable(address(proxy))); + } + + function _signQuote(IFeeManager.QuoteItem[] memory items, uint256 expiry, address feeToken) + internal + view + returns (bytes memory) + { + bytes32[] memory itemHashes = new bytes32[](items.length); + for (uint256 i = 0; i < items.length; ++i) { + itemHashes[i] = keccak256( + abi.encode( + EIP712TypeHashes.QUOTE_ITEM_TYPEHASH, + keccak256(bytes(items[i].quoteType)), + items[i].quoteArgsHash, + items[i].amount + ) + ); + } + bytes32 domainSep = keccak256( + abi.encode( + EIP712TypeHashes.DOMAIN_TYPEHASH, + keccak256(bytes("FeeManager")), + keccak256(bytes("1")), + block.chainid, + address(feeManager) + ) + ); + bytes32 structHash = keccak256( + abi.encode( + EIP712TypeHashes.QUOTE_TYPEHASH, keccak256(abi.encodePacked(itemHashes)), expiry, feeToken, uint256(0) + ) + ); + bytes32 digest = keccak256(abi.encodePacked(hex"1901", domainSep, structHash)); + (uint8 v, bytes32 r, bytes32 s) = vm.sign(TRUSTED_SIGNER_PK, digest); + IFeeManager.Quote memory q = IFeeManager.Quote({ + items: items, expiry: expiry, feeToken: feeToken, nonce: 0, signature: abi.encodePacked(r, s, v) + }); + return abi.encodePacked(uint8(SignedQuoteLib.QUOTE_WIRE_FORMAT_VERSION), abi.encode(q)); + } + + function _preFinArgsHash() internal view returns (bytes32) { + return _preFinArgsHashFor(address(burnToken), BURN_AMOUNT); + } + + function _preFinArgsHashFor(address burnTokenAddr, uint256 amount) internal view returns (bytes32) { + string[] memory args = new string[](4); + args[0] = Strings.toHexString(address(wrapper)); + args[1] = Strings.toString(uint256(DEST_DOMAIN)); + args[2] = Strings.toHexString(burnTokenAddr); + args[3] = Strings.toString(amount); + return keccak256(abi.encode(args)); + } + + function _forwardArgsHash(bytes memory hookData) internal view returns (bytes32) { + return _forwardArgsHashFor(address(burnToken), hookData); + } + + function _forwardArgsHashFor(address burnTokenAddr, bytes memory hookData) internal view returns (bytes32) { + string[] memory args = new string[](5); + args[0] = Strings.toHexString(address(wrapper)); + args[1] = Strings.toString(uint256(DEST_DOMAIN)); + args[2] = Strings.toHexString(burnTokenAddr); + args[3] = Strings.toHexString(uint256(DEST_CALLER), 32); + args[4] = Strings.toHexString(hookData); + return keccak256(abi.encode(args)); + } + + function _emptySignedQuote() internal view returns (bytes memory) { + IFeeManager.QuoteItem[] memory items = new IFeeManager.QuoteItem[](0); + return _signQuote(items, block.timestamp + 300, address(0)); + } + + function _preFinOnlyQuote() internal view returns (bytes memory) { + IFeeManager.QuoteItem[] memory items = new IFeeManager.QuoteItem[](1); + items[0] = + IFeeManager.QuoteItem({quoteType: QuoteTypes.PRE_FINALITY, quoteArgsHash: _preFinArgsHash(), amount: 100}); + return _signQuote(items, block.timestamp + 300, address(0)); + } + + function _forwardOnlyQuote() internal view returns (bytes memory) { + IFeeManager.QuoteItem[] memory items = new IFeeManager.QuoteItem[](1); + items[0] = IFeeManager.QuoteItem({ + quoteType: QuoteTypes.FORWARD, quoteArgsHash: _forwardArgsHash(_forwardHookData()), amount: 900_000 + }); + return _signQuote(items, block.timestamp + 300, address(0)); + } + + function _fullQuote() internal view returns (bytes memory) { + IFeeManager.QuoteItem[] memory items = new IFeeManager.QuoteItem[](2); + items[0] = IFeeManager.QuoteItem({ + quoteType: QuoteTypes.FORWARD, quoteArgsHash: _forwardArgsHash(_forwardHookData()), amount: 900_000 + }); + items[1] = + IFeeManager.QuoteItem({quoteType: QuoteTypes.PRE_FINALITY, quoteArgsHash: _preFinArgsHash(), amount: 100}); + return _signQuote(items, block.timestamp + 300, address(0)); + } + + function _preFinOnlyQuoteErc20() internal view returns (bytes memory) { + IFeeManager.QuoteItem[] memory items = new IFeeManager.QuoteItem[](1); + items[0] = + IFeeManager.QuoteItem({quoteType: QuoteTypes.PRE_FINALITY, quoteArgsHash: _preFinArgsHash(), amount: 5e18}); + return _signQuote(items, block.timestamp + 300, address(mockFeeToken)); + } + + function _fullQuoteErc20() internal view returns (bytes memory) { + IFeeManager.QuoteItem[] memory items = new IFeeManager.QuoteItem[](2); + items[0] = IFeeManager.QuoteItem({ + quoteType: QuoteTypes.FORWARD, quoteArgsHash: _forwardArgsHash(_forwardHookData()), amount: 3e18 + }); + items[1] = + IFeeManager.QuoteItem({quoteType: QuoteTypes.PRE_FINALITY, quoteArgsHash: _preFinArgsHash(), amount: 5e18}); + return _signQuote(items, block.timestamp + 300, address(mockFeeToken)); + } + + /// @dev PRE_FINALITY + extra type the wrapper never requests — mimics partial collection vs `getQuoteTotal`. + function _preFinPlusSettlementQuoteErc20() internal view returns (bytes memory) { + IFeeManager.QuoteItem[] memory items = new IFeeManager.QuoteItem[](2); + items[0] = + IFeeManager.QuoteItem({quoteType: QuoteTypes.PRE_FINALITY, quoteArgsHash: _preFinArgsHash(), amount: 5e18}); + items[1] = IFeeManager.QuoteItem({quoteType: "SETTLEMENT", quoteArgsHash: bytes32(0), amount: 5e18}); + return _signQuote(items, block.timestamp + 300, address(mockFeeToken)); + } + + /// @dev Builds a valid cctp-forward hook (version 0, no Circle data). + function _forwardHookData() internal pure returns (bytes memory) { + return hex"636374702d666f72776172640000000000000000000000000000000000000000"; + } + + // ──────────────── setUp ──────────────── + + function setUp() public { + mockFeeTaker = new MockFeeTakerForWrapper(); + mockUsdcBlacklist = new MockUSDCBlacklist(); + mockMt = new MockMessageTransmitter(SOURCE_DOMAIN); + mockTm = new MockTokenMessengerV2(address(mockMt), BODY_VERSION); + mockTm.setRemoteTokenMessenger(DEST_DOMAIN, REMOTE_TM); + + feeManager = _deployFeeManagerProxy(owner); + wrapper = _deployWrapperProxy(owner, address(mockTm), address(feeManager)); + + vm.startPrank(owner); + feeManager.grantRole(Roles.CALLER_ADMIN_ROLE, owner); + feeManager.grantRole(Roles.SIGNER_ADMIN_ROLE, owner); + feeManager.grantRole(Roles.ALLOWED_CALLER_ROLE, address(wrapper)); + feeManager.setTrustedQuoteSigner(vm.addr(TRUSTED_SIGNER_PK), true); + vm.stopPrank(); + + burnToken = new MockBurnToken(); + burnToken.mint(user, 1000e6); + + mockFeeToken = new MockFeeToken(); + mockFeeToken.mint(user, 1000e18); + + vm.startPrank(user); + burnToken.approve(address(wrapper), type(uint256).max); + mockFeeToken.approve(address(wrapper), type(uint256).max); + vm.stopPrank(); + } + + // ─────────────── initialize ─────────────── + + function test_initialize_setsOwner() public view { + assertEq(wrapper.owner(), owner); + } + + function test_initialize_setsTokenMessenger() public view { + assertEq(wrapper.tokenMessenger(), address(mockTm)); + } + + function test_initialize_setsFeeManager() public view { + assertEq(wrapper.feeManager(), address(feeManager)); + } + + function test_initialize_revertsWhenCalledTwice() public { + vm.expectRevert(Initializable.InvalidInitialization.selector); + wrapper.initialize(owner); + } + + function test_constructor_revertsOnZeroTokenMessenger() public { + vm.expectRevert(ITokenMessengerWithFees.ZeroTokenMessengerAddress.selector); + new TokenMessengerWithFees(address(0), address(feeManager)); + } + + function test_constructor_revertsOnZeroFeeManager() public { + vm.expectRevert(ITokenMessengerWithFees.ZeroFeeManagerAddress.selector); + new TokenMessengerWithFees(address(mockTm), address(0)); + } + + function test_feeManager_feeTaker_returnsCorrectAddress() public view { + assertEq(feeManager.feeTaker(), address(mockFeeTaker)); + } + + function test_feeManager_usdcBlacklist_returnsCorrectAddress() public view { + assertEq(feeManager.usdcBlacklist(), address(mockUsdcBlacklist)); + } + + function test_implementation_initializeRevertsWhenCalledDirectly() public { + TokenMessengerWithFees impl = new TokenMessengerWithFees(address(mockTm), address(feeManager)); + vm.expectRevert(Initializable.InvalidInitialization.selector); + impl.initialize(owner); + } + + // ─────────────── depositForBurnWithFees ─────────────── + + function test_depositForBurnWithFees_noFeeItems_delegatesToTokenMessenger() public { + bytes memory signedQuote = _emptySignedQuote(); + + vm.prank(user); + wrapper.depositForBurnWithFees( + BURN_AMOUNT, DEST_DOMAIN, MINT_RECIPIENT, address(burnToken), DEST_CALLER, _userClaim(signedQuote) + ); + + assertEq(mockTm.lastAmount(), BURN_AMOUNT); + assertEq(mockTm.lastDestinationDomain(), DEST_DOMAIN); + assertEq(mockTm.lastMintRecipient(), MINT_RECIPIENT); + assertEq(mockTm.lastBurnToken(), address(burnToken)); + assertEq(mockTm.lastDestinationCaller(), DEST_CALLER); + assertEq(mockTm.lastMaxFee(), 0); + assertEq(mockTm.lastMinFinalityThreshold(), 2000); + assertFalse(mockTm.lastCallWasHook()); + } + + function test_depositForBurnWithFees_decodedQuote_matchesWireDecode() public { + bytes memory signedQuote = _emptySignedQuote(); + IFeeManager.Quote memory decoded = feeManager.decodeQuote(signedQuote); + + vm.prank(user); + wrapper.depositForBurnWithFees( + BURN_AMOUNT, DEST_DOMAIN, MINT_RECIPIENT, address(burnToken), DEST_CALLER, _userDecodedClaim(decoded) + ); + + assertEq(mockTm.lastAmount(), BURN_AMOUNT); + assertEq(mockTm.lastMinFinalityThreshold(), 2000); + assertFalse(mockTm.lastCallWasHook()); + } + + function test_depositForBurnWithFees_decodedQuote_skipsWrapperDecodeQuote() public { + bytes memory signedQuote = _emptySignedQuote(); + IFeeManager.Quote memory decoded = feeManager.decodeQuote(signedQuote); + + vm.expectCall(address(feeManager), abi.encodeWithSelector(IFeeManager.decodeQuote.selector, signedQuote), 0); + + vm.prank(user); + wrapper.depositForBurnWithFees( + BURN_AMOUNT, DEST_DOMAIN, MINT_RECIPIENT, address(burnToken), DEST_CALLER, _userDecodedClaim(decoded) + ); + } + + function test_depositForBurnWithFees_preFinalityQuote_setsConfirmedThreshold() public { + bytes memory signedQuote = _preFinOnlyQuote(); + + vm.deal(user, 1 ether); + vm.prank(user); + wrapper.depositForBurnWithFees{value: 100}( + BURN_AMOUNT, DEST_DOMAIN, MINT_RECIPIENT, address(burnToken), DEST_CALLER, _userClaim(signedQuote) + ); + + assertEq(mockTm.lastMinFinalityThreshold(), 1000); + assertFalse(mockTm.lastCallWasHook()); + } + + function test_depositForBurnWithFees_forwardQuote_autoConstructsHookData() public { + bytes memory signedQuote = _forwardOnlyQuote(); + + vm.deal(user, 1 ether); + vm.prank(user); + wrapper.depositForBurnWithFees{value: 900_000}( + BURN_AMOUNT, DEST_DOMAIN, MINT_RECIPIENT, address(burnToken), DEST_CALLER, _userClaim(signedQuote) + ); + + assertTrue(mockTm.lastCallWasHook()); + assertEq(mockTm.lastHookData(), _forwardHookData()); + assertEq(mockTm.lastMinFinalityThreshold(), 2000); + } + + function test_depositForBurnWithFees_fullQuote_autoConstructsHookDataAndFastFinality() public { + bytes memory signedQuote = _fullQuote(); + + vm.deal(user, 1 ether); + vm.prank(user); + wrapper.depositForBurnWithFees{value: 900_100}( + BURN_AMOUNT, DEST_DOMAIN, MINT_RECIPIENT, address(burnToken), DEST_CALLER, _userClaim(signedQuote) + ); + + assertTrue(mockTm.lastCallWasHook()); + assertEq(mockTm.lastHookData(), _forwardHookData()); + assertEq(mockTm.lastMinFinalityThreshold(), 1000); + } + + function test_depositForBurnWithFees_transfersBurnTokens() public { + bytes memory signedQuote = _emptySignedQuote(); + uint256 userBefore = burnToken.balanceOf(user); + + vm.prank(user); + wrapper.depositForBurnWithFees( + BURN_AMOUNT, DEST_DOMAIN, MINT_RECIPIENT, address(burnToken), DEST_CALLER, _userClaim(signedQuote) + ); + + assertEq(burnToken.balanceOf(user), userBefore - BURN_AMOUNT); + assertEq(burnToken.balanceOf(address(mockTm)), BURN_AMOUNT); + } + + function test_depositForBurnWithFees_forwardsNativeFees() public { + IFeeManager.QuoteItem[] memory items = new IFeeManager.QuoteItem[](1); + items[0] = IFeeManager.QuoteItem({ + quoteType: QuoteTypes.PRE_FINALITY, quoteArgsHash: _preFinArgsHash(), amount: 0.1 ether + }); + bytes memory signedQuote = _signQuote(items, block.timestamp + 300, address(0)); + + vm.deal(user, 1 ether); + + vm.prank(user); + wrapper.depositForBurnWithFees{value: 0.1 ether}( + BURN_AMOUNT, DEST_DOMAIN, MINT_RECIPIENT, address(burnToken), DEST_CALLER, _userClaim(signedQuote) + ); + + assertEq(address(feeManager).balance, 0, "FeeManager should not hold funds"); + assertEq(address(wrapper).balance, 0, "wrapper should not hold funds"); + assertEq(address(mockFeeTaker).balance, 0.1 ether, "FeeTaker should receive native fees"); + } + + function test_depositForBurnWithFees_revertsForUnsetRemoteTokenMessenger() public { + bytes memory signedQuote = _emptySignedQuote(); + uint32 unknownDomain = 999; + + vm.prank(user); + vm.expectRevert( + abi.encodeWithSelector(ITokenMessengerWithFees.RemoteTokenMessengerNotSet.selector, unknownDomain) + ); + wrapper.depositForBurnWithFees( + BURN_AMOUNT, unknownDomain, MINT_RECIPIENT, address(burnToken), DEST_CALLER, _userClaim(signedQuote) + ); + } + + function test_depositForBurnWithFees_emitsFeeCollected() public { + IFeeManager.QuoteItem[] memory items = new IFeeManager.QuoteItem[](1); + items[0] = + IFeeManager.QuoteItem({quoteType: QuoteTypes.PRE_FINALITY, quoteArgsHash: _preFinArgsHash(), amount: 0}); + bytes memory signedQuote = _signQuote(items, block.timestamp + 300, address(0)); + + vm.recordLogs(); + + vm.prank(user); + wrapper.depositForBurnWithFees( + BURN_AMOUNT, DEST_DOMAIN, MINT_RECIPIENT, address(burnToken), DEST_CALLER, _userClaim(signedQuote) + ); + + Vm.Log[] memory entries = vm.getRecordedLogs(); + bytes32 feeCollectedSig = + keccak256("FeeCollected(address,bytes32,string,string[],address,address,address,uint256,uint256)"); + bool found = false; + for (uint256 i = 0; i < entries.length; ++i) { + if (entries[i].topics[0] == feeCollectedSig) { + (string memory quoteType,,,,,,) = + abi.decode(entries[i].data, (string, string[], address, address, address, uint256, uint256)); + if (keccak256(bytes(quoteType)) == keccak256(bytes(QuoteTypes.PRE_FINALITY))) { + assertEq(entries[i].topics[1], bytes32(uint256(uint160(address(wrapper))))); + found = true; + break; + } + } + } + assertTrue(found, "FeeCollected(PRE_FINALITY) event not emitted"); + } + + // ─────────────── depositForBurnWithHookAndFees ─────────────── + + function test_depositForBurnWithHookAndFees_forwardQuote_delegatesWithHook() public { + bytes memory signedQuote = _forwardOnlyQuote(); + bytes memory hookData = _forwardHookData(); + + vm.deal(user, 1 ether); + vm.prank(user); + wrapper.depositForBurnWithHookAndFees{value: 900_000}( + BURN_AMOUNT, DEST_DOMAIN, MINT_RECIPIENT, address(burnToken), DEST_CALLER, hookData, _userClaim(signedQuote) + ); + + assertTrue(mockTm.lastCallWasHook()); + assertEq(mockTm.lastAmount(), BURN_AMOUNT); + assertEq(mockTm.lastMinFinalityThreshold(), 2000); + assertEq(mockTm.lastHookData(), hookData); + } + + function test_depositForBurnWithHookAndFees_fullQuote_setsConfirmedThresholdAndHook() public { + bytes memory signedQuote = _fullQuote(); + bytes memory hookData = _forwardHookData(); + + vm.deal(user, 1 ether); + vm.prank(user); + wrapper.depositForBurnWithHookAndFees{value: 900_100}( + BURN_AMOUNT, DEST_DOMAIN, MINT_RECIPIENT, address(burnToken), DEST_CALLER, hookData, _userClaim(signedQuote) + ); + + assertTrue(mockTm.lastCallWasHook()); + assertEq(mockTm.lastMinFinalityThreshold(), 1000); + assertEq(mockTm.lastMaxFee(), 0); + } + + function test_depositForBurnWithHookAndFees_forwardQuote_nonForwardHook_reverts() public { + bytes memory signedQuote = _forwardOnlyQuote(); + bytes memory hookData = hex"01"; + + vm.prank(user); + vm.expectRevert(ITokenMessengerWithFees.ForwardFeeWithoutHook.selector); + wrapper.depositForBurnWithHookAndFees( + BURN_AMOUNT, DEST_DOMAIN, MINT_RECIPIENT, address(burnToken), DEST_CALLER, hookData, _userClaim(signedQuote) + ); + } + + function test_depositForBurnWithHookAndFees_forwardHook_emptyQuote_reverts() public { + bytes memory signedQuote = _emptySignedQuote(); + bytes memory hookData = _forwardHookData(); + + vm.prank(user); + vm.expectRevert(ITokenMessengerWithFees.ForwardHookWithoutFee.selector); + wrapper.depositForBurnWithHookAndFees( + BURN_AMOUNT, DEST_DOMAIN, MINT_RECIPIENT, address(burnToken), DEST_CALLER, hookData, _userClaim(signedQuote) + ); + } + + function test_depositForBurnWithHookAndFees_forwardHook_preFinOnlyQuote_reverts() public { + bytes memory signedQuote = _preFinOnlyQuote(); + bytes memory hookData = _forwardHookData(); + + vm.prank(user); + vm.expectRevert(ITokenMessengerWithFees.ForwardHookWithoutFee.selector); + wrapper.depositForBurnWithHookAndFees( + BURN_AMOUNT, DEST_DOMAIN, MINT_RECIPIENT, address(burnToken), DEST_CALLER, hookData, _userClaim(signedQuote) + ); + } + + function test_depositForBurnWithHookAndFees_decodedQuote_forwardHook_emptyQuote_reverts() public { + IFeeManager.Quote memory decoded = feeManager.decodeQuote(_emptySignedQuote()); + bytes memory hookData = _forwardHookData(); + + vm.prank(user); + vm.expectRevert(ITokenMessengerWithFees.ForwardHookWithoutFee.selector); + wrapper.depositForBurnWithHookAndFees( + BURN_AMOUNT, + DEST_DOMAIN, + MINT_RECIPIENT, + address(burnToken), + DEST_CALLER, + hookData, + _userDecodedClaim(decoded) + ); + } + + function test_depositForBurnWithHookAndFees_decodedQuote_forwardHook_preFinOnlyQuote_reverts() public { + IFeeManager.Quote memory decoded = feeManager.decodeQuote(_preFinOnlyQuote()); + bytes memory hookData = _forwardHookData(); + + vm.prank(user); + vm.expectRevert(ITokenMessengerWithFees.ForwardHookWithoutFee.selector); + wrapper.depositForBurnWithHookAndFees( + BURN_AMOUNT, + DEST_DOMAIN, + MINT_RECIPIENT, + address(burnToken), + DEST_CALLER, + hookData, + _userDecodedClaim(decoded) + ); + } + + function test_depositForBurnWithHookAndFees_emptyHookData_reverts() public { + bytes memory signedQuote = _emptySignedQuote(); + + vm.prank(user); + vm.expectRevert(ITokenMessengerWithFees.EmptyHookData.selector); + wrapper.depositForBurnWithHookAndFees( + BURN_AMOUNT, DEST_DOMAIN, MINT_RECIPIENT, address(burnToken), DEST_CALLER, "", _userClaim(signedQuote) + ); + } + + function test_depositForBurnWithHookAndFees_decodedQuote_emptyHookData_reverts() public { + IFeeManager.Quote memory decoded = feeManager.decodeQuote(_emptySignedQuote()); + + vm.prank(user); + vm.expectRevert(ITokenMessengerWithFees.EmptyHookData.selector); + wrapper.depositForBurnWithHookAndFees( + BURN_AMOUNT, DEST_DOMAIN, MINT_RECIPIENT, address(burnToken), DEST_CALLER, "", _userDecodedClaim(decoded) + ); + } + + function test_depositForBurnWithHookAndFees_noFeeItems_delegatesToTokenMessengerWithHook() public { + bytes memory signedQuote = _emptySignedQuote(); + bytes memory hookData = hex"01"; + + vm.prank(user); + wrapper.depositForBurnWithHookAndFees( + BURN_AMOUNT, DEST_DOMAIN, MINT_RECIPIENT, address(burnToken), DEST_CALLER, hookData, _userClaim(signedQuote) + ); + + assertTrue(mockTm.lastCallWasHook()); + assertEq(mockTm.lastMinFinalityThreshold(), 2000); + } + + function test_depositForBurnWithHookAndFees_decodedQuote_matchesWireDecode() public { + bytes memory signedQuote = _emptySignedQuote(); + bytes memory hookData = hex"01"; + IFeeManager.Quote memory decoded = feeManager.decodeQuote(signedQuote); + + vm.prank(user); + wrapper.depositForBurnWithHookAndFees( + BURN_AMOUNT, + DEST_DOMAIN, + MINT_RECIPIENT, + address(burnToken), + DEST_CALLER, + hookData, + _userDecodedClaim(decoded) + ); + + assertTrue(mockTm.lastCallWasHook()); + assertEq(mockTm.lastMinFinalityThreshold(), 2000); + } + + function test_depositForBurnWithHookAndFees_decodedQuote_skipsWrapperDecodeQuote() public { + bytes memory signedQuote = _emptySignedQuote(); + bytes memory hookData = hex"01"; + IFeeManager.Quote memory decoded = feeManager.decodeQuote(signedQuote); + + vm.expectCall(address(feeManager), abi.encodeWithSelector(IFeeManager.decodeQuote.selector, signedQuote), 0); + + vm.prank(user); + wrapper.depositForBurnWithHookAndFees( + BURN_AMOUNT, + DEST_DOMAIN, + MINT_RECIPIENT, + address(burnToken), + DEST_CALLER, + hookData, + _userDecodedClaim(decoded) + ); + } + + // ───────────────── getFee ───────────────── + + function test_getFee_signedQuote_returnsSupportedTypeFee() public view { + bytes memory signedQuote = _preFinPlusSettlementQuoteErc20(); + + (uint256 totalFee, address feeToken) = wrapper.getFee(signedQuote); + + assertEq(totalFee, 5e18, "getter should exclude unrequested quote items"); + assertEq(feeToken, address(mockFeeToken)); + } + + function test_getFee_decodedQuote_matchesSignedQuote() public view { + bytes memory signedQuote = _preFinPlusSettlementQuoteErc20(); + IFeeManager.Quote memory decoded = feeManager.decodeQuote(signedQuote); + + (uint256 totalFee, address feeToken) = wrapper.getFee(decoded); + + assertEq(totalFee, 5e18, "getter should exclude unrequested quote items"); + assertEq(feeToken, address(mockFeeToken)); + } + + function test_getFee_sumsForwardAndPreFinalityByType() public view { + bytes memory signedQuote = _fullQuoteErc20(); + + (uint256 totalFee, address feeToken) = wrapper.getFee(signedQuote); + + assertEq(totalFee, 8e18); + assertEq(feeToken, address(mockFeeToken)); + } + + function test_getFee_sumsSupportedTypeEvenWhenQuoteArgsWouldFailCollection() public view { + IFeeManager.QuoteItem[] memory items = new IFeeManager.QuoteItem[](2); + items[0] = IFeeManager.QuoteItem({ + quoteType: QuoteTypes.FORWARD, quoteArgsHash: keccak256("wrong forward args"), amount: 3e18 + }); + items[1] = IFeeManager.QuoteItem({quoteType: "SETTLEMENT", quoteArgsHash: bytes32(0), amount: 5e18}); + bytes memory signedQuote = _signQuote(items, block.timestamp + 300, address(mockFeeToken)); + + (uint256 totalFee, address feeToken) = wrapper.getFee(signedQuote); + + assertEq(totalFee, 3e18, "getter should sum by supported type only"); + assertEq(feeToken, address(mockFeeToken)); + } + + function test_getFee_decodedQuote_sumsSupportedTypeEvenWhenQuoteArgsWouldFailCollection() public view { + IFeeManager.QuoteItem[] memory items = new IFeeManager.QuoteItem[](1); + items[0] = IFeeManager.QuoteItem({ + quoteType: QuoteTypes.PRE_FINALITY, quoteArgsHash: keccak256("wrong pre-finality args"), amount: 5e18 + }); + bytes memory signedQuote = _signQuote(items, block.timestamp + 300, address(mockFeeToken)); + IFeeManager.Quote memory decoded = feeManager.decodeQuote(signedQuote); + + (uint256 totalFee, address feeToken) = wrapper.getFee(decoded); + + assertEq(totalFee, 5e18, "getter should sum by supported type only"); + assertEq(feeToken, address(mockFeeToken)); + } + + function test_getFee_emptyBytes_returnsZero() public view { + // Mirrors FeeManager.{sumFees, getQuoteTotal, getQuoteItem} empty-bytes + // behavior so off-chain callers can use a single "no quote -> no fee" + // pattern across all read APIs. + (uint256 totalFee, address feeToken) = wrapper.getFee(""); + assertEq(totalFee, 0); + assertEq(feeToken, address(0)); + } + + // ───────────────── Correlation hash ───────────────── + + function test_correlationHash_emptyQuote_usesSlowFinalityAndNoHook() public { + bytes memory signedQuote = _emptySignedQuote(); + + // Quote is decoded in-process (`SignedQuoteLib`); no external `FeeManager.decodeQuote`. + // `collectFeesWithQuote` uses the decoded-`Quote` overload; calldata is long (dynamic arrays + struct). + vm.expectCall(address(feeManager), bytes("")); + + vm.prank(user); + wrapper.depositForBurnWithFees( + BURN_AMOUNT, DEST_DOMAIN, MINT_RECIPIENT, address(burnToken), DEST_CALLER, _userClaim(signedQuote) + ); + } + + function test_correlationHash_matchesExpectedFormat() public { + IFeeManager.QuoteItem[] memory items = new IFeeManager.QuoteItem[](1); + items[0] = + IFeeManager.QuoteItem({quoteType: QuoteTypes.PRE_FINALITY, quoteArgsHash: _preFinArgsHash(), amount: 0}); + bytes memory signedQuote = _signQuote(items, block.timestamp + 300, address(0)); + + bytes memory messageBody = BurnMessageV2Lib.formatMessage( + BODY_VERSION, + bytes32(uint256(uint160(address(burnToken)))), + MINT_RECIPIENT, + BURN_AMOUNT, + bytes32(uint256(uint160(address(wrapper)))), + "" + ); + bytes32 expectedHash = MessageV2Lib.messageHash( + SOURCE_DOMAIN, + DEST_DOMAIN, + bytes32(uint256(uint160(address(mockTm)))), + REMOTE_TM, + DEST_CALLER, + 1000, + messageBody + ); + + vm.recordLogs(); + + vm.prank(user); + wrapper.depositForBurnWithFees( + BURN_AMOUNT, DEST_DOMAIN, MINT_RECIPIENT, address(burnToken), DEST_CALLER, _userClaim(signedQuote) + ); + + Vm.Log[] memory entries = vm.getRecordedLogs(); + bytes32 feeCollectedSig = + keccak256("FeeCollected(address,bytes32,string,string[],address,address,address,uint256,uint256)"); + bool found = false; + for (uint256 i = 0; i < entries.length; ++i) { + if (entries[i].topics[0] == feeCollectedSig) { + assertEq(entries[i].topics[2], expectedHash, "correlationHash mismatch"); + found = true; + break; + } + } + assertTrue(found, "FeeCollected event not emitted"); + } + + function test_correlationHash_withHook_includesHookData() public { + bytes memory hookData = _forwardHookData(); + IFeeManager.QuoteItem[] memory items = new IFeeManager.QuoteItem[](1); + items[0] = IFeeManager.QuoteItem({ + quoteType: QuoteTypes.FORWARD, quoteArgsHash: _forwardArgsHash(hookData), amount: 0 + }); + bytes memory signedQuote = _signQuote(items, block.timestamp + 300, address(0)); + + bytes memory messageBody = BurnMessageV2Lib.formatMessage( + BODY_VERSION, + bytes32(uint256(uint160(address(burnToken)))), + MINT_RECIPIENT, + BURN_AMOUNT, + bytes32(uint256(uint160(address(wrapper)))), + hookData + ); + bytes32 expectedHash = MessageV2Lib.messageHash( + SOURCE_DOMAIN, + DEST_DOMAIN, + bytes32(uint256(uint160(address(mockTm)))), + REMOTE_TM, + DEST_CALLER, + 2000, + messageBody + ); + + vm.recordLogs(); + + vm.prank(user); + wrapper.depositForBurnWithHookAndFees( + BURN_AMOUNT, DEST_DOMAIN, MINT_RECIPIENT, address(burnToken), DEST_CALLER, hookData, _userClaim(signedQuote) + ); + + Vm.Log[] memory entries = vm.getRecordedLogs(); + bytes32 feeCollectedSig = + keccak256("FeeCollected(address,bytes32,string,string[],address,address,address,uint256,uint256)"); + bool found = false; + for (uint256 i = 0; i < entries.length; ++i) { + if (entries[i].topics[0] == feeCollectedSig) { + assertEq(entries[i].topics[2], expectedHash, "correlationHash mismatch"); + found = true; + break; + } + } + assertTrue(found, "FeeCollected event not emitted"); + } + + function test_correlationHash_autoForwardHook_matchesExplicitHook() public { + bytes memory hookData = _forwardHookData(); + IFeeManager.QuoteItem[] memory items = new IFeeManager.QuoteItem[](1); + items[0] = IFeeManager.QuoteItem({ + quoteType: QuoteTypes.FORWARD, quoteArgsHash: _forwardArgsHash(hookData), amount: 0 + }); + bytes memory signedQuote = _signQuote(items, block.timestamp + 300, address(0)); + + bytes memory messageBody = BurnMessageV2Lib.formatMessage( + BODY_VERSION, + bytes32(uint256(uint160(address(burnToken)))), + MINT_RECIPIENT, + BURN_AMOUNT, + bytes32(uint256(uint160(address(wrapper)))), + hookData + ); + bytes32 expectedHash = MessageV2Lib.messageHash( + SOURCE_DOMAIN, + DEST_DOMAIN, + bytes32(uint256(uint160(address(mockTm)))), + REMOTE_TM, + DEST_CALLER, + 2000, + messageBody + ); + + vm.recordLogs(); + + vm.prank(user); + wrapper.depositForBurnWithFees( + BURN_AMOUNT, DEST_DOMAIN, MINT_RECIPIENT, address(burnToken), DEST_CALLER, _userClaim(signedQuote) + ); + + Vm.Log[] memory entries = vm.getRecordedLogs(); + bytes32 feeCollectedSig = + keccak256("FeeCollected(address,bytes32,string,string[],address,address,address,uint256,uint256)"); + bool found = false; + for (uint256 i = 0; i < entries.length; ++i) { + if (entries[i].topics[0] == feeCollectedSig) { + assertEq(entries[i].topics[2], expectedHash, "correlationHash mismatch"); + found = true; + break; + } + } + assertTrue(found, "FeeCollected event not emitted"); + } + + // ─────────────── ERC20 fee collection ─────────────── + + function test_depositForBurnWithFees_erc20Fee_pullsFromUserAndForwardsToFeeTaker() public { + bytes memory signedQuote = _preFinOnlyQuoteErc20(); + + uint256 userFeeBefore = mockFeeToken.balanceOf(user); + + vm.prank(user); + wrapper.depositForBurnWithFees( + BURN_AMOUNT, DEST_DOMAIN, MINT_RECIPIENT, address(burnToken), DEST_CALLER, _userClaim(signedQuote) + ); + + assertEq(mockFeeToken.balanceOf(user), userFeeBefore - 5e18, "user should pay ERC20 fee"); + assertEq(mockFeeToken.balanceOf(address(wrapper)), 0, "wrapper should not hold fee tokens"); + assertEq(mockFeeToken.balanceOf(address(feeManager)), 0, "FeeManager should not hold fee tokens"); + assertEq(mockFeeToken.balanceOf(address(mockFeeTaker)), 5e18, "FeeTaker should receive ERC20 fees"); + assertEq( + mockFeeToken.allowance(address(wrapper), address(feeManager)), + 0, + "wrapper should have zero residual allowance to FeeManager" + ); + assertEq( + mockFeeToken.allowance(address(feeManager), address(mockFeeTaker)), + 0, + "FeeManager should have zero residual allowance to FeeTaker" + ); + } + + function test_depositForBurnWithFees_erc20Fee_stillTransfersBurnTokens() public { + bytes memory signedQuote = _preFinOnlyQuoteErc20(); + + uint256 userBurnBefore = burnToken.balanceOf(user); + + vm.prank(user); + wrapper.depositForBurnWithFees( + BURN_AMOUNT, DEST_DOMAIN, MINT_RECIPIENT, address(burnToken), DEST_CALLER, _userClaim(signedQuote) + ); + + assertEq(burnToken.balanceOf(user), userBurnBefore - BURN_AMOUNT); + assertEq(burnToken.balanceOf(address(mockTm)), BURN_AMOUNT); + } + + function test_depositForBurnWithFees_erc20Fee_pullsOnlyMatchedFeeWhenNotAllItemsMatch() public { + // Quote total 10e18 (PRE_FINALITY + SETTLEMENT); wrapper only collects PRE_FINALITY (5e18). + bytes memory signedQuote = _preFinPlusSettlementQuoteErc20(); + + uint256 userFeeBefore = mockFeeToken.balanceOf(user); + + vm.prank(user); + wrapper.depositForBurnWithFees( + BURN_AMOUNT, DEST_DOMAIN, MINT_RECIPIENT, address(burnToken), DEST_CALLER, _userClaim(signedQuote) + ); + + assertEq(mockFeeToken.balanceOf(user), userFeeBefore - 5e18, "only matched PRE_FINALITY should be deducted"); + assertEq(mockFeeToken.balanceOf(address(mockFeeTaker)), 5e18, "FeeTaker should receive matched fees"); + assertEq(mockFeeToken.balanceOf(address(wrapper)), 0, "wrapper should not retain fees"); + } + + function test_depositForBurnWithFees_erc20Fee_succeedsWhenAllowanceCoversMatchedFeeButNotQuoteTotal() public { + // Quote total 10e18 (PRE_FINALITY + SETTLEMENT); supported matched fee is 5e18. + // Documents that callers only need to approve the matched fee, not the full quote total. + bytes memory signedQuote = _preFinPlusSettlementQuoteErc20(); + + vm.prank(user); + mockFeeToken.approve(address(wrapper), 6e18); + + uint256 userFeeBefore = mockFeeToken.balanceOf(user); + + vm.prank(user); + wrapper.depositForBurnWithFees( + BURN_AMOUNT, DEST_DOMAIN, MINT_RECIPIENT, address(burnToken), DEST_CALLER, _userClaim(signedQuote) + ); + + assertEq(mockFeeToken.balanceOf(user), userFeeBefore - 5e18, "user should pay only matched fee"); + assertEq(mockFeeToken.balanceOf(address(mockFeeTaker)), 5e18, "FeeTaker should receive matched fees"); + assertEq(mockFeeToken.balanceOf(address(wrapper)), 0, "wrapper should not retain fees"); + } + + function test_depositForBurnWithFees_erc20Fee_revertsWhenAllowanceBelowMatchedFee() public { + bytes memory signedQuote = _preFinOnlyQuoteErc20(); + + vm.prank(user); + mockFeeToken.approve(address(wrapper), 4e18); + + vm.prank(user); + vm.expectRevert(); + wrapper.depositForBurnWithFees( + BURN_AMOUNT, DEST_DOMAIN, MINT_RECIPIENT, address(burnToken), DEST_CALLER, _userClaim(signedQuote) + ); + } + + function test_depositForBurnWithFees_erc20Fee_revertsWhenPrecomputedTypeFeeHasWrongArgs() public { + IFeeManager.QuoteItem[] memory items = new IFeeManager.QuoteItem[](1); + items[0] = IFeeManager.QuoteItem({ + quoteType: QuoteTypes.PRE_FINALITY, quoteArgsHash: keccak256("wrong pre-finality args"), amount: 5e18 + }); + bytes memory signedQuote = _signQuote(items, block.timestamp + 300, address(mockFeeToken)); + + vm.prank(user); + mockFeeToken.approve(address(wrapper), 5e18); + + vm.prank(user); + vm.expectRevert( + abi.encodeWithSelector(IFeeManager.QuoteArgsMismatch.selector, QuoteTypes.PRE_FINALITY, _preFinArgsHash()) + ); + wrapper.depositForBurnWithFees( + BURN_AMOUNT, DEST_DOMAIN, MINT_RECIPIENT, address(burnToken), DEST_CALLER, _userClaim(signedQuote) + ); + } + + function test_depositForBurnWithFees_erc20Fee_revertsWhenNativeValueSent() public { + bytes memory signedQuote = _preFinOnlyQuoteErc20(); + + vm.deal(user, 1 ether); + vm.prank(user); + vm.expectRevert(abi.encodeWithSelector(FeeManager.IncorrectNativePayment.selector, 0, 0.1 ether)); + wrapper.depositForBurnWithFees{value: 0.1 ether}( + BURN_AMOUNT, DEST_DOMAIN, MINT_RECIPIENT, address(burnToken), DEST_CALLER, _userClaim(signedQuote) + ); + } + + function test_depositForBurnWithFees_erc20Fee_feeTokenSameAsBurnToken() public { + IFeeManager.QuoteItem[] memory items = new IFeeManager.QuoteItem[](1); + items[0] = + IFeeManager.QuoteItem({quoteType: QuoteTypes.PRE_FINALITY, quoteArgsHash: _preFinArgsHash(), amount: 1e6}); + bytes memory signedQuote = _signQuote(items, block.timestamp + 300, address(burnToken)); + + uint256 userBurnBefore = burnToken.balanceOf(user); + + vm.prank(user); + wrapper.depositForBurnWithFees( + BURN_AMOUNT, DEST_DOMAIN, MINT_RECIPIENT, address(burnToken), DEST_CALLER, _userClaim(signedQuote) + ); + + assertEq(burnToken.balanceOf(user), userBurnBefore - BURN_AMOUNT - 1e6, "user pays burn + fee from same token"); + assertEq(burnToken.balanceOf(address(mockTm)), BURN_AMOUNT, "TokenMessenger receives burn amount"); + assertEq(burnToken.balanceOf(address(mockFeeTaker)), 1e6, "FeeTaker receives fee"); + } + + function test_depositForBurnWithHookAndFees_erc20Fee_forwardsToFeeTaker() public { + IFeeManager.QuoteItem[] memory items = new IFeeManager.QuoteItem[](1); + bytes memory hookData = _forwardHookData(); + items[0] = IFeeManager.QuoteItem({ + quoteType: QuoteTypes.FORWARD, quoteArgsHash: _forwardArgsHash(hookData), amount: 3e18 + }); + bytes memory signedQuote = _signQuote(items, block.timestamp + 300, address(mockFeeToken)); + + uint256 userFeeBefore = mockFeeToken.balanceOf(user); + + vm.prank(user); + wrapper.depositForBurnWithHookAndFees( + BURN_AMOUNT, DEST_DOMAIN, MINT_RECIPIENT, address(burnToken), DEST_CALLER, hookData, _userClaim(signedQuote) + ); + + assertEq(mockFeeToken.balanceOf(user), userFeeBefore - 3e18, "user should pay ERC20 fee"); + assertEq(mockFeeToken.balanceOf(address(mockFeeTaker)), 3e18, "FeeTaker should receive ERC20 fees"); + assertTrue(mockTm.lastCallWasHook()); + } + + function test_depositForBurnWithHookAndFees_erc20Fee_pullsOnlyMatchedForwardFee() public { + // Quote contains FORWARD (matches) + an extra item type the wrapper + // never requests. Total = 8e18, matched = 3e18. + bytes memory hookData = _forwardHookData(); + IFeeManager.QuoteItem[] memory items = new IFeeManager.QuoteItem[](2); + items[0] = IFeeManager.QuoteItem({ + quoteType: QuoteTypes.FORWARD, quoteArgsHash: _forwardArgsHash(hookData), amount: 3e18 + }); + items[1] = IFeeManager.QuoteItem({quoteType: "SETTLEMENT", quoteArgsHash: bytes32(0), amount: 5e18}); + bytes memory signedQuote = _signQuote(items, block.timestamp + 300, address(mockFeeToken)); + + uint256 userFeeBefore = mockFeeToken.balanceOf(user); + + vm.prank(user); + wrapper.depositForBurnWithHookAndFees( + BURN_AMOUNT, DEST_DOMAIN, MINT_RECIPIENT, address(burnToken), DEST_CALLER, hookData, _userClaim(signedQuote) + ); + + assertEq(mockFeeToken.balanceOf(user), userFeeBefore - 3e18, "only matched FORWARD fee should be deducted"); + assertEq(mockFeeToken.balanceOf(address(mockFeeTaker)), 3e18, "FeeTaker should receive matched fees"); + assertEq(mockFeeToken.balanceOf(address(wrapper)), 0, "wrapper should not retain fees"); + assertTrue(mockTm.lastCallWasHook()); + } + + function test_depositForBurnWithFees_erc20Fee_emitsFeeCollected() public { + bytes memory signedQuote = _preFinOnlyQuoteErc20(); + + vm.recordLogs(); + + vm.prank(user); + wrapper.depositForBurnWithFees( + BURN_AMOUNT, DEST_DOMAIN, MINT_RECIPIENT, address(burnToken), DEST_CALLER, _userClaim(signedQuote) + ); + + Vm.Log[] memory entries = vm.getRecordedLogs(); + bytes32 feeCollectedSig = + keccak256("FeeCollected(address,bytes32,string,string[],address,address,address,uint256,uint256)"); + bool found = false; + for (uint256 i = 0; i < entries.length; ++i) { + if (entries[i].topics[0] == feeCollectedSig) { + (string memory quoteType,,,, address feeTokenEmitted, uint256 amount,) = + abi.decode(entries[i].data, (string, string[], address, address, address, uint256, uint256)); + if (keccak256(bytes(quoteType)) == keccak256(bytes(QuoteTypes.PRE_FINALITY))) { + assertEq(feeTokenEmitted, address(mockFeeToken), "feeToken should be ERC20"); + assertEq(amount, 5e18, "amount should match quote item"); + found = true; + break; + } + } + } + assertTrue(found, "FeeCollected(PRE_FINALITY) event not emitted for ERC20 fee"); + } + + // ───────────────── UUPS upgrade ───────────────── + + function test_upgrade_preservesState() public { + TokenMessengerWithFees newImpl = new TokenMessengerWithFees(address(mockTm), address(feeManager)); + vm.prank(owner); + wrapper.upgradeToAndCall(address(newImpl), ""); + + assertEq(wrapper.owner(), owner); + assertEq(wrapper.tokenMessenger(), address(mockTm)); + assertEq(wrapper.feeManager(), address(feeManager)); + } + + function test_upgrade_revertsForNonOwner() public { + address stranger = makeAddr("stranger"); + TokenMessengerWithFees newImpl = new TokenMessengerWithFees(address(mockTm), address(feeManager)); + + vm.prank(stranger); + vm.expectRevert(abi.encodeWithSelector(Ownable.OwnableUnauthorizedAccount.selector, stranger)); + wrapper.upgradeToAndCall(address(newImpl), ""); + } + + function test_renounceOwnership_reverts() public { + vm.prank(owner); + vm.expectRevert(TokenMessengerWithFees.RenounceDisabled.selector); + wrapper.renounceOwnership(); + + assertEq(wrapper.owner(), owner); + } + + // ───────────────── Denylist ───────────────── + + function test_depositForBurnWithFees_revertsWhenCallerDenylisted() public { + mockTm.setDenylisted(user, true); + bytes memory signedQuote = _emptySignedQuote(); + + vm.prank(user); + vm.expectRevert(abi.encodeWithSelector(ITokenMessengerWithFees.DenylistedCaller.selector, user)); + wrapper.depositForBurnWithFees( + BURN_AMOUNT, DEST_DOMAIN, MINT_RECIPIENT, address(burnToken), DEST_CALLER, _userClaim(signedQuote) + ); + } + + function test_depositForBurnWithHookAndFees_revertsWhenCallerDenylisted() public { + mockTm.setDenylisted(user, true); + bytes memory signedQuote = _forwardOnlyQuote(); + bytes memory hookData = _forwardHookData(); + + vm.prank(user); + vm.expectRevert(abi.encodeWithSelector(ITokenMessengerWithFees.DenylistedCaller.selector, user)); + wrapper.depositForBurnWithHookAndFees( + BURN_AMOUNT, DEST_DOMAIN, MINT_RECIPIENT, address(burnToken), DEST_CALLER, hookData, _userClaim(signedQuote) + ); + } + + function test_depositForBurnWithFees_succeedsWhenCallerNotDenylisted() public { + mockTm.setDenylisted(user, false); + bytes memory signedQuote = _emptySignedQuote(); + + vm.prank(user); + wrapper.depositForBurnWithFees( + BURN_AMOUNT, DEST_DOMAIN, MINT_RECIPIENT, address(burnToken), DEST_CALLER, _userClaim(signedQuote) + ); + + assertEq(mockTm.lastAmount(), BURN_AMOUNT); + } + + function test_depositForBurnWithHookAndFees_succeedsWhenCallerNotDenylisted() public { + mockTm.setDenylisted(user, false); + bytes memory signedQuote = _forwardOnlyQuote(); + bytes memory hookData = _forwardHookData(); + + vm.deal(user, 1 ether); + vm.prank(user); + wrapper.depositForBurnWithHookAndFees{value: 900_000}( + BURN_AMOUNT, DEST_DOMAIN, MINT_RECIPIENT, address(burnToken), DEST_CALLER, hookData, _userClaim(signedQuote) + ); + + assertTrue(mockTm.lastCallWasHook()); + } + + function test_depositForBurnWithFees_denylistChecksCallerNotOrigin() public { + address intermediary = makeAddr("intermediary"); + + // Denylist the intermediary, not the EOA (tx.origin) + mockTm.setDenylisted(intermediary, true); + + bytes memory signedQuote = _emptySignedQuote(); + + vm.prank(intermediary); + vm.expectRevert(abi.encodeWithSelector(ITokenMessengerWithFees.DenylistedCaller.selector, intermediary)); + wrapper.depositForBurnWithFees( + BURN_AMOUNT, DEST_DOMAIN, MINT_RECIPIENT, address(burnToken), DEST_CALLER, _userClaim(signedQuote) + ); + } + + /// @dev Wrapper only applies `TokenMessengerV2.isDenylisted` to `msg.sender`, not `tx.origin`. + /// `TokenMessengerV2.depositForBurn` still enforces `tx.origin` (mirrored in the mock). + function test_depositForBurnWithFees_revertsWhenOnlyTxOriginDenylistedOnTokenMessenger() public { + address cleanSender = makeAddr("cleanSender"); + address denylistedOrigin = makeAddr("denylistedOrigin"); + + mockTm.setDenylisted(denylistedOrigin, true); + + burnToken.mint(cleanSender, BURN_AMOUNT); + vm.prank(cleanSender); + burnToken.approve(address(wrapper), type(uint256).max); + + bytes memory signedQuote = _emptySignedQuote(); + + vm.prank(cleanSender, denylistedOrigin); + vm.expectRevert(abi.encodeWithSelector(ITokenMessengerWithFees.DenylistedCaller.selector, denylistedOrigin)); + wrapper.depositForBurnWithFees( + BURN_AMOUNT, DEST_DOMAIN, MINT_RECIPIENT, address(burnToken), DEST_CALLER, _userClaim(signedQuote) + ); + } + + // ───────────────── USDC blocklist (via FeeManager) ───────────────── + + function test_depositForBurnWithFees_revertsWhenSenderUsdcBlacklisted() public { + address intermediary = makeAddr("usdcBlockedIntermediary"); + mockUsdcBlacklist.setBlacklisted(intermediary, true); + burnToken.mint(intermediary, BURN_AMOUNT); + vm.prank(intermediary); + burnToken.approve(address(wrapper), type(uint256).max); + + bytes memory signedQuote = _emptySignedQuote(); + + vm.prank(intermediary); + vm.expectRevert(abi.encodeWithSelector(ITokenMessengerWithFees.DenylistedCaller.selector, intermediary)); + wrapper.depositForBurnWithFees( + BURN_AMOUNT, DEST_DOMAIN, MINT_RECIPIENT, address(burnToken), DEST_CALLER, _userClaim(signedQuote) + ); + } + + function test_depositForBurnWithHookAndFees_revertsWhenSenderUsdcBlacklisted() public { + address intermediary = makeAddr("usdcBlockedIntermediaryHook"); + mockUsdcBlacklist.setBlacklisted(intermediary, true); + burnToken.mint(intermediary, BURN_AMOUNT); + vm.prank(intermediary); + burnToken.approve(address(wrapper), type(uint256).max); + + bytes memory signedQuote = _forwardOnlyQuote(); + bytes memory hookData = _forwardHookData(); + + vm.prank(intermediary); + vm.expectRevert(abi.encodeWithSelector(ITokenMessengerWithFees.DenylistedCaller.selector, intermediary)); + wrapper.depositForBurnWithHookAndFees( + BURN_AMOUNT, DEST_DOMAIN, MINT_RECIPIENT, address(burnToken), DEST_CALLER, hookData, _userClaim(signedQuote) + ); + } + + /// @dev EOA path skips wrapper `FeeManager.isDenylisted`; burn `transferFrom` still enforces USDC blocklist. + function test_depositForBurnWithFees_eoaBlacklisted_revertsOnBurnTokenTransferFrom() public { + MockUsdcAwareBurnToken usdcLike = new MockUsdcAwareBurnToken(address(mockUsdcBlacklist)); + mockUsdcBlacklist.setBlacklisted(user, true); + usdcLike.mint(user, BURN_AMOUNT); + vm.prank(user); + usdcLike.approve(address(wrapper), type(uint256).max); + + bytes memory signedQuote = _emptySignedQuote(); + // Use a fresh non-blacklisted refund target so the FeeManager refund-address screen + // doesn't pre-empt the burn-token-layer revert this test is designed to verify. + address payable cleanRefund = payable(makeAddr("cleanRefund")); + + vm.prank(user, user); + vm.expectRevert(bytes("MockUsdcAwareBurnToken: blacklisted sender")); + wrapper.depositForBurnWithFees( + BURN_AMOUNT, DEST_DOMAIN, MINT_RECIPIENT, address(usdcLike), DEST_CALLER, _claim(signedQuote, cleanRefund) + ); + } + + // ───────────── Refund-address attribution & screening ───────────── + + /// @dev Wrapper applies no defaulting to `refundAddress`. A caller-supplied `address(0)` is + /// forwarded verbatim and surfaces as `address(0)` in the FeeCollected event. + function test_depositForBurnWithFees_zeroRefundAddress_forwardsZeroVerbatim() public { + IFeeManager.QuoteItem[] memory items = new IFeeManager.QuoteItem[](1); + items[0] = + IFeeManager.QuoteItem({quoteType: QuoteTypes.PRE_FINALITY, quoteArgsHash: _preFinArgsHash(), amount: 0}); + bytes memory signedQuote = _signQuote(items, block.timestamp + 1 hours, address(0)); + + vm.recordLogs(); + vm.prank(user); + wrapper.depositForBurnWithFees( + BURN_AMOUNT, + DEST_DOMAIN, + MINT_RECIPIENT, + address(burnToken), + DEST_CALLER, + _claim(signedQuote, payable(address(0))) + ); + + Vm.Log[] memory entries = vm.getRecordedLogs(); + bytes32 sig = keccak256("FeeCollected(address,bytes32,string,string[],address,address,address,uint256,uint256)"); + bool found = false; + for (uint256 i = 0; i < entries.length; ++i) { + if (entries[i].topics.length > 0 && entries[i].topics[0] == sig) { + (,, address payable refundAddress,,,,) = + abi.decode(entries[i].data, (string, string[], address, address, address, uint256, uint256)); + assertEq(refundAddress, payable(address(0)), "wrapper must not default zero refundAddress"); + found = true; + } + } + assertTrue(found, "FeeCollected event not emitted"); + } + + /// @dev Explicit non-zero refundAddress is forwarded verbatim to FeeManager and emitted in the event. + function test_depositForBurnWithFees_nonZeroRefundAddress_forwardsVerbatim() public { + IFeeManager.QuoteItem[] memory items = new IFeeManager.QuoteItem[](1); + items[0] = + IFeeManager.QuoteItem({quoteType: QuoteTypes.PRE_FINALITY, quoteArgsHash: _preFinArgsHash(), amount: 0}); + bytes memory signedQuote = _signQuote(items, block.timestamp + 1 hours, address(0)); + address payable target = payable(makeAddr("attributedRefundTarget")); + + vm.recordLogs(); + vm.prank(user); + wrapper.depositForBurnWithFees( + BURN_AMOUNT, DEST_DOMAIN, MINT_RECIPIENT, address(burnToken), DEST_CALLER, _claim(signedQuote, target) + ); + + Vm.Log[] memory entries = vm.getRecordedLogs(); + bytes32 sig = keccak256("FeeCollected(address,bytes32,string,string[],address,address,address,uint256,uint256)"); + bool found = false; + for (uint256 i = 0; i < entries.length; ++i) { + if (entries[i].topics.length > 0 && entries[i].topics[0] == sig) { + (,, address payable refundAddress,,,,) = + abi.decode(entries[i].data, (string, string[], address, address, address, uint256, uint256)); + assertEq(refundAddress, target, "non-zero refundAddress must be forwarded unchanged"); + found = true; + } + } + assertTrue(found, "FeeCollected event not emitted"); + } + + /// @dev Non-zero refundAddress is screened by FeeManager against the configured USDC blacklist. + function test_depositForBurnWithFees_revertsForBlacklistedRefundAddress() public { + bytes memory signedQuote = _emptySignedQuote(); + address payable blocked = payable(makeAddr("blockedRefund")); + mockUsdcBlacklist.setBlacklisted(blocked, true); + + vm.prank(user); + vm.expectRevert(abi.encodeWithSelector(IFeeManager.DenylistedRefundAddress.selector, blocked)); + wrapper.depositForBurnWithFees( + BURN_AMOUNT, DEST_DOMAIN, MINT_RECIPIENT, address(burnToken), DEST_CALLER, _claim(signedQuote, blocked) + ); + } + + /// @dev FORWARD `quoteArgs` bind `burnToken`; a FORWARD item signed for a different burn token + /// reaches `_collectMatchedItems` with a mismatched args hash and reverts. Addresses audit #012. + function test_depositForBurnWithHookAndFees_forward_revertsForMismatchedBurnToken() public { + MockBurnToken otherBurnToken = new MockBurnToken(); + bytes memory hookData = _forwardHookData(); + + IFeeManager.QuoteItem[] memory items = new IFeeManager.QuoteItem[](1); + items[0] = IFeeManager.QuoteItem({ + quoteType: QuoteTypes.FORWARD, + quoteArgsHash: _forwardArgsHashFor(address(otherBurnToken), hookData), + amount: 0 + }); + bytes memory signedQuote = _signQuote(items, block.timestamp + 300, address(0)); + + vm.prank(user); + vm.expectRevert( + abi.encodeWithSelector( + IFeeManager.QuoteArgsMismatch.selector, QuoteTypes.FORWARD, _forwardArgsHash(hookData) + ) + ); + wrapper.depositForBurnWithHookAndFees( + BURN_AMOUNT, DEST_DOMAIN, MINT_RECIPIENT, address(burnToken), DEST_CALLER, hookData, _userClaim(signedQuote) + ); + } + + /// @dev PRE_FINALITY `quoteArgs` bind `burnToken`; a PRE_FINALITY item signed for a different + /// burn token reaches `_collectMatchedItems` with a mismatched args hash and reverts. Addresses audit #012. + function test_depositForBurnWithFees_preFin_revertsForMismatchedBurnToken() public { + MockBurnToken otherBurnToken = new MockBurnToken(); + + IFeeManager.QuoteItem[] memory items = new IFeeManager.QuoteItem[](1); + items[0] = IFeeManager.QuoteItem({ + quoteType: QuoteTypes.PRE_FINALITY, + quoteArgsHash: _preFinArgsHashFor(address(otherBurnToken), BURN_AMOUNT), + amount: 0 + }); + bytes memory signedQuote = _signQuote(items, block.timestamp + 300, address(0)); + + vm.prank(user); + vm.expectRevert( + abi.encodeWithSelector(IFeeManager.QuoteArgsMismatch.selector, QuoteTypes.PRE_FINALITY, _preFinArgsHash()) + ); + wrapper.depositForBurnWithFees( + BURN_AMOUNT, DEST_DOMAIN, MINT_RECIPIENT, address(burnToken), DEST_CALLER, _userClaim(signedQuote) + ); + } + + /// @dev PRE_FINALITY `quoteArgs` bind the calling wrapper (`address(this)`); a PRE_FINALITY item + /// signed for a different `TokenMessengerWithFees` deployment reaches `_collectMatchedItems` + /// with a mismatched args hash and reverts. + function test_depositForBurnWithFees_preFin_revertsForMismatchedWrapper() public { + TokenMessengerWithFees otherWrapper = _deployWrapperProxy(owner, address(mockTm), address(feeManager)); + + string[] memory args = new string[](4); + args[0] = Strings.toHexString(address(otherWrapper)); + args[1] = Strings.toString(uint256(DEST_DOMAIN)); + args[2] = Strings.toHexString(address(burnToken)); + args[3] = Strings.toString(BURN_AMOUNT); + + IFeeManager.QuoteItem[] memory items = new IFeeManager.QuoteItem[](1); + items[0] = IFeeManager.QuoteItem({ + quoteType: QuoteTypes.PRE_FINALITY, quoteArgsHash: keccak256(abi.encode(args)), amount: 0 + }); + bytes memory signedQuote = _signQuote(items, block.timestamp + 300, address(0)); + + vm.prank(user); + vm.expectRevert( + abi.encodeWithSelector(IFeeManager.QuoteArgsMismatch.selector, QuoteTypes.PRE_FINALITY, _preFinArgsHash()) + ); + wrapper.depositForBurnWithFees( + BURN_AMOUNT, DEST_DOMAIN, MINT_RECIPIENT, address(burnToken), DEST_CALLER, _userClaim(signedQuote) + ); + } +} diff --git a/test/UpgradeFeeManager.t.sol b/test/UpgradeFeeManager.t.sol new file mode 100644 index 0000000..9d9dfcf --- /dev/null +++ b/test/UpgradeFeeManager.t.sol @@ -0,0 +1,148 @@ +/** + * Copyright 2026 Circle Internet Group, Inc. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + */ +pragma solidity ^0.8.20; + +import {DeployFeeManager, DeployFeeManagerConfig} from "../script/deploy/DeployFeeManager.s.sol"; +import {Salts} from "../script/deploy/Salts.sol"; +import {UpgradeFeeManager, UpgradeFeeManagerImplConfig} from "../script/deploy/UpgradeFeeManager.s.sol"; +import {Create3Factory} from "../src/contracts/Create3Factory.sol"; +import {FeeManager} from "../src/contracts/FeeManager.sol"; +import {ANVIL_DEFAULT_PK} from "./TestConstants.sol"; +import {Test} from "forge-std/Test.sol"; + +string constant UPGRADE_FM_DEFAULT_SALT = "fee-orchestrator-v1"; + +contract UpgradeFeeManagerTest is Test { + bytes32 internal constant EIP1967_IMPL_SLOT = 0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc; + + address internal deployer; + address internal feeTakerAddr; + address internal usdcAddr; + Create3Factory internal factory; + DeployFeeManager internal deployScript; + FeeManager internal fm; + + /// @dev Sets the impl salt env var and immediately caches it into a config struct. + function _upgradeImplConfigForSalt(string memory implSalt) internal returns (UpgradeFeeManagerImplConfig memory) { + vm.setEnv("FEE_MANAGER_IMPLEMENTATION_SALT", string.concat(implSalt, ".fm.impl")); + return UpgradeFeeManagerImplConfig({ + factory: factory, implSalt: Salts.feeManagerImpl(), feeTaker: feeTakerAddr, usdc: usdcAddr + }); + } + + function setUp() public { + deployer = vm.addr(ANVIL_DEFAULT_PK); + feeTakerAddr = makeAddr("feeTaker"); + usdcAddr = makeAddr("usdcBlacklist"); + + vm.prank(deployer); + factory = new Create3Factory(deployer); + + vm.setEnv("DEPLOYER_PRIVATE_KEY", vm.toString(ANVIL_DEFAULT_PK)); + vm.setEnv("CREATE3_FACTORY_ADDRESS", vm.toString(address(factory))); + vm.setEnv("FEE_MANAGER_PROXY_SALT", string.concat(UPGRADE_FM_DEFAULT_SALT, ".fm.proxy")); + vm.setEnv("FEE_MANAGER_IMPLEMENTATION_SALT", string.concat(UPGRADE_FM_DEFAULT_SALT, ".fm.impl")); + vm.setEnv("TOKEN_MESSENGER_WITH_FEES_PROXY_SALT", string.concat(UPGRADE_FM_DEFAULT_SALT, ".tmwf.proxy")); + vm.setEnv("TOKEN_MESSENGER_WITH_FEES_IMPLEMENTATION_SALT", string.concat(UPGRADE_FM_DEFAULT_SALT, ".tmwf.impl")); + + // Cache salt-derived values before deploying. + bytes32 fmImplSalt = Salts.feeManagerImpl(); + bytes32 fmProxySalt = Salts.feeManagerProxy(); + address predictedTmwfProxy = factory.getAddress(Salts.tokenMessengerWithFeesProxy()); + + address[] memory callers = new address[](1); + callers[0] = predictedTmwfProxy; + + deployScript = new DeployFeeManager(); + deployScript.runWithConfig( + DeployFeeManagerConfig({ + factory: factory, + implSalt: fmImplSalt, + proxySalt: fmProxySalt, + owner: address(0), + feeTaker: feeTakerAddr, + usdc: usdcAddr, + callerAdmin: address(0), + signerAdmin: address(0), + trustedSigner: address(0), + pauser: address(0), + feeCollectionAdmin: address(0), + feeCollectionAddress: payable(address(0)), + allowedCallers: callers + }) + ); + fm = deployScript.feeManager(); + + vm.prank(deployer); + fm.acceptOwnership(); + } + + function _implOf(address proxy) internal view returns (address) { + return address(uint160(uint256(vm.load(proxy, EIP1967_IMPL_SLOT)))); + } + + function test_run_deploysNewImplAndUpdatesSlot() public { + address oldImpl = _implOf(address(fm)); + UpgradeFeeManagerImplConfig memory config = _upgradeImplConfigForSalt("fee-orchestrator-v2"); + + UpgradeFeeManager up = new UpgradeFeeManager(); + up.runWithConfig(config, address(fm)); + + address newImpl = _implOf(address(fm)); + assertTrue(newImpl != oldImpl); + assertEq(newImpl, up.implementation()); + assertEq(fm.owner(), deployer); + assertEq(fm.feeTaker(), feeTakerAddr); + } + + function test_deployImplementationOnly_doesNotChangeProxySlot() public { + address oldImpl = _implOf(address(fm)); + UpgradeFeeManagerImplConfig memory config = _upgradeImplConfigForSalt("fee-orchestrator-v2"); + + UpgradeFeeManager up = new UpgradeFeeManager(); + up.deployImplementationOnlyWithConfig(config); + + assertEq(_implOf(address(fm)), oldImpl); + assertTrue(up.implementation() != address(0)); + assertTrue(up.implementation() != oldImpl); + } + + function test_upgradeProxyOnly_updatesSlotToEnvImpl() public { + UpgradeFeeManagerImplConfig memory config = _upgradeImplConfigForSalt("fee-orchestrator-v2"); + + UpgradeFeeManager deployImpl = new UpgradeFeeManager(); + deployImpl.deployImplementationOnlyWithConfig(config); + address newImpl = deployImpl.implementation(); + + UpgradeFeeManager up = new UpgradeFeeManager(); + up.upgradeProxyOnlyWithConfig(address(fm), newImpl); + + assertEq(_implOf(address(fm)), newImpl); + } + + function test_deployImplementationOnly_twiceSameSalt_reverts() public { + UpgradeFeeManagerImplConfig memory config = _upgradeImplConfigForSalt("fee-orchestrator-v2"); + + UpgradeFeeManager up = new UpgradeFeeManager(); + up.deployImplementationOnlyWithConfig(config); + + UpgradeFeeManager up2 = new UpgradeFeeManager(); + vm.expectRevert(); + up2.deployImplementationOnlyWithConfig(config); + } +} diff --git a/test/UpgradeTokenMessengerWithFees.t.sol b/test/UpgradeTokenMessengerWithFees.t.sol new file mode 100644 index 0000000..2620589 --- /dev/null +++ b/test/UpgradeTokenMessengerWithFees.t.sol @@ -0,0 +1,211 @@ +/** + * Copyright 2026 Circle Internet Group, Inc. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + */ +pragma solidity ^0.8.20; + +import {DeployFeeManager, DeployFeeManagerConfig} from "../script/deploy/DeployFeeManager.s.sol"; +import { + DeployTokenMessengerWithFees, + DeployTokenMessengerWithFeesConfig +} from "../script/deploy/DeployTokenMessengerWithFees.s.sol"; +import {Salts} from "../script/deploy/Salts.sol"; +import { + UpgradeTmwfImplConfig, + UpgradeTokenMessengerWithFees +} from "../script/deploy/UpgradeTokenMessengerWithFees.s.sol"; +import {Create3Factory} from "../src/contracts/Create3Factory.sol"; +import {FeeManager} from "../src/contracts/FeeManager.sol"; +import {TokenMessengerWithFees} from "../src/contracts/TokenMessengerWithFees.sol"; +import {ANVIL_DEFAULT_PK} from "./TestConstants.sol"; +import {MockMessageTransmitter} from "./mocks/MockMessageTransmitter.sol"; +import {MockTokenMessenger} from "./mocks/MockTokenMessenger.sol"; +import {Test} from "forge-std/Test.sol"; + +contract UpgradeTokenMessengerWithFeesTest is Test { + bytes32 internal constant EIP1967_IMPL_SLOT = 0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc; + + DeployTokenMessengerWithFees internal deployTmwfScript; + TokenMessengerWithFees internal tmwf; + FeeManager internal fm; + Create3Factory internal factory; + address internal deployer; + address internal mockTokenMessengerV2; + + /// @dev Sets the impl salt env var and immediately caches it into a config struct. + function _upgradeImplConfigForSalt(string memory implSalt) internal returns (UpgradeTmwfImplConfig memory) { + return _upgradeImplConfigForSaltWith(implSalt, address(fm)); + } + + /// @dev Like _upgradeImplConfigForSalt but with a custom feeManagerProxy. + function _upgradeImplConfigForSaltWith(string memory implSalt, address feeManagerProxy) + internal + returns (UpgradeTmwfImplConfig memory) + { + vm.setEnv("TOKEN_MESSENGER_WITH_FEES_IMPLEMENTATION_SALT", string.concat(implSalt, ".tmwf.impl")); + return UpgradeTmwfImplConfig({ + factory: factory, + implSalt: Salts.tokenMessengerWithFeesImpl(), + tokenMessengerV2: mockTokenMessengerV2, + feeManagerProxy: feeManagerProxy + }); + } + + function setUp() public { + deployer = vm.addr(ANVIL_DEFAULT_PK); + MockMessageTransmitter mt = new MockMessageTransmitter(0); + mockTokenMessengerV2 = address(new MockTokenMessenger(address(mt))); + + vm.prank(deployer); + factory = new Create3Factory(deployer); + + vm.setEnv("DEPLOYER_PRIVATE_KEY", vm.toString(ANVIL_DEFAULT_PK)); + vm.setEnv("CREATE3_FACTORY_ADDRESS", vm.toString(address(factory))); + vm.setEnv("FEE_MANAGER_PROXY_SALT", "fee-orchestrator-v1.fm.proxy"); + vm.setEnv("FEE_MANAGER_IMPLEMENTATION_SALT", "fee-orchestrator-v1.fm.impl"); + vm.setEnv("TOKEN_MESSENGER_WITH_FEES_PROXY_SALT", "fee-orchestrator-v1.tmwf.proxy"); + vm.setEnv("TOKEN_MESSENGER_WITH_FEES_IMPLEMENTATION_SALT", "fee-orchestrator-v1.tmwf.impl"); + + bytes32 fmImplSalt = Salts.feeManagerImpl(); + bytes32 fmProxySalt = Salts.feeManagerProxy(); + bytes32 tmwfImplSalt = Salts.tokenMessengerWithFeesImpl(); + bytes32 tmwfProxySalt = Salts.tokenMessengerWithFeesProxy(); + + fm = _deployFeeManager(fmImplSalt, fmProxySalt, tmwfProxySalt); + + deployTmwfScript = new DeployTokenMessengerWithFees(); + deployTmwfScript.runWithConfig( + DeployTokenMessengerWithFeesConfig({ + factory: factory, + tokenMessengerV2: mockTokenMessengerV2, + feeManagerProxy: address(fm), + owner: address(0), + implSalt: tmwfImplSalt, + proxySalt: tmwfProxySalt + }) + ); + tmwf = deployTmwfScript.tokenMessengerWithFees(); + + vm.prank(deployer); + tmwf.acceptOwnership(); + } + + function _deployFeeManager(bytes32 implSalt, bytes32 proxySalt, bytes32 tmwfProxySalt) + private + returns (FeeManager) + { + address[] memory callers = new address[](1); + callers[0] = factory.getAddress(tmwfProxySalt); + DeployFeeManager fmScript = new DeployFeeManager(); + fmScript.runWithConfig( + DeployFeeManagerConfig({ + factory: factory, + implSalt: implSalt, + proxySalt: proxySalt, + owner: address(0), + feeTaker: makeAddr("feeTaker"), + usdc: makeAddr("usdcBlacklist"), + callerAdmin: address(0), + signerAdmin: address(0), + trustedSigner: address(0), + pauser: address(0), + feeCollectionAdmin: address(0), + feeCollectionAddress: payable(address(0)), + allowedCallers: callers + }) + ); + FeeManager deployedFm = fmScript.feeManager(); + + vm.prank(deployer); + deployedFm.acceptOwnership(); + + return deployedFm; + } + + function _implOf(address proxy) internal view returns (address) { + return address(uint160(uint256(vm.load(proxy, EIP1967_IMPL_SLOT)))); + } + + function test_run_deploysNewImplAndUpdatesSlot() public { + address oldImpl = _implOf(address(tmwf)); + UpgradeTmwfImplConfig memory config = _upgradeImplConfigForSalt("fee-orchestrator-v2"); + + UpgradeTokenMessengerWithFees up = new UpgradeTokenMessengerWithFees(); + up.runWithConfig(config, address(tmwf)); + + address newImpl = _implOf(address(tmwf)); + assertTrue(newImpl != oldImpl); + assertEq(newImpl, up.implementation()); + assertEq(tmwf.owner(), deployer); + assertEq(tmwf.feeManager(), address(fm)); + assertEq(tmwf.tokenMessenger(), mockTokenMessengerV2); + } + + function test_deployImplementationOnly_doesNotChangeProxySlot() public { + address oldImpl = _implOf(address(tmwf)); + UpgradeTmwfImplConfig memory config = _upgradeImplConfigForSalt("fee-orchestrator-v2"); + + UpgradeTokenMessengerWithFees up = new UpgradeTokenMessengerWithFees(); + up.deployImplementationOnlyWithConfig(config); + + assertEq(_implOf(address(tmwf)), oldImpl); + assertTrue(up.implementation() != address(0)); + assertTrue(up.implementation() != oldImpl); + } + + function test_upgradeProxyOnly_updatesSlotToEnvImpl() public { + UpgradeTmwfImplConfig memory config = _upgradeImplConfigForSalt("fee-orchestrator-v2"); + + UpgradeTokenMessengerWithFees deployImpl = new UpgradeTokenMessengerWithFees(); + deployImpl.deployImplementationOnlyWithConfig(config); + address newImpl = deployImpl.implementation(); + + UpgradeTokenMessengerWithFees up = new UpgradeTokenMessengerWithFees(); + up.upgradeProxyOnlyWithConfig(address(tmwf), newImpl); + + assertEq(_implOf(address(tmwf)), newImpl); + } + + function test_deployImplementationOnly_feeManagerImmutable_matchesFeeManagerProxy() public { + UpgradeTmwfImplConfig memory config = _upgradeImplConfigForSalt("fee-orchestrator-v2"); + + UpgradeTokenMessengerWithFees up = new UpgradeTokenMessengerWithFees(); + up.deployImplementationOnlyWithConfig(config); + + assertEq(TokenMessengerWithFees(up.implementation()).feeManager(), address(fm)); + } + + function test_deployImplementationOnly_feeManagerImmutable_respectsOverride() public { + address altFeeManager = makeAddr("altFeeManagerProxy"); + UpgradeTmwfImplConfig memory config = _upgradeImplConfigForSaltWith("fee-orchestrator-v3", altFeeManager); + + UpgradeTokenMessengerWithFees up = new UpgradeTokenMessengerWithFees(); + up.deployImplementationOnlyWithConfig(config); + + assertEq(TokenMessengerWithFees(up.implementation()).feeManager(), altFeeManager); + } + + function test_deployImplementationOnly_twiceSameSalt_reverts() public { + UpgradeTmwfImplConfig memory config = _upgradeImplConfigForSalt("fee-orchestrator-v2"); + + UpgradeTokenMessengerWithFees up = new UpgradeTokenMessengerWithFees(); + up.deployImplementationOnlyWithConfig(config); + + UpgradeTokenMessengerWithFees up2 = new UpgradeTokenMessengerWithFees(); + vm.expectRevert(); + up2.deployImplementationOnlyWithConfig(config); + } +} diff --git a/test/WithFeeCollectionAddress.t.sol b/test/WithFeeCollectionAddress.t.sol new file mode 100644 index 0000000..f65e370 --- /dev/null +++ b/test/WithFeeCollectionAddress.t.sol @@ -0,0 +1,166 @@ +/** + * Copyright 2026 Circle Internet Group, Inc. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + */ +pragma solidity ^0.8.20; + +import {WithFeeCollectionAddress} from "../src/access/WithFeeCollectionAddress.sol"; +import {Roles} from "../src/constants/Roles.sol"; +import {IAccessControl} from "@openzeppelin/contracts/access/IAccessControl.sol"; +import {ERC1967Proxy} from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol"; +import {Test, Vm} from "forge-std/Test.sol"; + +/// @dev Minimal concrete contract for exercising `WithFeeCollectionAddress`. +contract WithFeeCollectionAddressHarness is WithFeeCollectionAddress { + constructor() { + _disableInitializers(); + } + + function initialize(address admin) external initializer { + __AccessControl_init(); + _grantRole(DEFAULT_ADMIN_ROLE, admin); + _grantRole(Roles.FEE_COLLECTION_ADMIN_ROLE, admin); + } + + function exposedFeeCollectionAddress() external view returns (address payable) { + return _feeCollectionAddress(); + } +} + +contract WithFeeCollectionAddressTest is Test { + WithFeeCollectionAddressHarness internal harness; + + address internal admin = makeAddr("admin"); + address payable internal feeCollector1 = payable(makeAddr("feeCollector1")); + address payable internal feeCollector2 = payable(makeAddr("feeCollector2")); + address internal stranger = makeAddr("stranger"); + + event FeeCollectionAddressSet(address indexed feeCollectionAddress); + + function setUp() public { + WithFeeCollectionAddressHarness impl = new WithFeeCollectionAddressHarness(); + bytes memory initData = abi.encodeCall(WithFeeCollectionAddressHarness.initialize, (admin)); + ERC1967Proxy proxy = new ERC1967Proxy(address(impl), initData); + harness = WithFeeCollectionAddressHarness(address(proxy)); + } + + // ───────────────────── setFeeCollectionAddress ───────────────────── + + function test_setFeeCollectionAddress_sets() public { + vm.prank(admin); + vm.expectEmit(true, false, false, true); + emit FeeCollectionAddressSet(feeCollector1); + harness.setFeeCollectionAddress(feeCollector1); + + assertEq(harness.feeCollectionAddress(), feeCollector1); + assertEq(harness.exposedFeeCollectionAddress(), feeCollector1); + } + + function test_setFeeCollectionAddress_overwritesPrevious() public { + vm.prank(admin); + harness.setFeeCollectionAddress(feeCollector1); + + vm.prank(admin); + vm.expectEmit(true, false, false, true); + emit FeeCollectionAddressSet(feeCollector2); + harness.setFeeCollectionAddress(feeCollector2); + + assertEq(harness.feeCollectionAddress(), feeCollector2); + } + + function test_setFeeCollectionAddress_clearsToZero() public { + vm.prank(admin); + harness.setFeeCollectionAddress(feeCollector1); + assertEq(harness.feeCollectionAddress(), feeCollector1); + + vm.prank(admin); + vm.expectEmit(true, false, false, true); + emit FeeCollectionAddressSet(address(0)); + harness.setFeeCollectionAddress(payable(address(0))); + + assertEq(harness.feeCollectionAddress(), address(0)); + } + + function test_setFeeCollectionAddress_noop_whenValueUnchanged_doesNotEmit() public { + vm.prank(admin); + harness.setFeeCollectionAddress(feeCollector1); + + vm.recordLogs(); + vm.prank(admin); + harness.setFeeCollectionAddress(feeCollector1); + + Vm.Log[] memory entries = vm.getRecordedLogs(); + assertEq(_countFeeCollectionAddressSet(entries), 0, "no-op call must not emit FeeCollectionAddressSet"); + assertEq(harness.feeCollectionAddress(), feeCollector1); + } + + function test_setFeeCollectionAddress_noop_settingZeroFromUnset_doesNotEmit() public { + vm.recordLogs(); + vm.prank(admin); + harness.setFeeCollectionAddress(payable(address(0))); + + Vm.Log[] memory entries = vm.getRecordedLogs(); + assertEq(_countFeeCollectionAddressSet(entries), 0, "no-op call must not emit FeeCollectionAddressSet"); + assertEq(harness.feeCollectionAddress(), address(0)); + } + + function _countFeeCollectionAddressSet(Vm.Log[] memory entries) private pure returns (uint256 count) { + bytes32 sig = keccak256("FeeCollectionAddressSet(address)"); + for (uint256 i = 0; i < entries.length; ++i) { + if (entries[i].topics.length > 0 && entries[i].topics[0] == sig) { + ++count; + } + } + } + + function test_setFeeCollectionAddress_revertsForNonFeeCollectionAdmin() public { + vm.prank(stranger); + vm.expectRevert( + abi.encodeWithSelector( + IAccessControl.AccessControlUnauthorizedAccount.selector, stranger, Roles.FEE_COLLECTION_ADMIN_ROLE + ) + ); + harness.setFeeCollectionAddress(feeCollector1); + } + + function test_setFeeCollectionAddress_revertsWhenDefaultAdminLacksFeeCollectionAdmin() public { + address defaultAdminOnly = makeAddr("defaultAdminOnly"); + WithFeeCollectionAddressHarness impl = new WithFeeCollectionAddressHarness(); + bytes memory initData = abi.encodeCall(WithFeeCollectionAddressHarness.initialize, (defaultAdminOnly)); + ERC1967Proxy proxy = new ERC1967Proxy(address(impl), initData); + WithFeeCollectionAddressHarness fresh = WithFeeCollectionAddressHarness(address(proxy)); + + vm.prank(defaultAdminOnly); + fresh.revokeRole(Roles.FEE_COLLECTION_ADMIN_ROLE, defaultAdminOnly); + + vm.prank(defaultAdminOnly); + vm.expectRevert( + abi.encodeWithSelector( + IAccessControl.AccessControlUnauthorizedAccount.selector, + defaultAdminOnly, + Roles.FEE_COLLECTION_ADMIN_ROLE + ) + ); + fresh.setFeeCollectionAddress(feeCollector1); + } + + // ───────────────────── feeCollectionAddress ───────────────────── + + function test_feeCollectionAddress_zeroByDefault() public view { + assertEq(harness.feeCollectionAddress(), address(0)); + assertEq(harness.exposedFeeCollectionAddress(), address(0)); + } +} diff --git a/test/WithTrustedQuoteSigners.t.sol b/test/WithTrustedQuoteSigners.t.sol new file mode 100644 index 0000000..6d96010 --- /dev/null +++ b/test/WithTrustedQuoteSigners.t.sol @@ -0,0 +1,141 @@ +/** + * Copyright 2026 Circle Internet Group, Inc. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + */ +pragma solidity ^0.8.20; + +import {WithTrustedQuoteSigners} from "../src/access/WithTrustedQuoteSigners.sol"; +import {Roles} from "../src/constants/Roles.sol"; +import {IWithTrustedQuoteSigners} from "../src/interfaces/IWithTrustedQuoteSigners.sol"; +import {IAccessControl} from "@openzeppelin/contracts/access/IAccessControl.sol"; +import {ERC1967Proxy} from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol"; +import {Test} from "forge-std/Test.sol"; + +/// @dev Minimal concrete contract for exercising `WithTrustedQuoteSigners`. +contract WithTrustedQuoteSignersHarness is WithTrustedQuoteSigners { + constructor() { + _disableInitializers(); + } + + function initialize(address admin) external initializer { + __AccessControl_init(); + _grantRole(DEFAULT_ADMIN_ROLE, admin); + _grantRole(Roles.SIGNER_ADMIN_ROLE, admin); + } +} + +contract WithTrustedQuoteSignersTest is Test { + WithTrustedQuoteSignersHarness internal harness; + + address internal admin = makeAddr("admin"); + address internal signer1 = makeAddr("signer1"); + address internal signer2 = makeAddr("signer2"); + address internal stranger = makeAddr("stranger"); + + event TrustedQuoteSignerSet(address indexed signer, bool trusted); + + function setUp() public { + WithTrustedQuoteSignersHarness impl = new WithTrustedQuoteSignersHarness(); + bytes memory initData = abi.encodeCall(WithTrustedQuoteSignersHarness.initialize, (admin)); + ERC1967Proxy proxy = new ERC1967Proxy(address(impl), initData); + harness = WithTrustedQuoteSignersHarness(address(proxy)); + } + + // ───────────────────── setTrustedQuoteSigner ───────────────────── + + function test_setTrustedQuoteSigner_addsSigner() public { + vm.prank(admin); + vm.expectEmit(true, false, false, true); + emit TrustedQuoteSignerSet(signer1, true); + harness.setTrustedQuoteSigner(signer1, true); + + assertTrue(harness.isTrustedQuoteSigner(signer1)); + } + + function test_setTrustedQuoteSigner_removesSigner() public { + vm.prank(admin); + harness.setTrustedQuoteSigner(signer1, true); + + vm.prank(admin); + vm.expectEmit(true, false, false, true); + emit TrustedQuoteSignerSet(signer1, false); + harness.setTrustedQuoteSigner(signer1, false); + + assertFalse(harness.isTrustedQuoteSigner(signer1)); + } + + function test_setTrustedQuoteSigner_multipleSigners() public { + vm.startPrank(admin); + harness.setTrustedQuoteSigner(signer1, true); + harness.setTrustedQuoteSigner(signer2, true); + vm.stopPrank(); + + assertTrue(harness.isTrustedQuoteSigner(signer1)); + assertTrue(harness.isTrustedQuoteSigner(signer2)); + } + + function test_setTrustedQuoteSigner_removingOneLeavesOthersTrusted() public { + vm.startPrank(admin); + harness.setTrustedQuoteSigner(signer1, true); + harness.setTrustedQuoteSigner(signer2, true); + harness.setTrustedQuoteSigner(signer1, false); + vm.stopPrank(); + + assertFalse(harness.isTrustedQuoteSigner(signer1)); + assertTrue(harness.isTrustedQuoteSigner(signer2)); + } + + function test_setTrustedQuoteSigner_idempotentAdd() public { + vm.startPrank(admin); + harness.setTrustedQuoteSigner(signer1, true); + harness.setTrustedQuoteSigner(signer1, true); + vm.stopPrank(); + + assertTrue(harness.isTrustedQuoteSigner(signer1)); + + vm.prank(admin); + harness.setTrustedQuoteSigner(signer1, false); + assertFalse(harness.isTrustedQuoteSigner(signer1)); + } + + function test_setTrustedQuoteSigner_idempotentRemove() public { + vm.prank(admin); + harness.setTrustedQuoteSigner(signer1, false); + assertFalse(harness.isTrustedQuoteSigner(signer1)); + } + + function test_setTrustedQuoteSigner_revertsForZeroSigner() public { + vm.prank(admin); + vm.expectRevert(IWithTrustedQuoteSigners.ZeroTrustedQuoteSignerAddress.selector); + harness.setTrustedQuoteSigner(address(0), true); + } + + function test_setTrustedQuoteSigner_revertsForNonSignerAdmin() public { + vm.prank(stranger); + vm.expectRevert( + abi.encodeWithSelector( + IAccessControl.AccessControlUnauthorizedAccount.selector, stranger, Roles.SIGNER_ADMIN_ROLE + ) + ); + harness.setTrustedQuoteSigner(signer1, true); + } + + // ───────────────────── isTrustedQuoteSigner ───────────────────── + + function test_isTrustedQuoteSigner_falseByDefault() public view { + assertFalse(harness.isTrustedQuoteSigner(signer1)); + } +} diff --git a/test/mocks/MockCreate2Factory.sol b/test/mocks/MockCreate2Factory.sol new file mode 100644 index 0000000..ead3357 --- /dev/null +++ b/test/mocks/MockCreate2Factory.sol @@ -0,0 +1,53 @@ +/** + * Copyright 2026 Circle Internet Group, Inc. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + */ +pragma solidity ^0.8.20; + +import {ICreate2Factory} from "../../script/deploy/interfaces/ICreate2Factory.sol"; + +/// @notice Minimal Create2Factory mock for tests. Implements the subset used +/// by the fee-orchestrator bootstrap flow. +contract MockCreate2Factory is ICreate2Factory { + address public override owner; + + constructor(address _owner) { + owner = _owner; + } + + function deployAndMultiCall(uint256 amount, bytes32 salt, bytes calldata bytecode, bytes[] calldata data) + external + payable + override + returns (address addr) + { + bytes memory bc = bytecode; + assembly { + addr := create2(amount, add(bc, 0x20), mload(bc), salt) + } + require(addr != address(0), "MockCreate2Factory: create2 failed"); + + for (uint256 i = 0; i < data.length; ++i) { + // solhint-disable-next-line avoid-low-level-calls + (bool ok, bytes memory ret) = addr.call(data[i]); + if (!ok) { + assembly { + revert(add(ret, 0x20), mload(ret)) + } + } + } + } +} diff --git a/test/mocks/MockMessageTransmitter.sol b/test/mocks/MockMessageTransmitter.sol new file mode 100644 index 0000000..e533059 --- /dev/null +++ b/test/mocks/MockMessageTransmitter.sol @@ -0,0 +1,28 @@ +/** + * Copyright 2026 Circle Internet Group, Inc. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + */ +pragma solidity ^0.8.20; + +/// @dev Minimal `MessageTransmitterV2` stub so `TokenMessengerWithFees` constructor can read `localDomain()`. +contract MockMessageTransmitter { + // forge-lint: disable-next-line(screaming-snake-case-immutable) + uint32 public immutable localDomain; // solhint-disable-line immutable-vars-naming + + constructor(uint32 domain_) { + localDomain = domain_; + } +} diff --git a/test/mocks/MockTokenMessenger.sol b/test/mocks/MockTokenMessenger.sol new file mode 100644 index 0000000..8f36d04 --- /dev/null +++ b/test/mocks/MockTokenMessenger.sol @@ -0,0 +1,28 @@ +/** + * Copyright 2026 Circle Internet Group, Inc. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + */ +pragma solidity ^0.8.20; + +/// @dev Minimal `TokenMessengerV2` stub so the TMWF constructor can call `localMessageTransmitter()`. +contract MockTokenMessenger { + // forge-lint: disable-next-line(screaming-snake-case-immutable) + address public immutable localMessageTransmitter; // solhint-disable-line immutable-vars-naming + + constructor(address messageTransmitter_) { + localMessageTransmitter = messageTransmitter_; + } +} diff --git a/test/mocks/MockUSDCBlacklist.sol b/test/mocks/MockUSDCBlacklist.sol new file mode 100644 index 0000000..78ce13b --- /dev/null +++ b/test/mocks/MockUSDCBlacklist.sol @@ -0,0 +1,31 @@ +/** + * Copyright 2026 Circle Internet Group, Inc. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + */ +pragma solidity ^0.8.20; + +/// @dev Minimal USDC blacklist stub (same interface surface as on-chain USDC for tests). +contract MockUSDCBlacklist { + mapping(address => bool) internal _blacklisted; + + function setBlacklisted(address account, bool value) external { + _blacklisted[account] = value; + } + + function isBlacklisted(address account) external view returns (bool) { + return _blacklisted[account]; + } +} diff --git a/test/mocks/MockUsdcAwareBurnToken.sol b/test/mocks/MockUsdcAwareBurnToken.sol new file mode 100644 index 0000000..89c9b0b --- /dev/null +++ b/test/mocks/MockUsdcAwareBurnToken.sol @@ -0,0 +1,42 @@ +/** + * Copyright 2026 Circle Internet Group, Inc. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + */ +pragma solidity ^0.8.20; + +import {IUSDCBlacklist} from "../../src/interfaces/IUSDCBlacklist.sol"; +import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol"; + +/// @dev Burn-token stand-in that rejects `transferFrom` when `from` is blocked on the same +/// blacklist oracle `FeeManager.isDenylisted` uses (defense-in-depth for the EOA path). +contract MockUsdcAwareBurnToken is ERC20 { + IUSDCBlacklist public immutable USDC_BLACKLIST; + + constructor(address usdcBlacklist_) ERC20("MockUSDC", "USDC") { + USDC_BLACKLIST = IUSDCBlacklist(usdcBlacklist_); + } + + function mint(address to, uint256 amount) external { + _mint(to, amount); + } + + function transferFrom(address from, address to, uint256 amount) public override returns (bool) { + if (USDC_BLACKLIST.isBlacklisted(from)) { + revert("MockUsdcAwareBurnToken: blacklisted sender"); + } + return super.transferFrom(from, to, amount); + } +} From 5f33baee39a5c5eff913d7b88c949b731f1857bc Mon Sep 17 00:00:00 2001 From: undefined Date: Mon, 19 Oct 2026 03:42:37 +0000 Subject: [PATCH 03/21] =?UTF-8?q?=F0=9F=94=84=20created=20local=20'script/?= =?UTF-8?q?'=20from=20remote=20'script/'?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- script/abi/extract-abis.sh | 13 + script/coverage/checkCoverage.js | 294 ++++++++++++++++++ script/coverage/excludedFiles.txt | 0 script/coverage/excludedFunctions.txt | 2 + script/coverage/passWithSlackWhitelist.txt | 0 script/deploy/.gitkeep | 0 script/deploy/Addresses.sol | 53 ++++ script/deploy/Broadcaster.sol | 45 +++ script/deploy/DeployCreate3Factory.s.sol | 44 +++ .../DeployCreate3FactoryViaCreate2.s.sol | 84 +++++ script/deploy/DeployFeeManager.s.sol | 245 +++++++++++++++ .../deploy/DeployTokenMessengerWithFees.s.sol | 122 ++++++++ script/deploy/PrintAddresses.s.sol | 76 +++++ script/deploy/Salts.sol | 55 ++++ script/deploy/UpgradeFeeManager.s.sol | 121 +++++++ .../UpgradeTokenMessengerWithFees.s.sol | 132 ++++++++ script/deploy/interfaces/ICreate2Factory.sol | 36 +++ script/lint/check-foundry-settings.sh | 71 +++++ script/pre-commit/check-copyright-header.sh | 94 ++++++ .../DepositForBurnWithGeneratedQuote.s.sol | 167 ++++++++++ .../test/DepositForBurnWithSignedQuote.s.sol | 159 ++++++++++ script/test/GenerateSignedTestQuote.s.sol | 116 +++++++ script/test/QuoteScriptUtils.sol | 169 ++++++++++ .../test/cast-deposit-for-burn-with-quote.sh | 183 +++++++++++ script/test/cast-deposit-for-burn.sh | 185 +++++++++++ 25 files changed, 2466 insertions(+) create mode 100644 script/abi/extract-abis.sh create mode 100644 script/coverage/checkCoverage.js create mode 100644 script/coverage/excludedFiles.txt create mode 100644 script/coverage/excludedFunctions.txt create mode 100644 script/coverage/passWithSlackWhitelist.txt create mode 100644 script/deploy/.gitkeep create mode 100644 script/deploy/Addresses.sol create mode 100644 script/deploy/Broadcaster.sol create mode 100644 script/deploy/DeployCreate3Factory.s.sol create mode 100644 script/deploy/DeployCreate3FactoryViaCreate2.s.sol create mode 100644 script/deploy/DeployFeeManager.s.sol create mode 100644 script/deploy/DeployTokenMessengerWithFees.s.sol create mode 100644 script/deploy/PrintAddresses.s.sol create mode 100644 script/deploy/Salts.sol create mode 100644 script/deploy/UpgradeFeeManager.s.sol create mode 100644 script/deploy/UpgradeTokenMessengerWithFees.s.sol create mode 100644 script/deploy/interfaces/ICreate2Factory.sol create mode 100755 script/lint/check-foundry-settings.sh create mode 100755 script/pre-commit/check-copyright-header.sh create mode 100644 script/test/DepositForBurnWithGeneratedQuote.s.sol create mode 100644 script/test/DepositForBurnWithSignedQuote.s.sol create mode 100644 script/test/GenerateSignedTestQuote.s.sol create mode 100644 script/test/QuoteScriptUtils.sol create mode 100755 script/test/cast-deposit-for-burn-with-quote.sh create mode 100755 script/test/cast-deposit-for-burn.sh diff --git a/script/abi/extract-abis.sh b/script/abi/extract-abis.sh new file mode 100644 index 0000000..27830c4 --- /dev/null +++ b/script/abi/extract-abis.sh @@ -0,0 +1,13 @@ +#!/usr/bin/env bash +# Export ABIs for the contracts users integrate with into abi/. +# The implementation ABIs are also the proxy ABIs (ERC1967 delegates everything). +# +# Usage: bash script/abi/extract-abis.sh (or: yarn abi) + +set -euo pipefail + +mkdir -p abi +for c in FeeManager TokenMessengerWithFees Create3Factory; do + forge inspect "$c" abi --json > "abi/$c.abi.json" + echo "abi/$c.abi.json" +done diff --git a/script/coverage/checkCoverage.js b/script/coverage/checkCoverage.js new file mode 100644 index 0000000..2d7433e --- /dev/null +++ b/script/coverage/checkCoverage.js @@ -0,0 +1,294 @@ +/** + * Copyright 2026 Circle Internet Group, Inc. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + */ +const fs = require('fs'); + +const COVERAGE_TABLE_HEADER_PATTERN = /^\| File\s+\| % Lines\s+\| % Statements\s+\| % Branches\s+\| % Funcs\s+\|$/; +const COVERAGE_TABLE_TOTAL_ROW_NAME = 'Total'; +const COVERAGE_TABLE_COLUMN_DELIM = '|'; + +const COVERAGE_TABLE_COVERAGE_FRACTION_REGEXP = /\((\d+)\/(\d+)\)/; + +const MIN_REQUIRED_LINE_COVERAGE_PERCENTAGE = 100; +const MIN_REQUIRED_STATEMENT_COVERAGE_PERCENTAGE = 100; +const MIN_REQUIRED_BRANCH_COVERAGE_PERCENTAGE = 100; +const MIN_REQUIRED_FUNCTION_COVERAGE_PERCENTAGE = 100; + +const CoverageLevel = Object.freeze({ + DOES_NOT_MEET_MINIMUM: 0, + MEETS_MINIMUM_WITH_SLACK: 1, + MEETS_MINIMUM: 2, +}); + +const NUM_COLUMNS = 5; + +const TOTAL_UNITS_SLACK_THRESHOLD = 8; +const COVERAGE_SLACK_PERCENTAGE = 5; + +const PASS_WITH_SLACK_WHITELIST_FILE = "script/coverage/passWithSlackWhitelist.txt" + +function loadExclusionSet(filePath) { + try { + return new Set( + fs.readFileSync(filePath, "utf8") + .split("\n") + .map(l => l.trim()) + .filter(l => l !== "" && !l.startsWith("//")) + ); + } catch (err) { + console.warn(`[coverage] exclusion file not found: ${filePath} – using empty set`); + return new Set(); + } +} + +const FUNCTION_EXCLUSION_LIST = loadExclusionSet("script/coverage/excludedFunctions.txt"); +const FILE_EXCLUSION_LIST = loadExclusionSet("script/coverage/excludedFiles.txt"); + +function pct(a, b) { + return b === 0 ? "100.00%" : `${(a * 100 / b).toFixed(2)}%`; +} + +function getNonWhitelistedPassWithSlackFiles(aboveThresholdWithSlackFileNames) { + if (aboveThresholdWithSlackFileNames.length == 0) { + return []; + } + let passWithSlackWhitelist = fs.readFileSync(PASS_WITH_SLACK_WHITELIST_FILE, "utf8").trim(); + if (passWithSlackWhitelist.length == 0) { + return aboveThresholdWithSlackFileNames; + } + + passWithSlackWhitelist = new Set(passWithSlackWhitelist.split("\n")); + return aboveThresholdWithSlackFileNames.filter(fileName => !passWithSlackWhitelist.has(fileName)); +} + +function parseCoverageDetails(rawCoveragePercentText) { + const match = COVERAGE_TABLE_COVERAGE_FRACTION_REGEXP.exec(rawCoveragePercentText); + + if (match) { + return { + coveredUnits: parseInt(match[1]), + totalUnits: parseInt(match[2]), + } + } + throw new Error(`Unparseable input: ${rawCoveragePercentText}`); +} + +function parseCoverageTableRow(rawRowText) { + const cols = rawRowText.split(COVERAGE_TABLE_COLUMN_DELIM); + if (cols.length - 2 !== NUM_COLUMNS) return null; + + const c = cols.slice(1, -1).map(s => s.trim()); + const fileName = c[0]; + + const excluded = [...FUNCTION_EXCLUSION_LIST] + .filter(tag => { + if (tag.includes("#")) { + const [path, fn] = tag.split("#"); + if (path === "" && fn) return true; + return tag === `${fileName}#${fn}`; + } + return false; + }).length; + + const lineCoverage = parseCoverageDetails(c[1]); + const statementCoverage = parseCoverageDetails(c[2]); + + const rawBranch = parseCoverageDetails(c[3]); + const branchTotal = Math.max(rawBranch.totalUnits - excluded, 0); + const branchCoverage = { + coveredUnits: Math.min(rawBranch.coveredUnits, branchTotal), + totalUnits: branchTotal, + }; + + const rawFunc = parseCoverageDetails(c[4]); + const funcTotal = Math.max(rawFunc.totalUnits - excluded, 0); + const functionCoverage = { + coveredUnits: Math.min(rawFunc.coveredUnits, funcTotal), + totalUnits: funcTotal, + }; + + return { + fileName, + lineCoverage, + statementCoverage, + branchCoverage, + functionCoverage, + _origCols: c, + _excluded: excluded, + }; +} + +function getCoverageLevel(coverageDetail, minCoveragePercent) { + let coveragePercent = 100; + if (coverageDetail.totalUnits > 0) { + coveragePercent = (coverageDetail.coveredUnits / coverageDetail.totalUnits) * 100; + } + + if (coveragePercent >= minCoveragePercent) { + return CoverageLevel.MEETS_MINIMUM; + } else if (coverageDetail.totalUnits <= TOTAL_UNITS_SLACK_THRESHOLD && (coveragePercent >= minCoveragePercent - COVERAGE_SLACK_PERCENTAGE)) { + return CoverageLevel.MEETS_MINIMUM_WITH_SLACK; + } + return CoverageLevel.DOES_NOT_MEET_MINIMUM; +} + +function getFileCoverageLevel(coverageRow) { + return Math.min( + getCoverageLevel(coverageRow.lineCoverage, MIN_REQUIRED_LINE_COVERAGE_PERCENTAGE), + getCoverageLevel(coverageRow.statementCoverage, MIN_REQUIRED_STATEMENT_COVERAGE_PERCENTAGE), + getCoverageLevel(coverageRow.branchCoverage, MIN_REQUIRED_BRANCH_COVERAGE_PERCENTAGE), + getCoverageLevel(coverageRow.functionCoverage, MIN_REQUIRED_FUNCTION_COVERAGE_PERCENTAGE) + ); +} + +function getFormattedCoverageTableRowsTest(coverageTableRows, tableHeader) { + const separator = '+' + '='.repeat(tableHeader.length - 2) + '+'; + return tableHeader + '\n' + + separator + '\n' + + coverageTableRows.join('\n') + '\n'; +} + +function rebuildRow(rowObj) { + const c = [...rowObj._origCols]; + if (rowObj._excluded === 0) return "|" + c.join(" | ") + " |"; + + c[3] = `${pct(rowObj.branchCoverage.coveredUnits, rowObj.branchCoverage.totalUnits)} ` + + `(${rowObj.branchCoverage.coveredUnits}/${rowObj.branchCoverage.totalUnits})`; + + c[4] = `${pct(rowObj.functionCoverage.coveredUnits, rowObj.functionCoverage.totalUnits)} ` + + `(${rowObj.functionCoverage.coveredUnits}/${rowObj.functionCoverage.totalUnits})`; + + return "|" + c.join(" | ") + " |"; +} + +(async function main() { + const coverateReportFileName = process.argv[2]; + const coverageReportRawText = fs.readFileSync(coverateReportFileName, "utf8"); + + let coverageTableBodyRaw = ""; + let actualTableHeader = ""; + try { + const lines = coverageReportRawText.split('\n'); + let headerIndex = -1; + + for (let i = 0; i < lines.length; i++) { + if (COVERAGE_TABLE_HEADER_PATTERN.test(lines[i])) { + headerIndex = i; + actualTableHeader = lines[i]; + break; + } + } + + if (headerIndex === -1) { + throw new Error("Coverage table header not found"); + } + + coverageTableBodyRaw = lines.slice(headerIndex + 1).join('\n'); + } catch (error) { + console.error("Unexpected coverage report format"); + console.error(error); + process.exit(1); + } + + const belowThresholdFiles = []; + const aboveThresholdWithSlackFileRows = []; + const aboveThresholdWithSlackFileNames = []; + const aboveThresholdFiles = []; + let totalCoverageRow = ""; + + let adjLineCov = 0, adjLineTot = 0; + let adjStmtCov = 0, adjStmtTot = 0; + let adjBrCov = 0, adjBrTot = 0; + let adjFnCov = 0, adjFnTot = 0; + + const allRows = coverageTableBodyRaw.split("\n"); + const coverageTableRows = allRows.filter((row, index) => { + return row.trim() !== '' && !row.trim().startsWith('+') && !row.trim().startsWith('╰') && !row.trim().startsWith('╭'); + }); + + for (const rawRow of coverageTableRows) { + const row = parseCoverageTableRow(rawRow); + if (!row) continue; + + if (FILE_EXCLUSION_LIST.has(row.fileName)) { + continue; + } + + const prettyRow = rebuildRow(row); + + if (row.fileName === COVERAGE_TABLE_TOTAL_ROW_NAME) { + totalCoverageRow = prettyRow; + continue; + } + + switch (getFileCoverageLevel(row)) { + case CoverageLevel.MEETS_MINIMUM: + aboveThresholdFiles.push(prettyRow); + break; + case CoverageLevel.MEETS_MINIMUM_WITH_SLACK: + aboveThresholdWithSlackFileRows.push(prettyRow); + aboveThresholdWithSlackFileNames.push(row.fileName); + break; + case CoverageLevel.DOES_NOT_MEET_MINIMUM: + belowThresholdFiles.push(prettyRow); + break; + } + + adjLineCov += row.lineCoverage.coveredUnits; + adjLineTot += row.lineCoverage.totalUnits; + + adjStmtCov += row.statementCoverage.coveredUnits; + adjStmtTot += row.statementCoverage.totalUnits; + + adjBrCov += row.branchCoverage.coveredUnits; + adjBrTot += row.branchCoverage.totalUnits; + + adjFnCov += row.functionCoverage.coveredUnits; + adjFnTot += row.functionCoverage.totalUnits; + } + + const nonWhitelistedPassWithSlackFiles = + getNonWhitelistedPassWithSlackFiles(aboveThresholdWithSlackFileNames); + + if (aboveThresholdFiles.length > 0) { + console.log("Files above coverage threshold: "); + console.log(getFormattedCoverageTableRowsTest(aboveThresholdFiles, actualTableHeader)); + } + + console.log("\nAdjusted total coverage (after exclusions):"); + console.log( + ` Lines : ${pct(adjLineCov, adjLineTot )} (${adjLineCov}/${adjLineTot})\n` + + ` Statements: ${pct(adjStmtCov, adjStmtTot)} (${adjStmtCov}/${adjStmtTot})\n` + + ` Branches : ${pct(adjBrCov, adjBrTot )} (${adjBrCov}/${adjBrTot})\n` + + ` Functions : ${pct(adjFnCov, adjFnTot )} (${adjFnCov}/${adjFnTot})` + ); + + const hasCoverageViolation = belowThresholdFiles.length > 0 || nonWhitelistedPassWithSlackFiles.length > 0; + if (hasCoverageViolation) { + if (belowThresholdFiles.length > 0) { + console.error("\nFiles below minimum coverage threshold:"); + console.error(getFormattedCoverageTableRowsTest(belowThresholdFiles, actualTableHeader)); + } + if (nonWhitelistedPassWithSlackFiles.length > 0) { + console.error("\nFiles meeting threshold only with slack and not whitelisted:"); + for (const file of nonWhitelistedPassWithSlackFiles) { + console.error(" -", file); + } + } + process.exit(2); + } +})(); diff --git a/script/coverage/excludedFiles.txt b/script/coverage/excludedFiles.txt new file mode 100644 index 0000000..e69de29 diff --git a/script/coverage/excludedFunctions.txt b/script/coverage/excludedFunctions.txt new file mode 100644 index 0000000..8148fa3 --- /dev/null +++ b/script/coverage/excludedFunctions.txt @@ -0,0 +1,2 @@ +// Functions excluded from branch/function coverage checks. +// Format: filepath#functionName or #functionName (for all files). diff --git a/script/coverage/passWithSlackWhitelist.txt b/script/coverage/passWithSlackWhitelist.txt new file mode 100644 index 0000000..e69de29 diff --git a/script/deploy/.gitkeep b/script/deploy/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/script/deploy/Addresses.sol b/script/deploy/Addresses.sol new file mode 100644 index 0000000..ca274fc --- /dev/null +++ b/script/deploy/Addresses.sol @@ -0,0 +1,53 @@ +/** + * Copyright 2026 Circle Internet Group, Inc. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + */ +pragma solidity ^0.8.20; + +import {Create3Factory} from "../../src/contracts/Create3Factory.sol"; +import {Salts} from "./Salts.sol"; +import {Vm} from "forge-std/Vm.sol"; + +/// @dev Predicts deterministic CREATE3 addresses from the CREATE3_FACTORY_ADDRESS +/// and per-contract salt env vars (see `Salts`). +/// Combines `Create3Factory.getAddress` with the salt derivation from `Salts` +/// so every script and test resolves addresses through a single entry point. +/// `feeManagerProxy()` checks `FEE_MANAGER_PROXY_ADDRESS` first, falling back +/// to the CREATE3-predicted address when the env var is unset or zero. +library Addresses { + address private constant _VM_ADDRESS = address(uint160(uint256(keccak256("hevm cheat code")))); + + function _factory() private view returns (Create3Factory) { + return Create3Factory(Vm(_VM_ADDRESS).envAddress("CREATE3_FACTORY_ADDRESS")); + } + + function feeManagerImpl() internal view returns (address) { + return _factory().getAddress(Salts.feeManagerImpl()); + } + + function feeManagerProxy() internal view returns (address) { + address fromEnv = Vm(_VM_ADDRESS).envOr("FEE_MANAGER_PROXY_ADDRESS", address(0)); + return fromEnv != address(0) ? fromEnv : _factory().getAddress(Salts.feeManagerProxy()); + } + + function tokenMessengerWithFeesImpl() internal view returns (address) { + return _factory().getAddress(Salts.tokenMessengerWithFeesImpl()); + } + + function tokenMessengerWithFeesProxy() internal view returns (address) { + return _factory().getAddress(Salts.tokenMessengerWithFeesProxy()); + } +} diff --git a/script/deploy/Broadcaster.sol b/script/deploy/Broadcaster.sol new file mode 100644 index 0000000..bd6079b --- /dev/null +++ b/script/deploy/Broadcaster.sol @@ -0,0 +1,45 @@ +/** + * Copyright 2026 Circle Internet Group, Inc. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + */ +pragma solidity ^0.8.20; + +import {Vm} from "forge-std/Vm.sol"; + +/// @dev Shared broadcast helper for deploy/upgrade scripts. +/// Supports two modes: +/// 1. **Live deployment** — set DEPLOYER_PRIVATE_KEY; derives the address +/// and broadcasts with the private key. +/// 2. **Offline prepare** — set DEPLOYER_ADDRESS instead; broadcasts with +/// an unlocked address (for Anvil forks). +library Broadcaster { + address private constant _VM_ADDRESS = address(uint160(uint256(keccak256("hevm cheat code")))); + + /// @dev Resolves the deployer and starts broadcasting. + /// Checks DEPLOYER_PRIVATE_KEY first; if unset or zero, falls back + /// to DEPLOYER_ADDRESS for unlocked-account mode. + function startBroadcast() internal returns (address deployer) { + Vm vm = Vm(_VM_ADDRESS); + uint256 pk = vm.envOr("DEPLOYER_PRIVATE_KEY", uint256(0)); + if (pk != 0) { + deployer = vm.addr(pk); + vm.startBroadcast(pk); + } else { + deployer = vm.envAddress("DEPLOYER_ADDRESS"); + vm.startBroadcast(deployer); + } + } +} diff --git a/script/deploy/DeployCreate3Factory.s.sol b/script/deploy/DeployCreate3Factory.s.sol new file mode 100644 index 0000000..b82a8fb --- /dev/null +++ b/script/deploy/DeployCreate3Factory.s.sol @@ -0,0 +1,44 @@ +/** + * Copyright 2026 Circle Internet Group, Inc. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + */ +pragma solidity ^0.8.20; + +import {Create3Factory} from "../../src/contracts/Create3Factory.sol"; +import {Broadcaster} from "./Broadcaster.sol"; +import {Script, console} from "forge-std/Script.sol"; + +/// @title DeployCreate3Factory +/// @notice Deploys the Create3Factory and sets the deployer as the initial +/// owner. This factory must be deployed once per chain before any +/// other Fee Orchestrator contract deployments. +/// +/// Required env vars (one of): +/// DEPLOYER_PRIVATE_KEY – private key used to broadcast transactions +/// DEPLOYER_ADDRESS – address for unlocked-account mode (Anvil) +contract DeployCreate3Factory is Script { + Create3Factory public factory; + + function run() external { + address deployer = Broadcaster.startBroadcast(); + factory = new Create3Factory(deployer); + + vm.stopBroadcast(); + + console.log("Create3Factory:", address(factory)); + console.log("Owner:", factory.owner()); + } +} diff --git a/script/deploy/DeployCreate3FactoryViaCreate2.s.sol b/script/deploy/DeployCreate3FactoryViaCreate2.s.sol new file mode 100644 index 0000000..1fc2e5f --- /dev/null +++ b/script/deploy/DeployCreate3FactoryViaCreate2.s.sol @@ -0,0 +1,84 @@ +/** + * Copyright 2026 Circle Internet Group, Inc. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + */ +pragma solidity ^0.8.20; + +import {Create3Factory} from "../../src/contracts/Create3Factory.sol"; +import {Salts} from "./Salts.sol"; +import {ICreate2Factory} from "./interfaces/ICreate2Factory.sol"; +import {Ownable2Step} from "@openzeppelin/contracts/access/Ownable2Step.sol"; +import {Script, console} from "forge-std/Script.sol"; + +/// @title DeployCreate3FactoryViaCreate2 +/// @notice Deploys Create3Factory via the canonical Create2Factory using +/// `deployAndMultiCall`, yielding a Create3Factory at a deterministic +/// address identical across every chain in an env that shares the same +/// `create2Factory`. The bootstrap tx: +/// +/// 1. Deploys Create3Factory (constructor arg = create2Factory; this is +/// why the address is chain-agnostic — owner does not enter bytecode). +/// 2. Multicall: Create3Factory.transferOwnership(deployer) executed by +/// create2Factory (allowed because msg.sender == initial owner). +/// +/// After this tx, Create3Factory._pendingOwner == deployer. The +/// `deployer` must follow up with a separate `acceptOwnership()` tx +/// (Ownable2Step requires the receiving owner to explicitly accept). +/// +/// @dev Existing `DeployCreate3Factory.s.sol` (plain `new`) is left untouched +/// and remains the path for one-off ops / local Anvil / downstream +/// deployment tooling. +/// +/// Required env vars: +/// CREATE2_FACTORY_CONTRACT_ADDRESS — address of the canonical Create2Factory +/// DEPLOYER_ADDRESS — address that will become the post-handoff +/// Create3Factory owner (encoded as the +/// transferOwnership target inside the multicall) +/// CREATE3_FACTORY_SALT — string salt; keccak256-hashed by Salts.sol +contract DeployCreate3FactoryViaCreate2 is Script { + error Create2FactoryAddressUnset(); + error DeployerAddressUnset(); + + Create3Factory public factory; + + function run() external { + address create2Factory = vm.envAddress("CREATE2_FACTORY_CONTRACT_ADDRESS"); + if (create2Factory == address(0)) revert Create2FactoryAddressUnset(); + + address deployer = vm.envAddress("DEPLOYER_ADDRESS"); + if (deployer == address(0)) revert DeployerAddressUnset(); + + bytes memory bytecode = abi.encodePacked(type(Create3Factory).creationCode, abi.encode(create2Factory)); + + bytes[] memory calls = new bytes[](1); + calls[0] = abi.encodeCall(Ownable2Step.transferOwnership, (deployer)); + + vm.startBroadcast(ICreate2Factory(create2Factory).owner()); + + address deployed = + ICreate2Factory(create2Factory).deployAndMultiCall(0, Salts.create3Factory(), bytecode, calls); + + vm.stopBroadcast(); + + factory = Create3Factory(deployed); + + console.log("Create3Factory deployed at:", deployed); + console.log("Initial owner (Create2Factory):", create2Factory); + console.log("Pending owner (post-multicall):", deployer); + console.log(""); + console.log("Next: deployer must call Create3Factory.acceptOwnership()"); + } +} diff --git a/script/deploy/DeployFeeManager.s.sol b/script/deploy/DeployFeeManager.s.sol new file mode 100644 index 0000000..a6ed486 --- /dev/null +++ b/script/deploy/DeployFeeManager.s.sol @@ -0,0 +1,245 @@ +/** + * Copyright 2026 Circle Internet Group, Inc. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + */ +pragma solidity ^0.8.20; + +import {Roles} from "../../src/constants/Roles.sol"; +import {Create3Factory} from "../../src/contracts/Create3Factory.sol"; +import {FeeManager} from "../../src/contracts/FeeManager.sol"; +import {IWithFeeCollectionAddress} from "../../src/interfaces/IWithFeeCollectionAddress.sol"; +import {IWithTrustedQuoteSigners} from "../../src/interfaces/IWithTrustedQuoteSigners.sol"; +import {Addresses} from "./Addresses.sol"; +import {Broadcaster} from "./Broadcaster.sol"; +import {Salts} from "./Salts.sol"; +import {IAccessControl} from "@openzeppelin/contracts/access/IAccessControl.sol"; +import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol"; +import {ERC1967Proxy} from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol"; +import {Script, console} from "forge-std/Script.sol"; + +struct DeployFeeManagerConfig { + Create3Factory factory; + bytes32 implSalt; + bytes32 proxySalt; + address owner; + address feeTaker; + address usdc; + address callerAdmin; + address signerAdmin; + address trustedSigner; + address pauser; + address feeCollectionAdmin; + address payable feeCollectionAddress; + address[] allowedCallers; +} + +/// @title DeployFeeManager +/// @notice Deploys FeeManager implementation and UUPS proxy via +/// Create3Factory for deterministic, bytecode-independent addresses. +/// Configures roles atomically via deployAndMultiCall and initiates +/// a two-step ownership transfer to the configured owner. +/// The owner must call acceptOwnership() separately after deployment. +/// Optionally sets an initial trusted quote signer. +/// Pre-registers the predicted TokenMessengerWithFees proxy address +/// as an allowed caller (always included). Additional allowed callers +/// can be supplied via FEE_MANAGER_ALLOWED_CALLERS. +/// +/// Required env vars (one of): +/// DEPLOYER_PRIVATE_KEY – private key used to broadcast transactions +/// DEPLOYER_ADDRESS – address for unlocked-account mode (Anvil) +/// +/// CREATE3_FACTORY_ADDRESS – address of the pre-deployed Create3Factory +/// FEE_MANAGER_PROXY_SALT – proxy CREATE3 salt (stable across impl upgrades) +/// FEE_MANAGER_IMPLEMENTATION_SALT – impl CREATE3 salt (bump per new impl) +/// TOKEN_MESSENGER_WITH_FEES_PROXY_SALT – needed to predict the TokenMessengerWithFees proxy for the allowed caller list +/// FEE_TAKER_ADDRESS – address of the deployed FeeTaker proxy on the target chain +/// USDC_ADDRESS – USDC token contract (same as FeeTaker's `usdc`); used for `FeeManager.isDenylisted` +/// +/// Optional env vars: +/// FEE_MANAGER_OWNER – final owner address; defaults to the deployer if unset +/// FEE_MANAGER_CALLER_ADMIN – if set and non-zero, granted CALLER_ADMIN_ROLE (omit for minimal privilege; owner grants later) +/// FEE_MANAGER_SIGNER_ADMIN – if set and non-zero, granted SIGNER_ADMIN_ROLE (omit for minimal privilege; owner grants later) +/// FEE_MANAGER_FEE_COLLECTION_ADMIN – if set and non-zero, granted FEE_COLLECTION_ADMIN_ROLE (omit for minimal privilege; owner grants later) +/// FEE_MANAGER_TRUSTED_SIGNER – if set and non-zero, registered as a trusted signer during deploy +/// FEE_MANAGER_PAUSER – if set and non-zero, granted PAUSER_ROLE during deploy +/// FEE_MANAGER_ALLOWED_CALLERS – comma-separated addresses granted ALLOWED_CALLER_ROLE (in addition to TMWF proxy) +/// FEE_MANAGER_FEE_COLLECTION_ADDRESS – if set and non-zero, the proxy's `feeCollectionAddress` is set to this value +/// atomically during deploy via a temporary FEE_COLLECTION_ADMIN_ROLE grant to the +/// Create3Factory (grant → setFeeCollectionAddress → revoke, all within the +/// deployAndMultiCall). Omit to leave the proxy's `feeCollectionAddress` unset +/// (default address(0)) so the owner/feeCollectionAdmin can set it later off-chain. +contract DeployFeeManager is Script { + FeeManager public feeManager; + address public implementation; + + DeployFeeManagerConfig private _config; + + /// @dev Reads deployment config from env vars. Called automatically by `forge script`. + function setUp() external { + _config = _loadConfigFromEnv(); + } + + /// @notice Deploy using config loaded from env vars (for `forge script`). + function run() public { + _run(_config); + } + + /// @notice Deploy using an explicit config struct (for tests — avoids vm.setEnv races). + function runWithConfig(DeployFeeManagerConfig memory config) public { + _run(config); + } + + function _run(DeployFeeManagerConfig memory c) private { + address deployer = Broadcaster.startBroadcast(); + + if (c.owner == address(0)) { + c.owner = deployer; + } + + implementation = c.factory + .deploy(c.implSalt, abi.encodePacked(type(FeeManager).creationCode, abi.encode(c.feeTaker, c.usdc))); + + bytes memory initData = abi.encodeCall(FeeManager.initialize, (address(c.factory))); + bytes memory proxyBytecode = + abi.encodePacked(type(ERC1967Proxy).creationCode, abi.encode(implementation, initData)); + + address proxy = c.factory.deployAndMultiCall(c.proxySalt, proxyBytecode, _buildProxyCalls(c)); + feeManager = FeeManager(payable(proxy)); + + vm.stopBroadcast(); + + _logDeployment(c); + console.log("\nPost-deploy: owner must call acceptOwnership() on proxy", address(feeManager)); + } + + function _loadConfigFromEnv() private view returns (DeployFeeManagerConfig memory c) { + c.factory = Create3Factory(vm.envAddress("CREATE3_FACTORY_ADDRESS")); + c.implSalt = Salts.feeManagerImpl(); + c.proxySalt = Salts.feeManagerProxy(); + c.owner = vm.envOr("FEE_MANAGER_OWNER", address(0)); + c.callerAdmin = vm.envOr("FEE_MANAGER_CALLER_ADMIN", address(0)); + c.signerAdmin = vm.envOr("FEE_MANAGER_SIGNER_ADMIN", address(0)); + c.trustedSigner = vm.envOr("FEE_MANAGER_TRUSTED_SIGNER", address(0)); + c.pauser = vm.envOr("FEE_MANAGER_PAUSER", address(0)); + c.feeCollectionAdmin = vm.envOr("FEE_MANAGER_FEE_COLLECTION_ADMIN", address(0)); + c.feeCollectionAddress = payable(vm.envOr("FEE_MANAGER_FEE_COLLECTION_ADDRESS", address(0))); + c.feeTaker = vm.envAddress("FEE_TAKER_ADDRESS"); + c.usdc = vm.envAddress("USDC_ADDRESS"); + + string memory extraCallersRaw = vm.envOr("FEE_MANAGER_ALLOWED_CALLERS", string("")); + address[] memory extraCallers; + if (bytes(extraCallersRaw).length > 0) { + extraCallers = vm.envAddress("FEE_MANAGER_ALLOWED_CALLERS", ","); + } else { + extraCallers = new address[](0); + } + c.allowedCallers = new address[](extraCallers.length + 1); + c.allowedCallers[0] = Addresses.tokenMessengerWithFeesProxy(); + for (uint256 i = 0; i < extraCallers.length; ++i) { + c.allowedCallers[i + 1] = extraCallers[i]; + } + } + + function _logDeployment(DeployFeeManagerConfig memory c) private view { + console.log("FeeManager implementation:", implementation); + console.log("FeeManager proxy:", address(feeManager)); + console.log("FeeTaker:", feeManager.feeTaker()); + console.log("USDC:", feeManager.usdcBlacklist()); + console.log("Pending owner:", c.owner); + console.log( + "CALLER_ADMIN_ROLE:", c.callerAdmin == address(0) ? "none (grant via owner)" : vm.toString(c.callerAdmin) + ); + console.log( + "SIGNER_ADMIN_ROLE:", c.signerAdmin == address(0) ? "none (grant via owner)" : vm.toString(c.signerAdmin) + ); + for (uint256 i = 0; i < c.allowedCallers.length; ++i) { + console.log("ALLOWED_CALLER_ROLE:", c.allowedCallers[i]); + } + console.log("PAUSER_ROLE:", c.pauser == address(0) ? "none" : vm.toString(c.pauser)); + console.log( + "FEE_COLLECTION_ADMIN_ROLE:", + c.feeCollectionAdmin == address(0) ? "none (grant via owner)" : vm.toString(c.feeCollectionAdmin) + ); + console.log( + "feeCollectionAddress:", + c.feeCollectionAddress == address(0) ? "unset (default address(0))" : vm.toString(c.feeCollectionAddress) + ); + console.log("Trusted signer:", c.trustedSigner == address(0) ? "none" : vm.toString(c.trustedSigner)); + } + + /// @dev Builds the multicall array for proxy role configuration. + function _buildProxyCalls(DeployFeeManagerConfig memory c) private pure returns (bytes[] memory calls) { + address factoryAddr = address(c.factory); + uint256 callCount = 5 + c.allowedCallers.length; + if (c.callerAdmin != address(0)) ++callCount; + if (c.signerAdmin != address(0)) ++callCount; + if (c.trustedSigner != address(0)) ++callCount; + if (c.pauser != address(0)) ++callCount; + if (c.feeCollectionAdmin != address(0)) ++callCount; + if (c.feeCollectionAddress != address(0)) callCount += 3; + + calls = new bytes[](callCount); + uint256 idx = 0; + + calls[idx] = abi.encodeCall(IAccessControl.grantRole, (Roles.CALLER_ADMIN_ROLE, factoryAddr)); + calls[++idx] = abi.encodeCall(IAccessControl.grantRole, (Roles.SIGNER_ADMIN_ROLE, factoryAddr)); + for (uint256 i = 0; i < c.allowedCallers.length; ++i) { + calls[++idx] = abi.encodeCall(IAccessControl.grantRole, (Roles.ALLOWED_CALLER_ROLE, c.allowedCallers[i])); + } + + if (c.callerAdmin != address(0)) { + calls[++idx] = abi.encodeCall(IAccessControl.grantRole, (Roles.CALLER_ADMIN_ROLE, c.callerAdmin)); + } + if (c.signerAdmin != address(0)) { + calls[++idx] = abi.encodeCall(IAccessControl.grantRole, (Roles.SIGNER_ADMIN_ROLE, c.signerAdmin)); + } + if (c.trustedSigner != address(0)) { + calls[++idx] = abi.encodeCall(IWithTrustedQuoteSigners.setTrustedQuoteSigner, (c.trustedSigner, true)); + } + if (c.pauser != address(0)) { + calls[++idx] = abi.encodeCall(IAccessControl.grantRole, (Roles.PAUSER_ROLE, c.pauser)); + } + if (c.feeCollectionAdmin != address(0)) { + calls[++idx] = + abi.encodeCall(IAccessControl.grantRole, (Roles.FEE_COLLECTION_ADMIN_ROLE, c.feeCollectionAdmin)); + } + if (c.feeCollectionAddress != address(0)) { + idx = _appendSetFeeCollectionAddressCalls(calls, idx, factoryAddr, c.feeCollectionAddress); + } + + calls[++idx] = abi.encodeCall(IAccessControl.revokeRole, (Roles.CALLER_ADMIN_ROLE, factoryAddr)); + calls[++idx] = abi.encodeCall(IAccessControl.revokeRole, (Roles.SIGNER_ADMIN_ROLE, factoryAddr)); + calls[++idx] = abi.encodeCall(Ownable.transferOwnership, (c.owner)); + } + + /// @dev Appends a temp-grant / setFeeCollectionAddress / revoke sequence to the multicall. + /// Mirrors the same temp-grant / use / revoke pattern used for CALLER_ADMIN_ROLE + + /// SIGNER_ADMIN_ROLE in `_buildProxyCalls`, but kept local since setFeeCollectionAddress + /// is the only call needing FEE_COLLECTION_ADMIN_ROLE and the factory must retain no + /// residual permission beyond this multicall. `idx` parameter is the last-written index + /// coming in; returns the new last-written index after the 3 appends. + function _appendSetFeeCollectionAddressCalls( + bytes[] memory calls, + uint256 idx, + address factoryAddr, + address payable feeCollectionAddress + ) private pure returns (uint256 newIdx) { + calls[++idx] = abi.encodeCall(IAccessControl.grantRole, (Roles.FEE_COLLECTION_ADMIN_ROLE, factoryAddr)); + calls[++idx] = abi.encodeCall(IWithFeeCollectionAddress.setFeeCollectionAddress, (feeCollectionAddress)); + calls[++idx] = abi.encodeCall(IAccessControl.revokeRole, (Roles.FEE_COLLECTION_ADMIN_ROLE, factoryAddr)); + return idx; + } +} diff --git a/script/deploy/DeployTokenMessengerWithFees.s.sol b/script/deploy/DeployTokenMessengerWithFees.s.sol new file mode 100644 index 0000000..fa44475 --- /dev/null +++ b/script/deploy/DeployTokenMessengerWithFees.s.sol @@ -0,0 +1,122 @@ +/** + * Copyright 2026 Circle Internet Group, Inc. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + */ +pragma solidity ^0.8.20; + +import {Create3Factory} from "../../src/contracts/Create3Factory.sol"; +import {TokenMessengerWithFees} from "../../src/contracts/TokenMessengerWithFees.sol"; +import {Addresses} from "./Addresses.sol"; +import {Broadcaster} from "./Broadcaster.sol"; +import {Salts} from "./Salts.sol"; +import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol"; +import {ERC1967Proxy} from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol"; +import {Script, console} from "forge-std/Script.sol"; + +struct DeployTokenMessengerWithFeesConfig { + Create3Factory factory; + address tokenMessengerV2; + address feeManagerProxy; + address owner; + bytes32 implSalt; + bytes32 proxySalt; +} + +/// @title DeployTokenMessengerWithFees +/// @notice Deploys TokenMessengerWithFees implementation and UUPS proxy via +/// Create3Factory for deterministic, bytecode-independent addresses. +/// Configures ownership atomically via deployAndMultiCall and initiates +/// a two-step ownership transfer to the configured owner. +/// The owner must call acceptOwnership() separately after deployment. +/// The proxy is pre-registered as an allowed caller on FeeManager +/// during FeeManager deployment (see DeployFeeManager.s.sol). +/// +/// Required env vars (one of): +/// DEPLOYER_PRIVATE_KEY – private key used to broadcast transactions +/// DEPLOYER_ADDRESS – address for unlocked-account mode (Anvil) +/// +/// CREATE3_FACTORY_ADDRESS – address of the pre-deployed Create3Factory +/// TOKEN_MESSENGER_WITH_FEES_PROXY_SALT – proxy CREATE3 salt (stable across impl upgrades) +/// TOKEN_MESSENGER_WITH_FEES_IMPLEMENTATION_SALT – impl CREATE3 salt (bump per new impl) +/// TOKEN_MESSENGER_V2_ADDRESS – address of the existing TokenMessengerV2 contract on the target chain +/// +/// Optional env vars: +/// TOKEN_MESSENGER_WITH_FEES_OWNER – final owner address; defaults to the deployer if unset +contract DeployTokenMessengerWithFees is Script { + TokenMessengerWithFees public tokenMessengerWithFees; + address public implementation; + + DeployTokenMessengerWithFeesConfig private _config; + + /// @dev Reads deployment config from env vars. Called automatically by `forge script`. + function setUp() external { + _config = _loadConfigFromEnv(); + } + + /// @notice Deploy using config loaded from env vars (for `forge script`). + function run() public { + _run(_config); + } + + /// @notice Deploy using an explicit config struct (for tests — avoids vm.setEnv races). + function runWithConfig(DeployTokenMessengerWithFeesConfig memory config) public { + _run(config); + } + + function _run(DeployTokenMessengerWithFeesConfig memory c) private { + address deployer = Broadcaster.startBroadcast(); + + if (c.owner == address(0)) { + c.owner = deployer; + } + + implementation = c.factory + .deploy( + c.implSalt, + abi.encodePacked( + type(TokenMessengerWithFees).creationCode, abi.encode(c.tokenMessengerV2, c.feeManagerProxy) + ) + ); + + bytes memory initData = abi.encodeCall(TokenMessengerWithFees.initialize, (address(c.factory))); + bytes memory proxyBytecode = + abi.encodePacked(type(ERC1967Proxy).creationCode, abi.encode(implementation, initData)); + + bytes[] memory calls = new bytes[](1); + calls[0] = abi.encodeCall(Ownable.transferOwnership, (c.owner)); + + address proxy = c.factory.deployAndMultiCall(c.proxySalt, proxyBytecode, calls); + tokenMessengerWithFees = TokenMessengerWithFees(proxy); + + vm.stopBroadcast(); + + console.log("TokenMessengerWithFees implementation:", implementation); + console.log("TokenMessengerWithFees proxy:", address(tokenMessengerWithFees)); + console.log("Pending owner:", c.owner); + console.log("TokenMessenger:", tokenMessengerWithFees.tokenMessenger()); + console.log("FeeManager:", tokenMessengerWithFees.feeManager()); + console.log("\nPost-deploy: owner must call acceptOwnership() on proxy", address(tokenMessengerWithFees)); + } + + function _loadConfigFromEnv() private view returns (DeployTokenMessengerWithFeesConfig memory c) { + c.factory = Create3Factory(vm.envAddress("CREATE3_FACTORY_ADDRESS")); + c.tokenMessengerV2 = vm.envAddress("TOKEN_MESSENGER_V2_ADDRESS"); + c.feeManagerProxy = Addresses.feeManagerProxy(); + c.owner = vm.envOr("TOKEN_MESSENGER_WITH_FEES_OWNER", address(0)); + c.implSalt = Salts.tokenMessengerWithFeesImpl(); + c.proxySalt = Salts.tokenMessengerWithFeesProxy(); + } +} diff --git a/script/deploy/PrintAddresses.s.sol b/script/deploy/PrintAddresses.s.sol new file mode 100644 index 0000000..c9ba0df --- /dev/null +++ b/script/deploy/PrintAddresses.s.sol @@ -0,0 +1,76 @@ +/** + * Copyright 2026 Circle Internet Group, Inc. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + */ +pragma solidity ^0.8.20; + +import {Addresses} from "./Addresses.sol"; +import {Script, console} from "forge-std/Script.sol"; + +/// @title PrintAddresses +/// @notice Prints CREATE3-predicted deployment addresses from `Addresses` (no transactions, no gas). +/// +/// Usage: +/// forge script script/deploy/PrintAddresses.s.sol --rpc-url $BLOCKCHAIN_RPC_URL +/// forge script script/deploy/PrintAddresses.s.sol --rpc-url $BLOCKCHAIN_RPC_URL --sig feeManagerProxyAddress +/// +/// `getAddress` is executed on-chain against `CREATE3_FACTORY_ADDRESS`; use the RPC for the chain where that +/// factory is deployed (omit localhost unless the factory exists there). +/// +/// Required env vars (same as deploy scripts): +/// CREATE3_FACTORY_ADDRESS +/// FEE_MANAGER_PROXY_SALT +/// FEE_MANAGER_IMPLEMENTATION_SALT +/// TOKEN_MESSENGER_WITH_FEES_PROXY_SALT +/// TOKEN_MESSENGER_WITH_FEES_IMPLEMENTATION_SALT +contract PrintAddresses is Script { + /// @dev Short custom error for solhint (no long reason strings on `require`). + error FactoryHasNoCode(); + + function run() external view { + _checkFactory(); + console.log("Create3Factory: ", vm.envAddress("CREATE3_FACTORY_ADDRESS")); + console.log("FeeManager implementation: ", Addresses.feeManagerImpl()); + console.log("FeeManager proxy: ", Addresses.feeManagerProxy()); + console.log("TokenMessengerWithFees impl: ", Addresses.tokenMessengerWithFeesImpl()); + console.log("TokenMessengerWithFees proxy: ", Addresses.tokenMessengerWithFeesProxy()); + } + + function feeManagerImplAddress() external view { + _checkFactory(); + console.log(Addresses.feeManagerImpl()); + } + + function feeManagerProxyAddress() external view { + _checkFactory(); + console.log(Addresses.feeManagerProxy()); + } + + function tokenMessengerWithFeesImplAddress() external view { + _checkFactory(); + console.log(Addresses.tokenMessengerWithFeesImpl()); + } + + function tokenMessengerWithFeesProxyAddress() external view { + _checkFactory(); + console.log(Addresses.tokenMessengerWithFeesProxy()); + } + + function _checkFactory() private view { + address factory = vm.envAddress("CREATE3_FACTORY_ADDRESS"); + if (factory.code.length == 0) revert FactoryHasNoCode(); + } +} diff --git a/script/deploy/Salts.sol b/script/deploy/Salts.sol new file mode 100644 index 0000000..ff343f8 --- /dev/null +++ b/script/deploy/Salts.sol @@ -0,0 +1,55 @@ +/** + * Copyright 2026 Circle Internet Group, Inc. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + */ +pragma solidity ^0.8.20; + +import {Vm} from "forge-std/Vm.sol"; + +/// @dev Derives deterministic CREATE3 / CREATE2 salts from per-contract env vars. +/// Create3Factory bootstrap (via Create2Factory.deployAndMultiCall): +/// CREATE3_FACTORY_SALT — used by the bootstrap to land Create3Factory at a +/// deterministic CREATE2 address. Same across chains in +/// an env that share the same create2Factory. +/// FeeManager: +/// FEE_MANAGER_PROXY_SALT — stable across impl upgrades; same across chains for identical proxy addresses. +/// FEE_MANAGER_IMPLEMENTATION_SALT — bump when deploying a new implementation (avoids CREATE3 collision). +/// TokenMessengerWithFees: +/// TOKEN_MESSENGER_WITH_FEES_PROXY_SALT — same semantics as FEE_MANAGER_PROXY_SALT. +/// TOKEN_MESSENGER_WITH_FEES_IMPLEMENTATION_SALT — same semantics as FEE_MANAGER_IMPLEMENTATION_SALT. +library Salts { + address private constant _VM_ADDRESS = address(uint160(uint256(keccak256("hevm cheat code")))); + + function create3Factory() internal view returns (bytes32) { + return keccak256(abi.encodePacked(Vm(_VM_ADDRESS).envString("CREATE3_FACTORY_SALT"))); + } + + function feeManagerImpl() internal view returns (bytes32) { + return keccak256(abi.encodePacked(Vm(_VM_ADDRESS).envString("FEE_MANAGER_IMPLEMENTATION_SALT"))); + } + + function feeManagerProxy() internal view returns (bytes32) { + return keccak256(abi.encodePacked(Vm(_VM_ADDRESS).envString("FEE_MANAGER_PROXY_SALT"))); + } + + function tokenMessengerWithFeesImpl() internal view returns (bytes32) { + return keccak256(abi.encodePacked(Vm(_VM_ADDRESS).envString("TOKEN_MESSENGER_WITH_FEES_IMPLEMENTATION_SALT"))); + } + + function tokenMessengerWithFeesProxy() internal view returns (bytes32) { + return keccak256(abi.encodePacked(Vm(_VM_ADDRESS).envString("TOKEN_MESSENGER_WITH_FEES_PROXY_SALT"))); + } +} diff --git a/script/deploy/UpgradeFeeManager.s.sol b/script/deploy/UpgradeFeeManager.s.sol new file mode 100644 index 0000000..7f5ede5 --- /dev/null +++ b/script/deploy/UpgradeFeeManager.s.sol @@ -0,0 +1,121 @@ +/** + * Copyright 2026 Circle Internet Group, Inc. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + */ +pragma solidity ^0.8.20; + +import {Create3Factory} from "../../src/contracts/Create3Factory.sol"; +import {FeeManager} from "../../src/contracts/FeeManager.sol"; +import {Broadcaster} from "./Broadcaster.sol"; +import {Salts} from "./Salts.sol"; +import {Script, console} from "forge-std/Script.sol"; + +/// @dev Parameters for deploying a new FeeManager implementation via CREATE3. +struct UpgradeFeeManagerImplConfig { + Create3Factory factory; + bytes32 implSalt; + address feeTaker; + address usdc; +} + +/// @title UpgradeFeeManager +/// @notice Deploys a new `FeeManager` implementation via `Create3Factory` and/or upgrades the UUPS proxy. +/// +/// Bump `FEE_MANAGER_IMPLEMENTATION_SALT` before deploying a new implementation so CREATE3 does not +/// collide with the previous implementation. +/// +/// Required env vars (all flows, one of): +/// DEPLOYER_PRIVATE_KEY – private key used to broadcast transactions +/// DEPLOYER_ADDRESS – address for unlocked-account mode (Anvil) +/// +/// CREATE3_FACTORY_ADDRESS +/// FEE_MANAGER_IMPLEMENTATION_SALT +/// FEE_TAKER_ADDRESS +/// USDC_ADDRESS +/// +/// Additional for `run()` and `upgradeProxyOnly()`: +/// FEE_MANAGER_PROXY_ADDRESS +/// +/// Additional for `upgradeProxyOnly()` when the implementation was deployed out-of-band: +/// FEE_MANAGER_IMPLEMENTATION_ADDRESS +contract UpgradeFeeManager is Script { + address public implementation; + + // ── Deploy implementation only ────────────────────────────────────── + + /// @notice Deploy a new implementation (reads config from env vars). + function deployImplementationOnly() external { + deployImplementationOnlyWithConfig(_loadImplConfigFromEnv()); + } + + /// @notice Deploy a new implementation (explicit config — for tests). + function deployImplementationOnlyWithConfig(UpgradeFeeManagerImplConfig memory config) public { + Broadcaster.startBroadcast(); + implementation = _deployImplementation(config); + vm.stopBroadcast(); + console.log("FeeManager implementation:", implementation); + } + + // ── Upgrade proxy only ────────────────────────────────────────────── + + /// @notice Upgrade the proxy to `FEE_MANAGER_IMPLEMENTATION_ADDRESS` (reads from env). + function upgradeProxyOnly() external { + upgradeProxyOnlyWithConfig( + vm.envAddress("FEE_MANAGER_PROXY_ADDRESS"), vm.envAddress("FEE_MANAGER_IMPLEMENTATION_ADDRESS") + ); + } + + /// @notice Upgrade the proxy to a specific implementation (explicit params — for tests). + function upgradeProxyOnlyWithConfig(address proxy, address impl) public { + Broadcaster.startBroadcast(); + FeeManager(payable(proxy)).upgradeToAndCall(impl, ""); + vm.stopBroadcast(); + console.log("FeeManager proxy upgraded:", proxy); + console.log("New implementation:", impl); + } + + // ── Deploy implementation + upgrade proxy ─────────────────────────── + + /// @notice Deploy implementation then upgrade `FEE_MANAGER_PROXY_ADDRESS` to it (reads from env). + function run() external { + runWithConfig(_loadImplConfigFromEnv(), vm.envAddress("FEE_MANAGER_PROXY_ADDRESS")); + } + + /// @notice Deploy implementation then upgrade proxy (explicit config — for tests). + function runWithConfig(UpgradeFeeManagerImplConfig memory config, address proxy) public { + Broadcaster.startBroadcast(); + implementation = _deployImplementation(config); + FeeManager(payable(proxy)).upgradeToAndCall(implementation, ""); + vm.stopBroadcast(); + console.log("FeeManager implementation:", implementation); + console.log("FeeManager proxy:", proxy); + } + + // ── Internal ──────────────────────────────────────────────────────── + + function _loadImplConfigFromEnv() private view returns (UpgradeFeeManagerImplConfig memory c) { + c.factory = Create3Factory(vm.envAddress("CREATE3_FACTORY_ADDRESS")); + c.implSalt = Salts.feeManagerImpl(); + c.feeTaker = vm.envAddress("FEE_TAKER_ADDRESS"); + c.usdc = vm.envAddress("USDC_ADDRESS"); + } + + function _deployImplementation(UpgradeFeeManagerImplConfig memory c) private returns (address) { + return + c.factory + .deploy(c.implSalt, abi.encodePacked(type(FeeManager).creationCode, abi.encode(c.feeTaker, c.usdc))); + } +} diff --git a/script/deploy/UpgradeTokenMessengerWithFees.s.sol b/script/deploy/UpgradeTokenMessengerWithFees.s.sol new file mode 100644 index 0000000..6953647 --- /dev/null +++ b/script/deploy/UpgradeTokenMessengerWithFees.s.sol @@ -0,0 +1,132 @@ +/** + * Copyright 2026 Circle Internet Group, Inc. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + */ +pragma solidity ^0.8.20; + +import {Create3Factory} from "../../src/contracts/Create3Factory.sol"; +import {TokenMessengerWithFees} from "../../src/contracts/TokenMessengerWithFees.sol"; +import {Addresses} from "./Addresses.sol"; +import {Broadcaster} from "./Broadcaster.sol"; +import {Salts} from "./Salts.sol"; +import {Script, console} from "forge-std/Script.sol"; + +/// @dev Parameters for deploying a new TokenMessengerWithFees implementation via CREATE3. +struct UpgradeTmwfImplConfig { + Create3Factory factory; + bytes32 implSalt; + address tokenMessengerV2; + address feeManagerProxy; +} + +/// @title UpgradeTokenMessengerWithFees +/// @notice Deploys a new `TokenMessengerWithFees` implementation via `Create3Factory` and/or upgrades the UUPS proxy. +/// +/// Bump `TOKEN_MESSENGER_WITH_FEES_IMPLEMENTATION_SALT` before deploying a new implementation so CREATE3 does not +/// collide with the previous implementation. +/// +/// Required env vars (all flows, one of): +/// DEPLOYER_PRIVATE_KEY – private key used to broadcast transactions +/// DEPLOYER_ADDRESS – address for unlocked-account mode (Anvil) +/// +/// CREATE3_FACTORY_ADDRESS +/// TOKEN_MESSENGER_WITH_FEES_IMPLEMENTATION_SALT +/// TOKEN_MESSENGER_V2_ADDRESS +/// +/// Optional: +/// FEE_MANAGER_PROXY_ADDRESS – FeeManager UUPS proxy for the new implementation's immutable `feeManager`. +/// If unset, `Addresses.feeManagerProxy()` derives it from +/// `FEE_MANAGER_PROXY_SALT` + `CREATE3_FACTORY_ADDRESS`. +/// +/// Additional for `run()` and `upgradeProxyOnly()`: +/// TOKEN_MESSENGER_WITH_FEES_PROXY_ADDRESS +/// +/// Additional for `upgradeProxyOnly()` when the implementation was deployed out-of-band: +/// TOKEN_MESSENGER_WITH_FEES_IMPLEMENTATION_ADDRESS +contract UpgradeTokenMessengerWithFees is Script { + address public implementation; + + // ── Deploy implementation only ────────────────────────────────────── + + /// @notice Deploy a new implementation (reads config from env vars). + function deployImplementationOnly() external { + deployImplementationOnlyWithConfig(_loadImplConfigFromEnv()); + } + + /// @notice Deploy a new implementation (explicit config — for tests). + function deployImplementationOnlyWithConfig(UpgradeTmwfImplConfig memory config) public { + Broadcaster.startBroadcast(); + implementation = _deployImplementation(config); + vm.stopBroadcast(); + console.log("TokenMessengerWithFees implementation:", implementation); + } + + // ── Upgrade proxy only ────────────────────────────────────────────── + + /// @notice Upgrade the proxy to `TOKEN_MESSENGER_WITH_FEES_IMPLEMENTATION_ADDRESS` (reads from env). + function upgradeProxyOnly() external { + upgradeProxyOnlyWithConfig( + vm.envAddress("TOKEN_MESSENGER_WITH_FEES_PROXY_ADDRESS"), + vm.envAddress("TOKEN_MESSENGER_WITH_FEES_IMPLEMENTATION_ADDRESS") + ); + } + + /// @notice Upgrade the proxy to a specific implementation (explicit params — for tests). + function upgradeProxyOnlyWithConfig(address proxy, address impl) public { + Broadcaster.startBroadcast(); + TokenMessengerWithFees(proxy).upgradeToAndCall(impl, ""); + vm.stopBroadcast(); + console.log("TokenMessengerWithFees proxy upgraded:", proxy); + console.log("New implementation:", impl); + } + + // ── Deploy implementation + upgrade proxy ─────────────────────────── + + /// @notice Deploy implementation then upgrade proxy (reads from env). + function run() external { + runWithConfig(_loadImplConfigFromEnv(), vm.envAddress("TOKEN_MESSENGER_WITH_FEES_PROXY_ADDRESS")); + } + + /// @notice Deploy implementation then upgrade proxy (explicit config — for tests). + function runWithConfig(UpgradeTmwfImplConfig memory config, address proxy) public { + Broadcaster.startBroadcast(); + implementation = _deployImplementation(config); + TokenMessengerWithFees(proxy).upgradeToAndCall(implementation, ""); + vm.stopBroadcast(); + console.log("TokenMessengerWithFees implementation:", implementation); + console.log("TokenMessengerWithFees proxy:", proxy); + } + + // ── Internal ──────────────────────────────────────────────────────── + + function _loadImplConfigFromEnv() private view returns (UpgradeTmwfImplConfig memory c) { + c.factory = Create3Factory(vm.envAddress("CREATE3_FACTORY_ADDRESS")); + c.implSalt = Salts.tokenMessengerWithFeesImpl(); + c.tokenMessengerV2 = vm.envAddress("TOKEN_MESSENGER_V2_ADDRESS"); + c.feeManagerProxy = Addresses.feeManagerProxy(); + } + + function _deployImplementation(UpgradeTmwfImplConfig memory c) private returns (address) { + console.log("TokenMessengerWithFees impl uses feeManager immutable:", c.feeManagerProxy); + return c.factory + .deploy( + c.implSalt, + abi.encodePacked( + type(TokenMessengerWithFees).creationCode, abi.encode(c.tokenMessengerV2, c.feeManagerProxy) + ) + ); + } +} diff --git a/script/deploy/interfaces/ICreate2Factory.sol b/script/deploy/interfaces/ICreate2Factory.sol new file mode 100644 index 0000000..9e1af99 --- /dev/null +++ b/script/deploy/interfaces/ICreate2Factory.sol @@ -0,0 +1,36 @@ +/** + * Copyright 2026 Circle Internet Group, Inc. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + */ +pragma solidity ^0.8.20; + +/// @notice Minimal interface for the canonical CCTP Create2Factory. Mirrors +/// the subset used by fee-orchestrator's bootstrap flow. +interface ICreate2Factory { + function owner() external view returns (address); + + /// @notice CREATE2-deploys `bytecode` at `salt`, then executes each + /// element of `data` on the resulting contract from the factory's + /// context (msg.sender = Create2Factory during the multicall). + /// @param amount Value (wei) forwarded to the CREATE2 opcode. + /// @param salt CREATE2 salt. + /// @param bytecode Creation bytecode (with constructor args appended). + /// @param data Calls to forward to the newly-deployed contract. + function deployAndMultiCall(uint256 amount, bytes32 salt, bytes calldata bytecode, bytes[] calldata data) + external + payable + returns (address); +} diff --git a/script/lint/check-foundry-settings.sh b/script/lint/check-foundry-settings.sh new file mode 100755 index 0000000..29ca559 --- /dev/null +++ b/script/lint/check-foundry-settings.sh @@ -0,0 +1,71 @@ +#!/usr/bin/env bash +# Defense-in-depth lint: assert foundry.toml settings that affect produced +# bytecode are exactly what the published predicted-address set assumes. +# +# This repo's CREATE3 deployment tree is rooted at Create3Factory, which is +# itself deployed via CREATE2. Create3Factory's CREATE2 address depends on its +# own creation bytecode, and every downstream CREATE3 address depends on +# Create3Factory's address. So changing any of these foundry.toml settings +# shifts Create3Factory's address and transitively shifts every CREATE3- +# derived address downstream, silently invalidating any predicted addresses +# already shared with downstream consumers. +# +# The Create3FactoryBytecodePin.t.sol test in test/ is the authoritative guard +# (it pins the actual Create3Factory bytecode hash). This lint runs earlier in +# CI and gives a clearer error message when a single foundry.toml line is +# changed. +# +# If the change is intentional, update the EXPECTED_* values in the +# Create3FactoryBytecodePin test AND the PINS array below AND coordinate an +# address republish with downstream consumers BEFORE merging. + +set -euo pipefail +HERE="$(cd "$(dirname "$0")" && pwd)" +FOUNDRY_TOML="$HERE/../../foundry.toml" + +if [ ! -f "$FOUNDRY_TOML" ]; then + echo "error: foundry.toml not found at $FOUNDRY_TOML" >&2 + exit 1 +fi + +# Pinned key=value pairs that, if changed, change Create3Factory's creation +# bytecode and therefore shift its CREATE2 address along with every +# CREATE3-derived address that uses it. +declare -a PINS=( + 'solc_version = "0.8.24"' + 'auto_detect_solc = false' + "evm_version = 'cancun'" + 'optimizer = true' + 'optimizer_runs = 200' + 'via_ir = true' +) + +fail=0 +for pin in "${PINS[@]}"; do + if ! grep -qxF "$pin" "$FOUNDRY_TOML"; then + echo " MISSING: $pin" >&2 + fail=1 + fi +done + +if [ "$fail" -ne 0 ]; then + cat >&2 < "$tmp" + # If the file leads with a bare SPDX comment, absorb it into the block; + # otherwise keep all existing content below the new header. + if [ "$(sed -n '1p' "$file")" = "// SPDX-License-Identifier: Apache-2.0" ]; then + tail -n +2 "$file" >> "$tmp" + else + cat "$file" >> "$tmp" + fi + mv "$tmp" "$file" + echo "added copyright header to $file" + fixed=1 +done + +if [ "$fixed" -ne 0 ]; then + echo "Copyright headers were added. Review and re-stage the changed files, then commit again." + exit 1 +fi diff --git a/script/test/DepositForBurnWithGeneratedQuote.s.sol b/script/test/DepositForBurnWithGeneratedQuote.s.sol new file mode 100644 index 0000000..a654a31 --- /dev/null +++ b/script/test/DepositForBurnWithGeneratedQuote.s.sol @@ -0,0 +1,167 @@ +/** + * Copyright 2026 Circle Internet Group, Inc. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + */ +pragma solidity ^0.8.20; + +import {QuoteTypes} from "../../src/constants/QuoteTypes.sol"; +import {IFeeManager} from "../../src/interfaces/IFeeManager.sol"; +import {ITokenMessengerWithFees} from "../../src/interfaces/ITokenMessengerWithFees.sol"; +import {QuoteScriptUtils} from "./QuoteScriptUtils.sol"; +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; +import {console} from "forge-std/console.sol"; + +/// @title DepositForBurnWithGeneratedQuote +/// @notice Builds a matching signed quote and calls TokenMessengerWithFees. +/// @dev Required env vars: +/// - CALLER_PRIVATE_KEY: EOA that submits the burn call +/// - QUOTE_SIGNER: private key used to sign quote digest +/// - TOKEN_MESSENGER_WITH_FEES_PROXY_ADDRESS: wrapper proxy address +/// - BURN_TOKEN_ADDRESS: token to burn (USDC) +/// Immutables on the wrapper are fixed at TMWF deploy: `tokenMessenger()` (CCTP TokenMessengerV2) and +/// `feeManager()` (FeeManager proxy). Fees are collected via `FeeManager.collectFeesWithQuote` on that +/// `feeManager` address. A revert such as call to non-contract on `0x..::collectFeesWithQuote` means +/// there is no contract at that FeeManager address on this RPC's chain (deploy the FeeManager proxy +/// there, or use a wrapper deployed with the correct FeeManager / salt prediction for this network). +/// Optional env vars: +/// - DESTINATION_DOMAIN: CCTP destination domain (default: 0 - Ethereum) +/// - MINT_RECIPIENT: bytes32 mint recipient on destination (default: caller address, left-padded) +/// - QUOTE_BASE_TIMESTAMP: unix timestamp used as "now" (default: block.timestamp) +/// - QUOTE_TYPES: comma-separated subset of {FORWARD, PRE_FINALITY} to include in the quote +/// (default: "FORWARD,PRE_FINALITY"; unknown entries, duplicates, empty tokens, and an +/// empty selection all revert). The attached `msg.value` is the sum of the included +/// items' fees and the wrapper picks the matching finality threshold and hook. +contract DepositForBurnWithGeneratedQuote is QuoteScriptUtils { + using SafeERC20 for IERC20; + + error WrapperNoCode(); + error BurnTokenNoCode(); + error TokenMessengerNoCode(); + error FeeManagerNoCode(); + + uint256 private constant BURN_AMOUNT = 500_000; // 0.5 USDC (6 decimals) + uint256 private constant FORWARD_COST = 0.00001 ether; + uint256 private constant PRE_FINALITY_COST = 0.000005 ether; + + struct Env { + uint256 callerPk; + uint256 quoteSignerPk; + address caller; + address wrapper; + address burnToken; + uint32 destinationDomain; + bytes32 mintRecipient; + address feeManager; + address tokenMessengerV2; + bool includeForward; + bool includePreFinality; + } + + function run() external { + Env memory e = _loadEnv(); + bytes memory signedQuote = _buildSignedQuote(e); + + uint256 nativeFeeValue = (e.includeForward ? FORWARD_COST : 0) + (e.includePreFinality ? PRE_FINALITY_COST : 0); + bytes32 destinationCaller = bytes32(0); + + // Use the script's broadcasting EOA as the refund target so any off-chain refund + // attribution emitted in `FeeCollected` points back at the caller of this script. + IFeeManager.QuoteClaim memory claim = + IFeeManager.QuoteClaim({signedQuote: signedQuote, refundAddress: payable(e.caller)}); + + vm.startBroadcast(e.callerPk); + IERC20(e.burnToken).forceApprove(e.wrapper, BURN_AMOUNT); + ITokenMessengerWithFees(e.wrapper).depositForBurnWithFees{value: nativeFeeValue}( + BURN_AMOUNT, e.destinationDomain, e.mintRecipient, e.burnToken, destinationCaller, claim + ); + vm.stopBroadcast(); + + _logRun(e, vm.addr(e.quoteSignerPk), destinationCaller, nativeFeeValue, signedQuote); + } + + function _loadEnv() private view returns (Env memory e) { + e.callerPk = vm.envUint("CALLER_PRIVATE_KEY"); + e.quoteSignerPk = vm.envUint("QUOTE_SIGNER"); + e.caller = vm.addr(e.callerPk); + e.wrapper = vm.envAddress("TOKEN_MESSENGER_WITH_FEES_PROXY_ADDRESS"); + e.burnToken = vm.envAddress("BURN_TOKEN_ADDRESS"); + if (e.wrapper.code.length == 0) revert WrapperNoCode(); + if (e.burnToken.code.length == 0) revert BurnTokenNoCode(); + e.tokenMessengerV2 = ITokenMessengerWithFees(e.wrapper).tokenMessenger(); + e.feeManager = ITokenMessengerWithFees(e.wrapper).feeManager(); + if (e.tokenMessengerV2.code.length == 0) revert TokenMessengerNoCode(); + if (e.feeManager.code.length == 0) revert FeeManagerNoCode(); + e.destinationDomain = uint32(vm.envOr("DESTINATION_DOMAIN", uint256(0))); + e.mintRecipient = vm.envOr("MINT_RECIPIENT", bytes32(uint256(uint160(e.caller)))); + (e.includeForward, e.includePreFinality) = _readSelectedQuoteTypes(); + } + + function _buildSignedQuote(Env memory e) private view returns (bytes memory) { + uint256 itemCount = (e.includeForward ? 1 : 0) + (e.includePreFinality ? 1 : 0); + IFeeManager.QuoteItem[] memory items = new IFeeManager.QuoteItem[](itemCount); + uint256 idx; + if (e.includeForward) { + items[idx] = IFeeManager.QuoteItem({ + quoteType: QuoteTypes.FORWARD, + quoteArgsHash: _forwardArgsHashForDepositWithFees( + e.wrapper, e.destinationDomain, e.burnToken, bytes32(0) + ), + amount: FORWARD_COST + }); + ++idx; + } + if (e.includePreFinality) { + items[idx] = IFeeManager.QuoteItem({ + quoteType: QuoteTypes.PRE_FINALITY, + quoteArgsHash: _preFinalityArgsHash(e.wrapper, e.destinationDomain, e.burnToken, BURN_AMOUNT), + amount: PRE_FINALITY_COST + }); + ++idx; + } + + uint256 baseTimestamp = vm.envOr("QUOTE_BASE_TIMESTAMP", uint256(block.timestamp)); + uint256 expiry = baseTimestamp + 1 hours; + (bytes memory signedQuote,,) = _buildAndSignQuote(e.quoteSignerPk, e.feeManager, items, address(0), expiry); + return signedQuote; + } + + function _logRun( + Env memory e, + address quoteSigner, + bytes32 destinationCaller, + uint256 nativeFeeValue, + bytes memory signedQuote + ) private pure { + console.log("caller:", e.caller); + console.log("quoteSigner:", quoteSigner); + console.log("wrapper:", e.wrapper); + console.log("tokenMessengerV2:", e.tokenMessengerV2); + console.log("feeManager:", e.feeManager); + console.log("burnToken:", e.burnToken); + console.log("burnAmount:", BURN_AMOUNT); + console.log("destinationDomain:", e.destinationDomain); + console.log("mintRecipient:"); + console.logBytes32(e.mintRecipient); + console.log("destinationCaller:"); + console.logBytes32(destinationCaller); + console.log("includeForward:", e.includeForward); + console.log("includePreFinality:", e.includePreFinality); + console.log("attachedNativeValue:", nativeFeeValue); + console.log("signedQuote:"); + console.logBytes(signedQuote); + } +} diff --git a/script/test/DepositForBurnWithSignedQuote.s.sol b/script/test/DepositForBurnWithSignedQuote.s.sol new file mode 100644 index 0000000..70dc6aa --- /dev/null +++ b/script/test/DepositForBurnWithSignedQuote.s.sol @@ -0,0 +1,159 @@ +/** + * Copyright 2026 Circle Internet Group, Inc. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + */ +pragma solidity ^0.8.20; + +import {IFeeManager} from "../../src/interfaces/IFeeManager.sol"; +import {ITokenMessengerWithFees} from "../../src/interfaces/ITokenMessengerWithFees.sol"; +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; +import {Script} from "forge-std/Script.sol"; +import {console} from "forge-std/console.sol"; + +/// @title DepositForBurnWithSignedQuote +/// @notice Calls TokenMessengerWithFees with a pre-signed quote supplied via env var. +/// @dev Unlike `DepositForBurnWithGeneratedQuote`, this script does **not** sign anything; +/// it assumes the caller already has a version-prefixed signed quote (e.g. from a +/// backend signer service). It reads the fee amount and token from the wrapper's +/// `getFee(signedQuote)` view and supports both fee modes: +/// - native fees (`feeToken == address(0)`) are attached as `msg.value`; +/// - ERC-20 fees are approved to the wrapper, which pulls them from the caller via +/// FeeManager. When the fee token equals the burn token a single combined allowance +/// (`burnAmount + feeAmount`) covers both the fee and burn pulls, since the wrapper +/// runs two separate `transferFrom`s and a second `forceApprove` would clobber the first. +/// +/// Required env vars: +/// - CALLER_PRIVATE_KEY: EOA that submits the burn call +/// - TOKEN_MESSENGER_WITH_FEES_PROXY_ADDRESS: wrapper proxy address +/// - BURN_TOKEN_ADDRESS: token to burn (must match what the quote was signed for) +/// - SIGNED_QUOTE: 0x-prefixed, version-prefixed signed quote bytes +/// +/// Optional env vars: +/// - BURN_AMOUNT: token units to burn (default: 500_000 — 0.5 USDC at 6 decimals). +/// Must match the `amount` bound into any PRE_FINALITY quote args. +/// - DESTINATION_DOMAIN: CCTP destination domain (default: 0 — Ethereum) +/// - MINT_RECIPIENT: bytes32 mint recipient on destination (default: caller, left-padded) +/// - DESTINATION_CALLER: bytes32 authorized destination caller (default: bytes32(0)) +/// - REFUND_ADDRESS: refund attribution address (default: caller). Forwarded to FeeManager +/// verbatim — `address(0)` bypasses the USDC blocklist screen on refund target. +/// - HOOK_DATA: 0x-prefixed hookData. Empty (default) calls `depositForBurnWithFees` and +/// the wrapper auto-injects `HookDataLib.defaultForwardHookData()` if the quote includes +/// a FORWARD item; non-empty routes through `depositForBurnWithHookAndFees` with the +/// caller-supplied hook, which must match what the FORWARD quote args were bound to. +contract DepositForBurnWithSignedQuote is Script { + using SafeERC20 for IERC20; + + error WrapperNoCode(); + error BurnTokenNoCode(); + + struct Env { + uint256 callerPk; + uint256 burnAmount; + bytes32 mintRecipient; + bytes32 destinationCaller; + address caller; + uint32 destinationDomain; + address wrapper; + address burnToken; + address refundAddress; + bytes signedQuote; + bytes hookData; + } + + function run() external { + Env memory e = _loadEnv(); + + (uint256 feeAmount, address feeToken) = ITokenMessengerWithFees(e.wrapper).getFee(e.signedQuote); + // Native fees are paid via msg.value; ERC-20 fees are pulled from the caller by the + // wrapper (through FeeManager), so they need an allowance instead and carry no value. + uint256 nativeValue = feeToken == address(0) ? feeAmount : 0; + + IFeeManager.QuoteClaim memory claim = + IFeeManager.QuoteClaim({signedQuote: e.signedQuote, refundAddress: payable(e.refundAddress)}); + + vm.startBroadcast(e.callerPk); + _approveSpend(e, feeToken, feeAmount); + if (e.hookData.length > 0) { + ITokenMessengerWithFees(e.wrapper).depositForBurnWithHookAndFees{value: nativeValue}( + e.burnAmount, e.destinationDomain, e.mintRecipient, e.burnToken, e.destinationCaller, e.hookData, claim + ); + } else { + ITokenMessengerWithFees(e.wrapper).depositForBurnWithFees{value: nativeValue}( + e.burnAmount, e.destinationDomain, e.mintRecipient, e.burnToken, e.destinationCaller, claim + ); + } + vm.stopBroadcast(); + + _logRun(e, feeToken, feeAmount, nativeValue); + } + + /// @dev Approves the wrapper to pull what it needs from the caller. The burn token is + /// always approved for `burnAmount`. For an ERC-20 fee quote the fee token is also + /// approved for `feeAmount`; when the fee token *is* the burn token a single combined + /// allowance is set instead, because the wrapper issues two separate `transferFrom`s + /// and independent approvals would overwrite each other. Native-fee quotes + /// (`feeToken == address(0)`) only need the burn-token allowance. + function _approveSpend(Env memory e, address feeToken, uint256 feeAmount) private { + if (feeToken != address(0) && feeToken == e.burnToken) { + IERC20(e.burnToken).forceApprove(e.wrapper, e.burnAmount + feeAmount); + return; + } + IERC20(e.burnToken).forceApprove(e.wrapper, e.burnAmount); + if (feeToken != address(0)) { + IERC20(feeToken).forceApprove(e.wrapper, feeAmount); + } + } + + function _loadEnv() private view returns (Env memory e) { + e.callerPk = vm.envUint("CALLER_PRIVATE_KEY"); + e.caller = vm.addr(e.callerPk); + e.wrapper = vm.envAddress("TOKEN_MESSENGER_WITH_FEES_PROXY_ADDRESS"); + e.burnToken = vm.envAddress("BURN_TOKEN_ADDRESS"); + e.signedQuote = vm.envBytes("SIGNED_QUOTE"); + if (e.wrapper.code.length == 0) revert WrapperNoCode(); + if (e.burnToken.code.length == 0) revert BurnTokenNoCode(); + e.burnAmount = vm.envOr("BURN_AMOUNT", uint256(500_000)); + e.destinationDomain = uint32(vm.envOr("DESTINATION_DOMAIN", uint256(0))); + e.mintRecipient = vm.envOr("MINT_RECIPIENT", bytes32(uint256(uint160(e.caller)))); + e.destinationCaller = vm.envOr("DESTINATION_CALLER", bytes32(0)); + e.refundAddress = vm.envOr("REFUND_ADDRESS", e.caller); + e.hookData = vm.envOr("HOOK_DATA", bytes("")); + } + + function _logRun(Env memory e, address feeToken, uint256 feeAmount, uint256 nativeValue) private pure { + console.log("caller:", e.caller); + console.log("wrapper:", e.wrapper); + console.log("burnToken:", e.burnToken); + console.log("burnAmount:", e.burnAmount); + console.log("destinationDomain:", e.destinationDomain); + console.log("mintRecipient:"); + console.logBytes32(e.mintRecipient); + console.log("destinationCaller:"); + console.logBytes32(e.destinationCaller); + console.log("refundAddress:", e.refundAddress); + console.log("hookDataLength:", e.hookData.length); + if (e.hookData.length > 0) { + console.log("hookData:"); + console.logBytes(e.hookData); + } + console.log("feeToken:", feeToken); + console.log("feeAmount:", feeAmount); + console.log("attachedNativeValue:", nativeValue); + console.log("signedQuote:"); + console.logBytes(e.signedQuote); + } +} diff --git a/script/test/GenerateSignedTestQuote.s.sol b/script/test/GenerateSignedTestQuote.s.sol new file mode 100644 index 0000000..031932b --- /dev/null +++ b/script/test/GenerateSignedTestQuote.s.sol @@ -0,0 +1,116 @@ +/** + * Copyright 2026 Circle Internet Group, Inc. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + */ +pragma solidity ^0.8.20; + +import {QuoteTypes} from "../../src/constants/QuoteTypes.sol"; +import {IFeeManager} from "../../src/interfaces/IFeeManager.sol"; +import {HookDataLib} from "../../src/libraries/HookDataLib.sol"; +import {QuoteScriptUtils} from "./QuoteScriptUtils.sol"; +import {console} from "forge-std/console.sol"; + +/// @title GenerateSignedTestQuote +/// @notice Produces a version-prefixed signed quote blob for local testing. +/// @dev Required env vars: +/// - QUOTE_SIGNER: private key used for vm.sign +/// - FEE_MANAGER_PROXY_ADDRESS: verifying contract used in EIP-712 domain +/// - TOKEN_MESSENGER_WITH_FEES_PROXY_ADDRESS: wrapper address bound into both FORWARD and PRE_FINALITY quote args +/// - BURN_TOKEN_ADDRESS: burn token bound into both FORWARD and PRE_FINALITY quote args (must match a real burn if you use the quote on-chain) +/// Optional env vars: +/// - DESTINATION_DOMAIN: destination domain bound into both FORWARD and PRE_FINALITY quote args (default: 0) +/// - QUOTE_BASE_TIMESTAMP: unix timestamp used as "now" (default: block.timestamp) +/// - QUOTE_TYPES: comma-separated subset of {FORWARD, PRE_FINALITY} to include in the quote +/// (default: "FORWARD,PRE_FINALITY"). Order is irrelevant; unknown entries, duplicates, +/// empty tokens, and an empty selection all revert. +contract GenerateSignedTestQuote is QuoteScriptUtils { + uint256 private constant BURN_AMOUNT = 500_000; // 0.5 USDC (6 decimals); must match intended `depositForBurnWithFees` amount + uint256 private constant FORWARD_COST = 0.00001 ether; + uint256 private constant PRE_FINALITY_COST = 0.000005 ether; + + function run() external view { + uint256 signerPk = vm.envUint("QUOTE_SIGNER"); + address signer = vm.addr(signerPk); + address feeManager = vm.envAddress("FEE_MANAGER_PROXY_ADDRESS"); + address tokenMessengerWithFees = vm.envAddress("TOKEN_MESSENGER_WITH_FEES_PROXY_ADDRESS"); + address burnToken = vm.envAddress("BURN_TOKEN_ADDRESS"); + address feeToken = address(0); + uint32 destinationDomain = uint32(vm.envOr("DESTINATION_DOMAIN", uint256(0))); + (bool includeForward, bool includePreFinality) = _readSelectedQuoteTypes(); + + IFeeManager.QuoteItem[] memory items = + _buildItems(tokenMessengerWithFees, destinationDomain, burnToken, includeForward, includePreFinality); + + uint256 baseTimestamp = vm.envOr("QUOTE_BASE_TIMESTAMP", uint256(block.timestamp)); + uint256 expiry = baseTimestamp + 1 hours; + (bytes memory signedQuote, bytes32 digest, bytes memory signature) = + _buildAndSignQuote(signerPk, feeManager, items, feeToken, expiry); + + console.log("quoteSigner:", signer); + console.log("feeManager:", feeManager); + console.log("tokenMessengerWithFees:", tokenMessengerWithFees); + console.log("feeToken (native):", feeToken); + console.log("burnToken:", burnToken); + console.log("burnAmount (PRE_FINALITY args):", BURN_AMOUNT); + console.log("destinationDomain:", destinationDomain); + console.log("destinationCaller (bytes32):"); + console.logBytes32(bytes32(0)); + console.log("includeForward:", includeForward); + console.log("includePreFinality:", includePreFinality); + console.log("resolvedForwardHookData (matches depositForBurnWithFees with empty calldata hook):"); + console.logBytes(HookDataLib.defaultForwardHookData()); + console.log("baseTimestamp:", baseTimestamp); + console.log("validUntil:", expiry); + console.log("forwardAmount:", includeForward ? FORWARD_COST : 0); + console.log("preFinalityAmount:", includePreFinality ? PRE_FINALITY_COST : 0); + console.log("digest:"); + console.logBytes32(digest); + console.log("signature:"); + console.logBytes(signature); + console.log("signedQuote:"); + console.logBytes(signedQuote); + } + + function _buildItems( + address tokenMessengerWithFees, + uint32 destinationDomain, + address burnToken, + bool includeForward, + bool includePreFinality + ) private pure returns (IFeeManager.QuoteItem[] memory items) { + uint256 itemCount = (includeForward ? 1 : 0) + (includePreFinality ? 1 : 0); + items = new IFeeManager.QuoteItem[](itemCount); + uint256 idx; + if (includeForward) { + items[idx] = IFeeManager.QuoteItem({ + quoteType: QuoteTypes.FORWARD, + quoteArgsHash: _forwardArgsHashForDepositWithFees( + tokenMessengerWithFees, destinationDomain, burnToken, bytes32(0) + ), + amount: FORWARD_COST + }); + ++idx; + } + if (includePreFinality) { + items[idx] = IFeeManager.QuoteItem({ + quoteType: QuoteTypes.PRE_FINALITY, + quoteArgsHash: _preFinalityArgsHash(tokenMessengerWithFees, destinationDomain, burnToken, BURN_AMOUNT), + amount: PRE_FINALITY_COST + }); + ++idx; + } + } +} diff --git a/script/test/QuoteScriptUtils.sol b/script/test/QuoteScriptUtils.sol new file mode 100644 index 0000000..7edfc86 --- /dev/null +++ b/script/test/QuoteScriptUtils.sol @@ -0,0 +1,169 @@ +/** + * Copyright 2026 Circle Internet Group, Inc. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + */ +pragma solidity ^0.8.20; + +import {EIP712TypeHashes} from "../../src/constants/EIP712TypeHashes.sol"; +import {QuoteTypes} from "../../src/constants/QuoteTypes.sol"; +import {IFeeManager} from "../../src/interfaces/IFeeManager.sol"; +import {HookDataLib} from "../../src/libraries/HookDataLib.sol"; +import {Strings} from "@openzeppelin/contracts/utils/Strings.sol"; +import {Script} from "forge-std/Script.sol"; + +/// @notice Shared helper for script-side quote hashing/signing. +abstract contract QuoteScriptUtils is Script { + uint8 internal constant QUOTE_FORMAT_VERSION = 1; + + /// @notice `QUOTE_TYPES` env var contained an entry that is not a TMWF-supported quote type. + error UnknownQuoteType(string quoteType); + /// @notice `QUOTE_TYPES` env var listed the same supported quote type more than once. + /// @dev Duplicates are rejected so the Solidity-side item builder and the bash-side + /// per-item `NATIVE_FEE` accumulator (`cast-deposit-for-burn.sh`) cannot diverge: + /// the latter sums fees per entry while the former collapses duplicates into a + /// single `QuoteItem`, which would otherwise revert on-chain with + /// `IncorrectNativePayment`. + error DuplicateQuoteType(string quoteType); + /// @notice `QUOTE_TYPES` parsed to an empty selection (no FORWARD, no PRE_FINALITY). + error NoQuoteTypesSelected(); + + /// @dev Reads the `QUOTE_TYPES` env var (comma-separated; defaults to + /// `FORWARD,PRE_FINALITY`) and returns booleans for each supported + /// TMWF quote type. Order within the env var does not matter. + /// Unknown entries revert with `UnknownQuoteType`, duplicate entries + /// revert with `DuplicateQuoteType`, and an empty selection reverts + /// with `NoQuoteTypesSelected`. + function _readSelectedQuoteTypes() internal view returns (bool includeForward, bool includePreFinality) { + string[] memory defaults = new string[](2); + defaults[0] = QuoteTypes.FORWARD; + defaults[1] = QuoteTypes.PRE_FINALITY; + string[] memory selected = vm.envOr("QUOTE_TYPES", ",", defaults); + + bytes32 forwardHash = keccak256(bytes(QuoteTypes.FORWARD)); + bytes32 preFinHash = keccak256(bytes(QuoteTypes.PRE_FINALITY)); + for (uint256 i = 0; i < selected.length; ++i) { + bytes32 h = keccak256(bytes(selected[i])); + if (h == forwardHash) { + if (includeForward) revert DuplicateQuoteType(QuoteTypes.FORWARD); + includeForward = true; + } else if (h == preFinHash) { + if (includePreFinality) revert DuplicateQuoteType(QuoteTypes.PRE_FINALITY); + includePreFinality = true; + } else { + revert UnknownQuoteType(selected[i]); + } + } + if (!includeForward && !includePreFinality) revert NoQuoteTypesSelected(); + } + + function _buildAndSignQuote( + uint256 signerPk, + address feeManager, + IFeeManager.QuoteItem[] memory items, + address feeToken, + uint256 expiry + ) internal view returns (bytes memory signedQuote, bytes32 digest, bytes memory signature) { + digest = _quoteDigest(items, expiry, feeToken, feeManager); + (uint8 v, bytes32 r, bytes32 s) = vm.sign(signerPk, digest); + signature = abi.encodePacked(r, s, v); + + // nonce must be 0; the field is reserved for future use + IFeeManager.Quote memory quote = + IFeeManager.Quote({items: items, expiry: expiry, feeToken: feeToken, nonce: 0, signature: signature}); + signedQuote = abi.encodePacked(QUOTE_FORMAT_VERSION, abi.encode(quote)); + } + + function _quoteDigest(IFeeManager.QuoteItem[] memory items, uint256 expiry, address feeToken, address feeManager) + internal + view + returns (bytes32) + { + bytes32 domainSep = keccak256( + abi.encode( + EIP712TypeHashes.DOMAIN_TYPEHASH, + keccak256(bytes("FeeManager")), + keccak256(bytes("1")), + block.chainid, + feeManager + ) + ); + bytes32 structHash = + keccak256(abi.encode(EIP712TypeHashes.QUOTE_TYPEHASH, _hashQuoteItems(items), expiry, feeToken, 0)); + return keccak256(abi.encodePacked(hex"1901", domainSep, structHash)); + } + + function _hashQuoteItems(IFeeManager.QuoteItem[] memory items) internal pure returns (bytes32) { + bytes32[] memory itemHashes = new bytes32[](items.length); + for (uint256 i = 0; i < items.length; ++i) { + itemHashes[i] = keccak256( + abi.encode( + EIP712TypeHashes.QUOTE_ITEM_TYPEHASH, + keccak256(bytes(items[i].quoteType)), + items[i].quoteArgsHash, + items[i].amount + ) + ); + } + return keccak256(abi.encodePacked(itemHashes)); + } + + function _forwardArgsHash( + address tokenMessengerWithFees, + uint32 destinationDomain, + address burnToken, + bytes32 destinationCaller, + bytes memory hookData + ) internal pure returns (bytes32) { + string[] memory args = new string[](5); + args[0] = Strings.toHexString(tokenMessengerWithFees); + args[1] = Strings.toString(uint256(destinationDomain)); + args[2] = Strings.toHexString(burnToken); + args[3] = Strings.toHexString(uint256(destinationCaller), 32); + args[4] = Strings.toHexString(hookData); + return keccak256(abi.encode(args)); + } + + /// @dev FORWARD args hash for `depositForBurnWithFees` when the caller passes no hookData. + /// Uses `HookDataLib.defaultForwardHookData()` — the wrapper injects the same bytes before fee matching. + function _forwardArgsHashForDepositWithFees( + address tokenMessengerWithFees, + uint32 destinationDomain, + address burnToken, + bytes32 destinationCaller + ) internal pure returns (bytes32) { + return _forwardArgsHash( + tokenMessengerWithFees, + destinationDomain, + burnToken, + destinationCaller, + HookDataLib.defaultForwardHookData() + ); + } + + function _preFinalityArgsHash( + address tokenMessengerWithFees, + uint32 destinationDomain, + address burnToken, + uint256 amount + ) internal pure returns (bytes32) { + string[] memory args = new string[](4); + args[0] = Strings.toHexString(tokenMessengerWithFees); + args[1] = Strings.toString(uint256(destinationDomain)); + args[2] = Strings.toHexString(burnToken); + args[3] = Strings.toString(amount); + return keccak256(abi.encode(args)); + } +} diff --git a/script/test/cast-deposit-for-burn-with-quote.sh b/script/test/cast-deposit-for-burn-with-quote.sh new file mode 100755 index 0000000..b54aba5 --- /dev/null +++ b/script/test/cast-deposit-for-burn-with-quote.sh @@ -0,0 +1,183 @@ +#!/usr/bin/env bash +# Deposit-for-burn via cast send using a *pre-signed* quote supplied by the caller. +# +# Companion to cast-deposit-for-burn.sh: that script signs its own quote with +# QUOTE_SIGNER and is meant for local round-trip testing; this one assumes the +# caller already has a signed quote (e.g. from a backend signer) and just wants +# to relay it on-chain. The native fee value (msg.value) is derived from the +# wrapper's getFee(bytes) view, so the caller does not need to know the exact +# amount in advance. +# +# Same Arc Testnet caveat as the generated-quote script: USDC precompiles +# (0x18…00, 0x18…01) break Forge's local revm simulation, so we use `cast send` +# directly. +# +# Usage: +# set -o allexport && source configs/.env.dev && set +o allexport +# export CALLER_PRIVATE_KEY=0x +# export BURN_TOKEN_ADDRESS=$USDC_ADDRESS +# export SIGNED_QUOTE=0x01... # version-prefixed, signed by a trusted FeeManager signer +# bash script/test/cast-deposit-for-burn-with-quote.sh +# +# Optional env vars: +# BURN_AMOUNT token units to burn (default: 500000) +# DESTINATION_DOMAIN CCTP destination domain (default: 0 — Ethereum) +# MINT_RECIPIENT bytes32 mint recipient (default: caller, left-padded) +# DESTINATION_CALLER bytes32 authorized destination caller (default: zero) +# REFUND_ADDRESS refund attribution address (default: zero — bypasses USDC blocklist screen) +# HOOK_DATA 0x-prefixed hookData; non-empty switches to depositForBurnWithHookAndFees +# NATIVE_FEE wei to attach as msg.value (default: queried via wrapper.getFee). +# getFee(bytes) is always called regardless, and the script aborts when +# the quoted feeToken is not address(0); the override only changes the +# value attached to the deposit call (e.g. for IncorrectNativePayment +# smoke tests). A mismatch against the quoted fee is logged. + +set -euo pipefail + +: "${BLOCKCHAIN_RPC_URL:?Set BLOCKCHAIN_RPC_URL}" +: "${CALLER_PRIVATE_KEY:?Set CALLER_PRIVATE_KEY (0x-prefixed hex)}" +: "${TOKEN_MESSENGER_WITH_FEES_PROXY_ADDRESS:?Set TOKEN_MESSENGER_WITH_FEES_PROXY_ADDRESS}" +: "${BURN_TOKEN_ADDRESS:?Set BURN_TOKEN_ADDRESS}" +: "${SIGNED_QUOTE:?Set SIGNED_QUOTE (0x-prefixed version-prefixed bytes)}" + +BURN_AMOUNT="${BURN_AMOUNT:-500000}" +DESTINATION_DOMAIN="${DESTINATION_DOMAIN:-0}" +HOOK_DATA="${HOOK_DATA:-0x}" +ZERO_BYTES32=0x0000000000000000000000000000000000000000000000000000000000000000 +ZERO_ADDRESS=0x0000000000000000000000000000000000000000 +DESTINATION_CALLER="${DESTINATION_CALLER:-$ZERO_BYTES32}" + +if [[ "$SIGNED_QUOTE" != 0x* ]]; then + echo "ERROR: SIGNED_QUOTE must be 0x-prefixed hex (got: ${SIGNED_QUOTE:0:16}…)" >&2 + exit 1 +fi +if [[ "$HOOK_DATA" != 0x* ]]; then + echo "ERROR: HOOK_DATA must be 0x-prefixed hex (got: ${HOOK_DATA:0:16}…)" >&2 + exit 1 +fi + +CALLER_ADDRESS=$(cast wallet address --private-key "$CALLER_PRIVATE_KEY") +MINT_RECIPIENT="${MINT_RECIPIENT:-0x000000000000000000000000${CALLER_ADDRESS#0x}}" +# QuoteClaim.refundAddress: address(0) means "no refund target" and bypasses +# the USDC blocklist screen (see FeeManager._screenRefundAddress). Override +# via REFUND_ADDRESS env var to test the non-zero path. +REFUND_ADDRESS="${REFUND_ADDRESS:-$ZERO_ADDRESS}" + +# Mask everything after the hostname (path, query, fragment) and any user-info +# component (`user:pass@`) so secrets in either spot don't leak into shared +# terminals or CI logs. +redact_url_after_host() { + local url="$1" + local rest="${url#*://}" + local host="${rest%%/*}" + host="${host%%\?*}" + host="${host%%#*}" + host="${host##*@}" + printf '%s\n' "${url%%"$rest"}${host}/" +} + +echo "--- Configuration ---" +echo "RPC: $(redact_url_after_host "$BLOCKCHAIN_RPC_URL")" +echo "Caller: $CALLER_ADDRESS" +echo "Wrapper (TMWF): $TOKEN_MESSENGER_WITH_FEES_PROXY_ADDRESS" +echo "Burn token: $BURN_TOKEN_ADDRESS" +echo "Burn amount: $BURN_AMOUNT" +echo "Destination domain: $DESTINATION_DOMAIN" +echo "Mint recipient: $MINT_RECIPIENT" +echo "Destination caller: $DESTINATION_CALLER" +echo "Refund address: $REFUND_ADDRESS" +echo "Hook data length: $(( (${#HOOK_DATA} - 2) / 2 )) bytes" +echo "Signed quote: ${SIGNED_QUOTE:0:42}…${SIGNED_QUOTE: -8} ($(( (${#SIGNED_QUOTE} - 2) / 2 )) bytes)" +echo "" + +# Step 1 — Resolve the native fee value via the wrapper's getFee(bytes) view. +# Always query getFee so we can enforce the native-only invariant +# (feeToken == address(0)) even when the caller pre-set NATIVE_FEE. Otherwise +# an ERC-20-fee quote could slip through and waste gas on a doomed +# approve + deposit. The NATIVE_FEE override is preserved for cases like +# deliberately triggering IncorrectNativePayment in smoke tests. +echo "=== Step 1/3: Querying native fee ===" +# `cast call` with multi-return prints one value per line. The integer line +# also carries a "[1.5e13]"-style scientific suffix that awk strips by +# selecting only the first field. +if ! GET_FEE_OUTPUT=$(cast call \ + "$TOKEN_MESSENGER_WITH_FEES_PROXY_ADDRESS" \ + "getFee(bytes)(uint256,address)" \ + "$SIGNED_QUOTE" \ + --rpc-url "$BLOCKCHAIN_RPC_URL" 2>&1); then + echo "ERROR: getFee query failed:" >&2 + echo "$GET_FEE_OUTPUT" >&2 + exit 1 +fi +QUOTED_FEE=$(echo "$GET_FEE_OUTPUT" | sed -n '1p' | awk '{print $1}') +FEE_TOKEN=$(echo "$GET_FEE_OUTPUT" | sed -n '2p' | awk '{print $1}') +if [[ -z "$QUOTED_FEE" || -z "$FEE_TOKEN" ]]; then + echo "ERROR: failed to parse getFee output:" >&2 + echo "$GET_FEE_OUTPUT" >&2 + exit 1 +fi +# Lowercase via tr so this works on macOS's stock bash 3.2 (no ${var,,}). +FEE_TOKEN_LC=$(printf '%s' "$FEE_TOKEN" | tr '[:upper:]' '[:lower:]') +ZERO_ADDRESS_LC=$(printf '%s' "$ZERO_ADDRESS" | tr '[:upper:]' '[:lower:]') +if [[ "$FEE_TOKEN_LC" != "$ZERO_ADDRESS_LC" ]]; then + echo "ERROR: quote feeToken is $FEE_TOKEN; this script only supports native fees." >&2 + echo " Either supply a native-fee quote or use a separate ERC-20 approve flow." >&2 + exit 1 +fi + +if [[ -n "${NATIVE_FEE:-}" ]]; then + if [[ "$NATIVE_FEE" != "$QUOTED_FEE" ]]; then + echo "Note: caller-provided NATIVE_FEE=$NATIVE_FEE wei differs from quoted $QUOTED_FEE wei" >&2 + echo " (the wrapper requires an exact match and will revert with IncorrectNativePayment)." >&2 + fi + echo "Using caller-provided NATIVE_FEE=$NATIVE_FEE wei (quoted fee: $QUOTED_FEE wei, feeToken=$FEE_TOKEN)" +else + NATIVE_FEE="$QUOTED_FEE" + echo "Resolved native fee: $NATIVE_FEE wei (feeToken=$FEE_TOKEN)" +fi +echo "" + +# Step 2 — Approve the burn token for the wrapper. +echo "=== Step 2/3: Approving burn token ===" +cast send "$BURN_TOKEN_ADDRESS" \ + "approve(address,uint256)" \ + "$TOKEN_MESSENGER_WITH_FEES_PROXY_ADDRESS" "$BURN_AMOUNT" \ + --private-key "$CALLER_PRIVATE_KEY" \ + --rpc-url "$BLOCKCHAIN_RPC_URL" +echo "" + +# Step 3 — Relay the burn. Choose between depositForBurnWithFees and +# depositForBurnWithHookAndFees based on whether HOOK_DATA is non-empty. +# QuoteClaim is a (bytes signedQuote, address refundAddress) tuple — passed +# to cast as "($SIGNED_QUOTE,$REFUND_ADDRESS)" with sig type `(bytes,address)`. +if [[ "$HOOK_DATA" == "0x" || -z "${HOOK_DATA:2}" ]]; then + echo "=== Step 3/3: depositForBurnWithFees ===" + cast send "$TOKEN_MESSENGER_WITH_FEES_PROXY_ADDRESS" \ + "depositForBurnWithFees(uint256,uint32,bytes32,address,bytes32,(bytes,address))" \ + "$BURN_AMOUNT" \ + "$DESTINATION_DOMAIN" \ + "$MINT_RECIPIENT" \ + "$BURN_TOKEN_ADDRESS" \ + "$DESTINATION_CALLER" \ + "($SIGNED_QUOTE,$REFUND_ADDRESS)" \ + --value "$NATIVE_FEE" \ + --private-key "$CALLER_PRIVATE_KEY" \ + --rpc-url "$BLOCKCHAIN_RPC_URL" +else + echo "=== Step 3/3: depositForBurnWithHookAndFees ===" + cast send "$TOKEN_MESSENGER_WITH_FEES_PROXY_ADDRESS" \ + "depositForBurnWithHookAndFees(uint256,uint32,bytes32,address,bytes32,bytes,(bytes,address))" \ + "$BURN_AMOUNT" \ + "$DESTINATION_DOMAIN" \ + "$MINT_RECIPIENT" \ + "$BURN_TOKEN_ADDRESS" \ + "$DESTINATION_CALLER" \ + "$HOOK_DATA" \ + "($SIGNED_QUOTE,$REFUND_ADDRESS)" \ + --value "$NATIVE_FEE" \ + --private-key "$CALLER_PRIVATE_KEY" \ + --rpc-url "$BLOCKCHAIN_RPC_URL" +fi + +echo "" +echo "=== Done ===" diff --git a/script/test/cast-deposit-for-burn.sh b/script/test/cast-deposit-for-burn.sh new file mode 100755 index 0000000..abd5c07 --- /dev/null +++ b/script/test/cast-deposit-for-burn.sh @@ -0,0 +1,185 @@ +#!/usr/bin/env bash +# Deposit-for-burn via cast send, bypassing Forge's local EVM simulation. +# +# Arc Testnet (chain id 5042002) delegates USDC balance transfers and blocklist +# checks to native precompiles (0x18…00, 0x18…01) that have no standard EVM +# bytecode. Forge's revm fetches empty code at those addresses and fails with +# StackUnderflow when it tries to simulate the transaction locally. This script +# avoids the issue by submitting transactions directly to the RPC node via +# `cast send`. +# +# Usage: +# set -o allexport && source configs/.env.dev && set +o allexport +# export CALLER_PRIVATE_KEY=0x +# export QUOTE_SIGNER= +# export BURN_TOKEN_ADDRESS=$USDC_ADDRESS +# bash script/test/cast-deposit-for-burn.sh +# +# Fee amounts are hardcoded to match GenerateSignedTestQuote.s.sol: +# FORWARD = 0.00001 ether, PRE_FINALITY = 0.000005 ether, burn amount = 500 000. +# To change them, update both the Solidity script constants and the +# FORWARD_FEE_WEI / PRE_FINALITY_FEE_WEI constants below. +# +# QUOTE_TYPES selects which fee items the generated quote contains and which +# fees are included in the attached msg.value. Comma-separated subset of +# {FORWARD, PRE_FINALITY}. Default: "FORWARD,PRE_FINALITY". + +set -euo pipefail + +: "${BLOCKCHAIN_RPC_URL:?Set BLOCKCHAIN_RPC_URL}" +: "${CALLER_PRIVATE_KEY:?Set CALLER_PRIVATE_KEY (0x-prefixed hex)}" +: "${QUOTE_SIGNER:?Set QUOTE_SIGNER (uint256, must be a trusted signer on FeeManager)}" +: "${TOKEN_MESSENGER_WITH_FEES_PROXY_ADDRESS:?Set TOKEN_MESSENGER_WITH_FEES_PROXY_ADDRESS}" +: "${BURN_TOKEN_ADDRESS:?Set BURN_TOKEN_ADDRESS}" +: "${FEE_MANAGER_PROXY_ADDRESS:?Set FEE_MANAGER_PROXY_ADDRESS}" + +BURN_AMOUNT=500000 +FORWARD_FEE_WEI=10000000000000 # 0.00001 ether +PRE_FINALITY_FEE_WEI=5000000000000 # 0.000005 ether +DESTINATION_DOMAIN="${DESTINATION_DOMAIN:-0}" +QUOTE_TYPES="${QUOTE_TYPES:-FORWARD,PRE_FINALITY}" +ZERO_BYTES32=0x0000000000000000000000000000000000000000000000000000000000000000 +ZERO_ADDRESS=0x0000000000000000000000000000000000000000 + +# Parse QUOTE_TYPES → NATIVE_FEE. Keep this in sync with QuoteScriptUtils +# `_readSelectedQuoteTypes` so the bash side rejects the same set of bad +# inputs the Solidity side would. In particular, duplicates are rejected on +# both sides: this loop sums fees per entry, while the Solidity item builder +# collapses duplicates into a single `QuoteItem`, so `QUOTE_TYPES=FORWARD,FORWARD` +# would otherwise sign one FORWARD item but attach 2x the forward fee and +# revert on-chain with `IncorrectNativePayment`. Empty tokens (e.g. a trailing +# comma like `FORWARD,`) are also rejected to match the Solidity side. +INCLUDE_FORWARD=false +INCLUDE_PRE_FINALITY=false +NATIVE_FEE=0 +# `read -ra` silently strips trailing empty fields, so `FORWARD,` would otherwise +# pass on the bash side while Solidity's `vm.envOr` splits it into ["FORWARD", ""] +# and reverts with `UnknownQuoteType("")`. Reject leading/trailing commas up front +# to keep both sides aligned. +if [[ "$QUOTE_TYPES" == ,* || "$QUOTE_TYPES" == *, ]]; then + echo "ERROR: QUOTE_TYPES has a leading or trailing comma (got '$QUOTE_TYPES')" >&2 + exit 1 +fi +IFS=',' read -ra _QUOTE_TYPE_TOKENS <<< "$QUOTE_TYPES" +for _token in "${_QUOTE_TYPE_TOKENS[@]}"; do + # Trim surrounding whitespace so "FORWARD, PRE_FINALITY" works too. + _token="${_token#"${_token%%[![:space:]]*}"}" + _token="${_token%"${_token##*[![:space:]]}"}" + case "$_token" in + FORWARD) + if $INCLUDE_FORWARD; then + echo "ERROR: QUOTE_TYPES lists FORWARD more than once (got '$QUOTE_TYPES'); duplicates are rejected to keep NATIVE_FEE in sync with the Solidity quote" >&2 + exit 1 + fi + INCLUDE_FORWARD=true + NATIVE_FEE=$((NATIVE_FEE + FORWARD_FEE_WEI)) + ;; + PRE_FINALITY) + if $INCLUDE_PRE_FINALITY; then + echo "ERROR: QUOTE_TYPES lists PRE_FINALITY more than once (got '$QUOTE_TYPES'); duplicates are rejected to keep NATIVE_FEE in sync with the Solidity quote" >&2 + exit 1 + fi + INCLUDE_PRE_FINALITY=true + NATIVE_FEE=$((NATIVE_FEE + PRE_FINALITY_FEE_WEI)) + ;; + *) + echo "ERROR: Unknown QUOTE_TYPES entry '$_token' (expected FORWARD or PRE_FINALITY)" >&2 + exit 1 + ;; + esac +done +if ! $INCLUDE_FORWARD && ! $INCLUDE_PRE_FINALITY; then + echo "ERROR: QUOTE_TYPES must include at least one of FORWARD, PRE_FINALITY (got '$QUOTE_TYPES')" >&2 + exit 1 +fi + +CALLER_ADDRESS=$(cast wallet address --private-key "$CALLER_PRIVATE_KEY") +MINT_RECIPIENT="${MINT_RECIPIENT:-0x000000000000000000000000${CALLER_ADDRESS#0x}}" +# QuoteClaim.refundAddress: address(0) means "no refund target" and bypasses +# the USDC blocklist screen (see FeeManager._screenRefundAddress). Override +# via REFUND_ADDRESS env var to test the non-zero path. +REFUND_ADDRESS="${REFUND_ADDRESS:-$ZERO_ADDRESS}" + +# Mask everything after the hostname (path, query, fragment) and any user-info +# component (`user:pass@`) so secrets in either spot don't leak into shared +# terminals or CI logs. +redact_url_after_host() { + local url="$1" + local rest="${url#*://}" + local host="${rest%%/*}" + host="${host%%\?*}" + host="${host%%#*}" + host="${host##*@}" + printf '%s\n' "${url%%"$rest"}${host}/" +} + +echo "--- Configuration ---" +echo "RPC: $(redact_url_after_host "$BLOCKCHAIN_RPC_URL")" +echo "Caller: $CALLER_ADDRESS" +echo "Wrapper (TMWF): $TOKEN_MESSENGER_WITH_FEES_PROXY_ADDRESS" +echo "FeeManager: $FEE_MANAGER_PROXY_ADDRESS" +echo "Burn token: $BURN_TOKEN_ADDRESS" +echo "Burn amount: $BURN_AMOUNT" +echo "Quote types: $QUOTE_TYPES (forward=$INCLUDE_FORWARD, pre_finality=$INCLUDE_PRE_FINALITY)" +echo "Native fee (wei): $NATIVE_FEE" +echo "Destination domain: $DESTINATION_DOMAIN" +echo "Mint recipient: $MINT_RECIPIENT" +echo "Refund address: $REFUND_ADDRESS" +echo "" + +# Step 1 — Generate a signed quote via the Forge test script. +# This is a view-only call (no --broadcast); Forge does not simulate on-chain +# state, so native precompiles are not hit. +echo "=== Step 1/3: Generating signed quote ===" + +# The `if !` wrapper is required because forge's stderr is captured via 2>&1; +# without it, `set -e` aborts the script on a failed forge run before the +# captured output can be printed (silent exit). A frequent cause of failure +# here is stale build artifacts after pulling new contract changes — try +# `forge clean && forge build` if forge prints "No contract bytecode". +if ! QUOTE_OUTPUT=$(DESTINATION_DOMAIN="$DESTINATION_DOMAIN" QUOTE_BASE_TIMESTAMP=$(date +%s) QUOTE_TYPES="$QUOTE_TYPES" forge script \ + script/test/GenerateSignedTestQuote.s.sol:GenerateSignedTestQuote \ + --rpc-url "$BLOCKCHAIN_RPC_URL" 2>&1); then + echo "ERROR: forge script failed:" + echo "$QUOTE_OUTPUT" + exit 1 +fi + +# console.logBytes prints the hex on a separate line after "signedQuote:" +SIGNED_QUOTE=$(echo "$QUOTE_OUTPUT" | grep -A1 'signedQuote:' | tail -1 | tr -d ' ') +if [[ -z "$SIGNED_QUOTE" || "$SIGNED_QUOTE" != 0x* ]]; then + echo "ERROR: failed to extract signedQuote from forge output:" + echo "$QUOTE_OUTPUT" + exit 1 +fi +echo "signedQuote: ${SIGNED_QUOTE:0:42}…${SIGNED_QUOTE: -8}" +echo "" + +# Step 2 — Approve the burn token for the wrapper. +echo "=== Step 2/3: Approving burn token ===" +cast send "$BURN_TOKEN_ADDRESS" \ + "approve(address,uint256)" \ + "$TOKEN_MESSENGER_WITH_FEES_PROXY_ADDRESS" "$BURN_AMOUNT" \ + --private-key "$CALLER_PRIVATE_KEY" \ + --rpc-url "$BLOCKCHAIN_RPC_URL" +echo "" + +# Step 3 — Call depositForBurnWithFees on TokenMessengerWithFees. +# The last arg is `IFeeManager.QuoteClaim`, a tuple of +# (bytes signedQuote, address refundAddress); pass it to cast as +# "($SIGNED_QUOTE,$REFUND_ADDRESS)" with sig type `(bytes,address)`. +echo "=== Step 3/3: depositForBurnWithFees ===" +cast send "$TOKEN_MESSENGER_WITH_FEES_PROXY_ADDRESS" \ + "depositForBurnWithFees(uint256,uint32,bytes32,address,bytes32,(bytes,address))" \ + "$BURN_AMOUNT" \ + "$DESTINATION_DOMAIN" \ + "$MINT_RECIPIENT" \ + "$BURN_TOKEN_ADDRESS" \ + "$ZERO_BYTES32" \ + "($SIGNED_QUOTE,$REFUND_ADDRESS)" \ + --value "$NATIVE_FEE" \ + --private-key "$CALLER_PRIVATE_KEY" \ + --rpc-url "$BLOCKCHAIN_RPC_URL" + +echo "" +echo "=== Done ===" From 88e0ffbcf3709a15b6ffa23503cf548c9eb268ab Mon Sep 17 00:00:00 2001 From: undefined Date: Mon, 19 Oct 2026 03:42:37 +0000 Subject: [PATCH 04/21] =?UTF-8?q?=F0=9F=94=84=20created=20local=20'configs?= =?UTF-8?q?/'=20from=20remote=20'configs/'?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- configs/.env.dev.template | 48 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 48 insertions(+) create mode 100644 configs/.env.dev.template diff --git a/configs/.env.dev.template b/configs/.env.dev.template new file mode 100644 index 0000000..8864022 --- /dev/null +++ b/configs/.env.dev.template @@ -0,0 +1,48 @@ +# set -o allexport && source configs/.env.dev && set +o allexport + +BLOCKCHAIN_RPC_URL= + +DEPLOYER_PRIVATE_KEY= + +# Address of the pre-deployed Create3Factory on the target chain. +# Deploy via: forge script script/deploy/DeployCreate3Factory.s.sol ... +CREATE3_FACTORY_ADDRESS= + +# Per-contract CREATE3 salts. Proxy salts are stable across impl upgrades; +# use the same proxy salt across chains for identical proxy addresses. +# Bump the implementation salt when deploying a new impl (avoids CREATE3 collision). +FEE_MANAGER_PROXY_SALT=fee-manager-proxy-v1 +FEE_MANAGER_IMPLEMENTATION_SALT=fee-manager-impl-v1 +TOKEN_MESSENGER_WITH_FEES_PROXY_SALT=tmwf-proxy-v1 +TOKEN_MESSENGER_WITH_FEES_IMPLEMENTATION_SALT=tmwf-impl-v1 + +# Address of the existing TokenMessengerV2 contract on the target chain. +# Required for TokenMessengerWithFees deployment. +TOKEN_MESSENGER_V2_ADDRESS= + +# Address of the deployed FeeTaker proxy on the target chain. +# Passed as an immutable constructor arg to FeeManager implementation. +FEE_TAKER_ADDRESS= + +# USDC token contract on the target chain (must match FeeTaker’s configured USDC). +# Passed as an immutable constructor arg to FeeManager implementation. +USDC_ADDRESS= + +# Deployed FeeManager UUPS proxy (from DeployFeeManager logs). Used by test scripts and verification. +FEE_MANAGER_PROXY_ADDRESS= + +# TokenMessengerWithFees proxy — required for UpgradeTokenMessengerWithFees.s.sol `run` / `upgradeProxyOnly`. +# TOKEN_MESSENGER_WITH_FEES_PROXY_ADDRESS= + +# Set only when using `upgradeProxyOnly()` after deploying an implementation elsewhere. +# FEE_MANAGER_IMPLEMENTATION_ADDRESS= +# TOKEN_MESSENGER_WITH_FEES_IMPLEMENTATION_ADDRESS= + +# Optional: FeeManager role holders and initial signer (see DeployFeeManager.s.sol). +# FEE_MANAGER_CALLER_ADMIN= # if set and non-zero, granted at deploy; else owner grants later +# FEE_MANAGER_SIGNER_ADMIN= # if set and non-zero, granted at deploy; else owner grants later +# FEE_MANAGER_TRUSTED_SIGNER= +# FEE_MANAGER_PAUSER= + +# Optional: required for --verify on testnet/mainnet deploys. +# ETHERSCAN_API_KEY= From 7e0975404e750e2bd44251f50e71675df4bd2c6b Mon Sep 17 00:00:00 2001 From: undefined Date: Mon, 19 Oct 2026 03:42:37 +0000 Subject: [PATCH 05/21] =?UTF-8?q?=F0=9F=94=84=20created=20local=20'foundry?= =?UTF-8?q?.toml'=20from=20remote=20'foundry.toml'?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- foundry.toml | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) create mode 100644 foundry.toml diff --git a/foundry.toml b/foundry.toml new file mode 100644 index 0000000..ad49eff --- /dev/null +++ b/foundry.toml @@ -0,0 +1,32 @@ +[profile.default] +src = 'src' +out = 'out' +libs = ["node_modules", "lib"] +solc_version = "0.8.24" +evm_version = 'cancun' +test = 'test' +optimizer = true +optimizer_runs = 200 +via_ir = true +auto_detect_solc = false +deny = "warnings" +no_match_coverage = "test/|script/" +build_info = true +extra_output = ["storageLayout"] + +[fuzz] +runs = 1024 + +[invariant] +runs = 512 +fail_on_revert = true +depth = 15 + +[lint] +# Tests intentionally use vm.setEnv; production code uses high-level keccak256 for EIP-712 (clearer than asm). +# block-timestamp: QuoteLib.validateExpiry intentionally compares block.timestamp for wall-clock TTL. +exclude_lints = ["unsafe-cheatcode", "asm-keccak256", "block-timestamp"] + +[fmt] +line_length = 120 +sort_imports = true From e6d9024198c9f2739d54364f0842cde46718c798 Mon Sep 17 00:00:00 2001 From: undefined Date: Mon, 19 Oct 2026 03:42:37 +0000 Subject: [PATCH 06/21] =?UTF-8?q?=F0=9F=94=84=20created=20local=20'foundry?= =?UTF-8?q?.lock'=20from=20remote=20'foundry.lock'?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- foundry.lock | 8 ++++++++ 1 file changed, 8 insertions(+) create mode 100644 foundry.lock diff --git a/foundry.lock b/foundry.lock new file mode 100644 index 0000000..bc06b89 --- /dev/null +++ b/foundry.lock @@ -0,0 +1,8 @@ +{ + "lib/forge-std": { + "tag": { + "name": "v1.15.0", + "rev": "0844d7e1fc5e60d77b68e469bff60265f236c398" + } + } +} \ No newline at end of file From 4eeb47bea6563aa5da65f91a8c980630ca9607df Mon Sep 17 00:00:00 2001 From: undefined Date: Mon, 19 Oct 2026 03:42:37 +0000 Subject: [PATCH 07/21] =?UTF-8?q?=F0=9F=94=84=20created=20local=20'remappi?= =?UTF-8?q?ngs.txt'=20from=20remote=20'remappings.txt'?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- remappings.txt | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 remappings.txt diff --git a/remappings.txt b/remappings.txt new file mode 100644 index 0000000..f058434 --- /dev/null +++ b/remappings.txt @@ -0,0 +1,3 @@ +@openzeppelin/contracts/=node_modules/@openzeppelin/contracts/ +@openzeppelin/contracts-upgradeable/=node_modules/@openzeppelin/contracts-upgradeable/ +solady/=node_modules/solady/src/ From 94212e02aa4b48b80613ebacce80a7108668fb3d Mon Sep 17 00:00:00 2001 From: undefined Date: Mon, 19 Oct 2026 03:42:37 +0000 Subject: [PATCH 08/21] =?UTF-8?q?=F0=9F=94=84=20created=20local=20'.solhin?= =?UTF-8?q?t-src.json'=20from=20remote=20'.solhint-src.json'?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .solhint-src.json | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 .solhint-src.json diff --git a/.solhint-src.json b/.solhint-src.json new file mode 100644 index 0000000..bfd114b --- /dev/null +++ b/.solhint-src.json @@ -0,0 +1,11 @@ +{ + "extends": "solhint:recommended", + "rules": { + "compiler-version": ["error", "^0.8.20"], + "func-visibility": ["error", { "ignoreConstructors": true }], + "no-inline-assembly": "off", + "use-natspec": "off", + "gas-indexed-events": "off", + "gas-strict-inequalities": "off" + } +} From 277d3683b8cb11fc6ca15e5402418472dd3888d2 Mon Sep 17 00:00:00 2001 From: undefined Date: Mon, 19 Oct 2026 03:42:37 +0000 Subject: [PATCH 09/21] =?UTF-8?q?=F0=9F=94=84=20created=20local=20'.solhin?= =?UTF-8?q?t-test.json'=20from=20remote=20'.solhint-test.json'?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .solhint-test.json | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) create mode 100644 .solhint-test.json diff --git a/.solhint-test.json b/.solhint-test.json new file mode 100644 index 0000000..393107d --- /dev/null +++ b/.solhint-test.json @@ -0,0 +1,20 @@ +{ + "extends": "solhint:recommended", + "rules": { + "compiler-version": ["error", "^0.8.20"], + "no-unused-vars": "off", + "no-console": "off", + "func-name-mixedcase": "off", + "no-inline-assembly": "off", + "gas-custom-errors": "off", + "reason-string": "off", + "func-visibility": ["error", { "ignoreConstructors": true }], + "one-contract-per-file": "off", + "import-path-check": "off", + "use-natspec": "off", + "gas-indexed-events": "off", + "gas-increment-by-one": "off", + "no-complex-fallback": "off", + "gas-small-strings": "off" + } +} From ea2e372cdc8b0cfeea519e6d2b377cc034172f7b Mon Sep 17 00:00:00 2001 From: undefined Date: Mon, 19 Oct 2026 03:42:37 +0000 Subject: [PATCH 10/21] =?UTF-8?q?=F0=9F=94=84=20created=20local=20'.solhin?= =?UTF-8?q?t-script.json'=20from=20remote=20'.solhint-script.json'?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .solhint-script.json | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 .solhint-script.json diff --git a/.solhint-script.json b/.solhint-script.json new file mode 100644 index 0000000..4b2a13c --- /dev/null +++ b/.solhint-script.json @@ -0,0 +1,13 @@ +{ + "extends": "solhint:recommended", + "rules": { + "compiler-version": ["error", "^0.8.20"], + "func-visibility": ["error", { "ignoreConstructors": true }], + "no-inline-assembly": "off", + "no-console": "off", + "import-path-check": "off", + "gas-small-strings": "off", + "use-natspec": "off", + "gas-indexed-events": "off" + } +} From 53d5b447493674e9b0947937fe38b8ed90d87bcb Mon Sep 17 00:00:00 2001 From: undefined Date: Mon, 19 Oct 2026 03:42:38 +0000 Subject: [PATCH 11/21] =?UTF-8?q?=F0=9F=94=84=20created=20local=20'.solhin?= =?UTF-8?q?tignore'=20from=20remote=20'.solhintignore'?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .solhintignore | 1 + 1 file changed, 1 insertion(+) create mode 100644 .solhintignore diff --git a/.solhintignore b/.solhintignore new file mode 100644 index 0000000..c2658d7 --- /dev/null +++ b/.solhintignore @@ -0,0 +1 @@ +node_modules/ From 50dc514b407457128f72a85a7b857bad758506e1 Mon Sep 17 00:00:00 2001 From: undefined Date: Mon, 19 Oct 2026 03:42:38 +0000 Subject: [PATCH 12/21] =?UTF-8?q?=F0=9F=94=84=20created=20local=20'.gitign?= =?UTF-8?q?ore'=20from=20remote=20'.gitignore'?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 39 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) create mode 100644 .gitignore diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..cf1bd45 --- /dev/null +++ b/.gitignore @@ -0,0 +1,39 @@ +node_modules + +# OS +.DS_Store +*.orig + +# Build +build + +# Foundry +/cache +/cache_forge +/out +.gas-snapshot +broadcast + +# ABI exports (yarn abi → script/abi/extract-abis.sh); regenerable from source +/abi + +# IDE +/.idea/ +/.vscode +/.wake + +# Environment +.env* +!/configs/.env.dev.template + +# Coverage report output +report/ +lcov.info +temp-coverage.out +/coverage +/coverage.json + +/yarn-error.log + +/licenses.csv +/sbom.spdx.json \ No newline at end of file From 8d910ce819fbd5c95208a8a20c984d3918da8869 Mon Sep 17 00:00:00 2001 From: undefined Date: Mon, 19 Oct 2026 03:42:38 +0000 Subject: [PATCH 13/21] =?UTF-8?q?=F0=9F=94=84=20created=20local=20'.pre-co?= =?UTF-8?q?mmit-config.yaml'=20from=20remote=20'.pre-commit-config.yaml'?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .pre-commit-config.yaml | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) create mode 100644 .pre-commit-config.yaml diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..acaf38f --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,18 @@ +repos: + - repo: local + hooks: + # Solidity + - id: check-copyright-solidity + name: Check copyright header - solidity + entry: ./script/pre-commit/check-copyright-header.sh + language: script + files: \.sol$ + exclude: | + (?x)^( + .github/.*| + build/.*| + out/.*| + lib/.*| + docs/.*| + .*\.md$ + )$ From ac466b2806351b04c20a176cc3d9de707f8c0d8d Mon Sep 17 00:00:00 2001 From: undefined Date: Mon, 19 Oct 2026 03:42:38 +0000 Subject: [PATCH 14/21] =?UTF-8?q?=F0=9F=94=84=20created=20local=20'package?= =?UTF-8?q?.json'=20from=20remote=20'package.json'?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package.json | 37 +++++++++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) create mode 100644 package.json diff --git a/package.json b/package.json new file mode 100644 index 0000000..05166f0 --- /dev/null +++ b/package.json @@ -0,0 +1,37 @@ +{ + "name": "fee-orchestrator-contracts", + "version": "1.0.0", + "description": "Circle Fee Orchestrator smart contracts", + "packageManager": "yarn@1.22.22", + "scripts": { + "prepare": "if [ -n \"$CI\" ]; then exit 0; fi; command -v pre-commit >/dev/null 2>&1 || { echo 'ERROR: pre-commit is not installed. Install it (brew install pre-commit | pipx install pre-commit) and re-run yarn install.'; exit 1; }; pre-commit install", + "build": "forge build", + "abi": "bash script/abi/extract-abis.sh", + "lint": "solhint -w 0 -c .solhint-src.json './src/**/*.sol' && solhint -w 0 -c .solhint-test.json './test/**/*.sol' && solhint -w 0 -c .solhint-script.json './script/**/*.sol'", + "lint:foundry-settings": "bash script/lint/check-foundry-settings.sh", + "test": "forge test -vvvv", + "coverage": "forge coverage --no-match-coverage 'test|^script/' --no-match-test '_pinnedBytecode' > temp-coverage.out 2>&1; status=$?; cat temp-coverage.out; if [ $status -ne 0 ]; then exit $status; fi; node script/coverage/checkCoverage.js temp-coverage.out && rm temp-coverage.out", + "format:check": "forge fmt --check", + "format:write": "forge fmt", + "validate:upgrades": "npx @openzeppelin/upgrades-core validate out/build-info --exclude \"test/**\" --exclude \"script/**\"" + }, + "author": "circle", + "license": "Apache-2.0", + "repository": "git@github.com:circlefin/fee-orchestrator-contracts.git", + "private": true, + "dependencies": { + "@openzeppelin/contracts": "5.6.1", + "@openzeppelin/contracts-upgradeable": "5.6.1", + "solady": "^0.1.26" + }, + "devDependencies": { + "@openzeppelin/upgrades-core": "^1.44.2", + "solhint": "^6.0.3" + }, + "resolutions": { + "minimatch": "10.2.3", + "brace-expansion": "5.0.5", + "lodash": "4.18.1", + "solidity-ast": "0.4.61" + } +} From db8e8c269d2ee0bbb370e2f7d6a3a02b0f4e9b41 Mon Sep 17 00:00:00 2001 From: undefined Date: Mon, 19 Oct 2026 03:42:38 +0000 Subject: [PATCH 15/21] =?UTF-8?q?=F0=9F=94=84=20created=20local=20'yarn.lo?= =?UTF-8?q?ck'=20from=20remote=20'yarn.lock'?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- yarn.lock | 1369 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 1369 insertions(+) create mode 100644 yarn.lock diff --git a/yarn.lock b/yarn.lock new file mode 100644 index 0000000..e58bb53 --- /dev/null +++ b/yarn.lock @@ -0,0 +1,1369 @@ +# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. +# yarn lockfile v1 + + +"@babel/code-frame@^7.0.0", "@babel/code-frame@^7.27.1": + version "7.29.0" + resolved "https://registry.yarnpkg.com/@babel/code-frame/-/code-frame-7.29.0.tgz#7cd7a59f15b3cc0dcd803038f7792712a7d0b15c" + integrity sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw== + dependencies: + "@babel/helper-validator-identifier" "^7.28.5" + js-tokens "^4.0.0" + picocolors "^1.1.1" + +"@babel/helper-validator-identifier@^7.28.5": + version "7.28.5" + resolved "https://registry.yarnpkg.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz#010b6938fab7cb7df74aa2bbc06aa503b8fe5fb4" + integrity sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q== + +"@bytecodealliance/preview2-shim@0.17.0": + version "0.17.0" + resolved "https://registry.yarnpkg.com/@bytecodealliance/preview2-shim/-/preview2-shim-0.17.0.tgz#9bc1cadbb9f86c446c6f579d3431c08a06a6672e" + integrity sha512-JorcEwe4ud0x5BS/Ar2aQWOQoFzjq/7jcnxYXCvSMh0oRm0dQXzOA+hqLDBnOMks1LLBA7dmiLLsEBl09Yd6iQ== + +"@humanwhocodes/momoa@^2.0.4": + version "2.0.4" + resolved "https://registry.yarnpkg.com/@humanwhocodes/momoa/-/momoa-2.0.4.tgz#8b9e7a629651d15009c3587d07a222deeb829385" + integrity sha512-RE815I4arJFtt+FVeU1Tgp9/Xvecacji8w/V6XtXsWWH/wz/eNkNbhb+ny/+PlVZjV0rxQpRSQKNKE3lcktHEA== + +"@nomicfoundation/slang@^0.18.3": + version "0.18.3" + resolved "https://registry.yarnpkg.com/@nomicfoundation/slang/-/slang-0.18.3.tgz#976b6c3820081cebf050afbea434038aac9313cc" + integrity sha512-YqAWgckqbHM0/CZxi9Nlf4hjk9wUNLC9ngWCWBiqMxPIZmzsVKYuChdlrfeBPQyvQQBoOhbx+7C1005kLVQDZQ== + dependencies: + "@bytecodealliance/preview2-shim" "0.17.0" + +"@openzeppelin/contracts-upgradeable@5.6.1": + version "5.6.1" + resolved "https://registry.yarnpkg.com/@openzeppelin/contracts-upgradeable/-/contracts-upgradeable-5.6.1.tgz#7c1a0543bb73f5389e5f6cad268bf0715b7650e6" + integrity sha512-n4a/vfRs114lXyUdYg7pyY8LvFKWvCDF5lEcRRAVxap8g6ZEdLqm+9tmt2zTtRHcNMxTYp9y5t6KBof4tHp7Og== + +"@openzeppelin/contracts@5.6.1": + version "5.6.1" + resolved "https://registry.yarnpkg.com/@openzeppelin/contracts/-/contracts-5.6.1.tgz#90c1cd427b3c1007ada4f42378ce84cc2a2145a5" + integrity sha512-Ly6SlsVJ3mj+b18W3R8gNufB7dTICT105fJhodGAGgyC2oqnBAhqSiNDJ8V8DLY05cCz81GLI0CU5vNYA1EC/w== + +"@openzeppelin/upgrades-core@^1.44.2": + version "1.44.2" + resolved "https://registry.yarnpkg.com/@openzeppelin/upgrades-core/-/upgrades-core-1.44.2.tgz#c38d6adc075f4d5e946c0fef9ba4b68953adec6a" + integrity sha512-m6iorjyhPK9ow5/trNs7qsBC/SOzJCO51pvvAF2W9nOiZ1t0RtCd+rlRmRmlWTv4M33V0wzIUeamJ2BPbzgUXA== + dependencies: + "@nomicfoundation/slang" "^0.18.3" + bignumber.js "^9.1.2" + cbor "^10.0.0" + chalk "^4.1.0" + compare-versions "^6.0.0" + debug "^4.1.1" + ethereumjs-util "^7.0.3" + minimatch "^9.0.5" + minimist "^1.2.7" + proper-lockfile "^4.1.1" + solidity-ast "^0.4.60" + +"@pnpm/config.env-replace@^1.1.0": + version "1.1.0" + resolved "https://registry.yarnpkg.com/@pnpm/config.env-replace/-/config.env-replace-1.1.0.tgz#ab29da53df41e8948a00f2433f085f54de8b3a4c" + integrity sha512-htyl8TWnKL7K/ESFa1oW2UB5lVDxuF5DpM7tBi6Hu2LNL3mWkIzNLG6N4zoCUP1lCKNxWy/3iu8mS8MvToGd6w== + +"@pnpm/network.ca-file@^1.0.1": + version "1.0.2" + resolved "https://registry.yarnpkg.com/@pnpm/network.ca-file/-/network.ca-file-1.0.2.tgz#2ab05e09c1af0cdf2fcf5035bea1484e222f7983" + integrity sha512-YcPQ8a0jwYU9bTdJDpXjMi7Brhkr1mXsXrUJvjqM2mQDgkRiz8jFaQGOdaLxgjtUfQgZhKy/O3cG/YwmgKaxLA== + dependencies: + graceful-fs "4.2.10" + +"@pnpm/npm-conf@^3.0.2": + version "3.0.2" + resolved "https://registry.yarnpkg.com/@pnpm/npm-conf/-/npm-conf-3.0.2.tgz#857622421aa9bbf254e557b8a022c216a7928f47" + integrity sha512-h104Kh26rR8tm+a3Qkc5S4VLYint3FE48as7+/5oCEcKR2idC/pF1G6AhIXKI+eHPJa/3J9i5z0Al47IeGHPkA== + dependencies: + "@pnpm/config.env-replace" "^1.1.0" + "@pnpm/network.ca-file" "^1.0.1" + config-chain "^1.1.11" + +"@sindresorhus/is@^5.2.0": + version "5.6.0" + resolved "https://registry.yarnpkg.com/@sindresorhus/is/-/is-5.6.0.tgz#41dd6093d34652cddb5d5bdeee04eafc33826668" + integrity sha512-TV7t8GKYaJWsn00tFDqBw8+Uqmr8A0fRU1tvTQhyZzGv0sJCGRQL3JGMI3ucuKo3XIZdUP+Lx7/gh2t3lewy7g== + +"@solidity-parser/parser@^0.20.2": + version "0.20.2" + resolved "https://registry.yarnpkg.com/@solidity-parser/parser/-/parser-0.20.2.tgz#e07053488ed60dae1b54f6fe37bb6d2c5fe146a7" + integrity sha512-rbu0bzwNvMcwAjH86hiEAcOeRI2EeK8zCkHDrFykh/Al8mvJeFmjy3UrE7GYQjNwOgbGUUtCn5/k8CB8zIu7QA== + +"@szmarczak/http-timer@^5.0.1": + version "5.0.1" + resolved "https://registry.yarnpkg.com/@szmarczak/http-timer/-/http-timer-5.0.1.tgz#c7c1bf1141cdd4751b0399c8fc7b8b664cd5be3a" + integrity sha512-+PmQX0PiAYPMeVYe237LJAYvOMYW1j2rH5YROyS3b4CTVJum34HfRvKvAzozHAQG0TnHNdUfY9nCeUyRAs//cw== + dependencies: + defer-to-connect "^2.0.1" + +"@types/bn.js@^5.1.0": + version "5.2.0" + resolved "https://registry.yarnpkg.com/@types/bn.js/-/bn.js-5.2.0.tgz#4349b9710e98f9ab3cdc50f1c5e4dcbd8ef29c80" + integrity sha512-DLbJ1BPqxvQhIGbeu8VbUC1DiAiahHtAYvA0ZEAa4P31F7IaArc8z3C3BRQdWX4mtLQuABG4yzp76ZrS02Ui1Q== + dependencies: + "@types/node" "*" + +"@types/http-cache-semantics@^4.0.2": + version "4.2.0" + resolved "https://registry.yarnpkg.com/@types/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz#f6a7788f438cbfde15f29acad46512b4c01913b3" + integrity sha512-L3LgimLHXtGkWikKnsPg0/VFx9OGZaC+eN1u4r+OB1XRqH3meBIAVC2zr1WdMH+RHmnRkqliQAOHNJ/E0j/e0Q== + +"@types/node@*": + version "25.5.0" + resolved "https://registry.yarnpkg.com/@types/node/-/node-25.5.0.tgz#5c99f37c443d9ccc4985866913f1ed364217da31" + integrity sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw== + dependencies: + undici-types "~7.18.0" + +"@types/pbkdf2@^3.0.0": + version "3.1.2" + resolved "https://registry.yarnpkg.com/@types/pbkdf2/-/pbkdf2-3.1.2.tgz#2dc43808e9985a2c69ff02e2d2027bd4fe33e8dc" + integrity sha512-uRwJqmiXmh9++aSu1VNEn3iIxWOhd8AHXNSdlaLfdAAdSTY9jYVeGWnzejM3dvrkbqE3/hyQkQQ29IFATEGlew== + dependencies: + "@types/node" "*" + +"@types/secp256k1@^4.0.1": + version "4.0.7" + resolved "https://registry.yarnpkg.com/@types/secp256k1/-/secp256k1-4.0.7.tgz#534c9814eb80964962108ad45d549d1555c75fa0" + integrity sha512-Rcvjl6vARGAKRO6jHeKMatGrvOMGrR/AR11N1x2LqintPCyDZ7NBhrh238Z2VZc7aM7KIwnFpFQ7fnfK4H/9Qw== + dependencies: + "@types/node" "*" + +ajv-errors@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/ajv-errors/-/ajv-errors-1.0.1.tgz#f35986aceb91afadec4102fbd85014950cefa64d" + integrity sha512-DCRfO/4nQ+89p/RK43i8Ezd41EqdGIU4ld7nGF8OQ14oc/we5rEntLCUa7+jrn3nn83BosfwZA0wb4pon2o8iQ== + +ajv@^6.12.6: + version "6.14.0" + resolved "https://registry.yarnpkg.com/ajv/-/ajv-6.14.0.tgz#fd067713e228210636ebb08c60bd3765d6dbe73a" + integrity sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw== + dependencies: + fast-deep-equal "^3.1.1" + fast-json-stable-stringify "^2.0.0" + json-schema-traverse "^0.4.1" + uri-js "^4.2.2" + +ajv@^8.0.1: + version "8.18.0" + resolved "https://registry.yarnpkg.com/ajv/-/ajv-8.18.0.tgz#8864186b6738d003eb3a933172bb3833e10cefbc" + integrity sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A== + dependencies: + fast-deep-equal "^3.1.3" + fast-uri "^3.0.1" + json-schema-traverse "^1.0.0" + require-from-string "^2.0.2" + +ansi-regex@^5.0.1: + version "5.0.1" + resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-5.0.1.tgz#082cb2c89c9fe8659a311a53bd6a4dc5301db304" + integrity sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ== + +ansi-styles@^4.0.0, ansi-styles@^4.1.0: + version "4.3.0" + resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-4.3.0.tgz#edd803628ae71c04c85ae7a0906edad34b648937" + integrity sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg== + dependencies: + color-convert "^2.0.1" + +argparse@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/argparse/-/argparse-2.0.1.tgz#246f50f3ca78a3240f6c997e8a9bd1eac49e4b38" + integrity sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q== + +ast-parents@^0.0.1: + version "0.0.1" + resolved "https://registry.yarnpkg.com/ast-parents/-/ast-parents-0.0.1.tgz#508fd0f05d0c48775d9eccda2e174423261e8dd3" + integrity sha512-XHusKxKz3zoYk1ic8Un640joHbFMhbqneyoZfoKnEGtf2ey9Uh/IdpcQplODdO/kENaMIWsD0nJm4+wX3UNLHA== + +astral-regex@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/astral-regex/-/astral-regex-2.0.0.tgz#483143c567aeed4785759c0865786dc77d7d2e31" + integrity sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ== + +available-typed-arrays@^1.0.7: + version "1.0.7" + resolved "https://registry.yarnpkg.com/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz#a5cc375d6a03c2efc87a553f3e0b1522def14846" + integrity sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ== + dependencies: + possible-typed-array-names "^1.0.0" + +balanced-match@^4.0.2: + version "4.0.4" + resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-4.0.4.tgz#bfb10662feed8196a2c62e7c68e17720c274179a" + integrity sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA== + +base-x@^3.0.2: + version "3.0.11" + resolved "https://registry.yarnpkg.com/base-x/-/base-x-3.0.11.tgz#40d80e2a1aeacba29792ccc6c5354806421287ff" + integrity sha512-xz7wQ8xDhdyP7tQxwdteLYeFfS68tSMNCZ/Y37WJ4bhGfKPpqEIlmIyueQHqOyoPhE6xNUqjzRr8ra0eF9VRvA== + dependencies: + safe-buffer "^5.0.1" + +better-ajv-errors@^2.0.2: + version "2.0.3" + resolved "https://registry.yarnpkg.com/better-ajv-errors/-/better-ajv-errors-2.0.3.tgz#effc8d80b5b9777447159bfec7492daedeb75ecb" + integrity sha512-t1vxUP+vYKsaYi/BbKo2K98nEAZmfi4sjwvmRT8aOPDzPJeAtLurfoIDazVkLILxO4K+Sw4YrLYnBQ46l6pePg== + dependencies: + "@babel/code-frame" "^7.27.1" + "@humanwhocodes/momoa" "^2.0.4" + chalk "^4.1.2" + jsonpointer "^5.0.1" + leven "^3.1.0 < 4" + +bignumber.js@^9.1.2: + version "9.3.1" + resolved "https://registry.yarnpkg.com/bignumber.js/-/bignumber.js-9.3.1.tgz#759c5aaddf2ffdc4f154f7b493e1c8770f88c4d7" + integrity sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ== + +blakejs@^1.1.0: + version "1.2.1" + resolved "https://registry.yarnpkg.com/blakejs/-/blakejs-1.2.1.tgz#5057e4206eadb4a97f7c0b6e197a505042fc3814" + integrity sha512-QXUSXI3QVc/gJME0dBpXrag1kbzOqCjCX8/b54ntNyW6sjtoqxqRk3LTmXzaJoh71zMsDCjM+47jS7XiwN/+fQ== + +bn.js@^4.11.9: + version "4.12.3" + resolved "https://registry.yarnpkg.com/bn.js/-/bn.js-4.12.3.tgz#2cc2c679188eb35b006f2d0d4710bed8437a769e" + integrity sha512-fGTi3gxV/23FTYdAoUtLYp6qySe2KE3teyZitipKNRuVYcBkoP/bB3guXN/XVKUe9mxCHXnc9C4ocyz8OmgN0g== + +bn.js@^5.1.2, bn.js@^5.2.0: + version "5.2.3" + resolved "https://registry.yarnpkg.com/bn.js/-/bn.js-5.2.3.tgz#16a9e409616b23fef3ccbedb8d42f13bff80295e" + integrity sha512-EAcmnPkxpntVL+DS7bO1zhcZNvCkxqtkd0ZY53h06GNQ3DEkkGZ/gKgmDv6DdZQGj9BgfSPKtJJ7Dp1GPP8f7w== + +brace-expansion@5.0.5, brace-expansion@^5.0.2: + version "5.0.5" + resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-5.0.5.tgz#dcc3a37116b79f3e1b46db994ced5d570e930fdb" + integrity sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ== + dependencies: + balanced-match "^4.0.2" + +brorand@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/brorand/-/brorand-1.1.0.tgz#12c25efe40a45e3c323eb8675a0a0ce57b22371f" + integrity sha512-cKV8tMCEpQs4hK/ik71d6LrPOnpkpGBR0wzxqr68g2m/LB2GxVYQroAjMJZRVM1Y4BCjCKc3vAamxSzOY2RP+w== + +browserify-aes@^1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/browserify-aes/-/browserify-aes-1.2.0.tgz#326734642f403dabc3003209853bb70ad428ef48" + integrity sha512-+7CHXqGuspUn/Sl5aO7Ea0xWGAtETPXNSAjHo48JfLdPWcMng33Xe4znFvQweqc/uzk5zSOI3H52CYnjCfb5hA== + dependencies: + buffer-xor "^1.0.3" + cipher-base "^1.0.0" + create-hash "^1.1.0" + evp_bytestokey "^1.0.3" + inherits "^2.0.1" + safe-buffer "^5.0.1" + +bs58@^4.0.0: + version "4.0.1" + resolved "https://registry.yarnpkg.com/bs58/-/bs58-4.0.1.tgz#be161e76c354f6f788ae4071f63f34e8c4f0a42a" + integrity sha512-Ok3Wdf5vOIlBrgCvTq96gBkJw+JUEzdBgyaza5HLtPm7yTHkjRy8+JzNyHF7BHa0bNWOQIp3m5YF0nnFcOIKLw== + dependencies: + base-x "^3.0.2" + +bs58check@^2.1.2: + version "2.1.2" + resolved "https://registry.yarnpkg.com/bs58check/-/bs58check-2.1.2.tgz#53b018291228d82a5aa08e7d796fdafda54aebfc" + integrity sha512-0TS1jicxdU09dwJMNZtVAfzPi6Q6QeN0pM1Fkzrjn+XYHvzMKPU3pHVpva+769iNVSfIYWf7LJ6WR+BuuMf8cA== + dependencies: + bs58 "^4.0.0" + create-hash "^1.1.0" + safe-buffer "^5.1.2" + +buffer-xor@^1.0.3: + version "1.0.3" + resolved "https://registry.yarnpkg.com/buffer-xor/-/buffer-xor-1.0.3.tgz#26e61ed1422fb70dd42e6e36729ed51d855fe8d9" + integrity sha512-571s0T7nZWK6vB67HI5dyUF7wXiNcfaPPPTl6zYCNApANjIvYJTg7hlud/+cJpdAhS7dVzqMLmfhfHR3rAcOjQ== + +cacheable-lookup@^7.0.0: + version "7.0.0" + resolved "https://registry.yarnpkg.com/cacheable-lookup/-/cacheable-lookup-7.0.0.tgz#3476a8215d046e5a3202a9209dd13fec1f933a27" + integrity sha512-+qJyx4xiKra8mZrcwhjMRMUhD5NR1R8esPkzIYxX96JiecFoxAXFuz/GpR3+ev4PE1WamHip78wV0vcmPQtp8w== + +cacheable-request@^10.2.8: + version "10.2.14" + resolved "https://registry.yarnpkg.com/cacheable-request/-/cacheable-request-10.2.14.tgz#eb915b665fda41b79652782df3f553449c406b9d" + integrity sha512-zkDT5WAF4hSSoUgyfg5tFIxz8XQK+25W/TLVojJTMKBaxevLBBtLxgqguAuVQB8PVW79FVjHcU+GJ9tVbDZ9mQ== + dependencies: + "@types/http-cache-semantics" "^4.0.2" + get-stream "^6.0.1" + http-cache-semantics "^4.1.1" + keyv "^4.5.3" + mimic-response "^4.0.0" + normalize-url "^8.0.0" + responselike "^3.0.0" + +call-bind-apply-helpers@^1.0.0, call-bind-apply-helpers@^1.0.1, call-bind-apply-helpers@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz#4b5428c222be985d79c3d82657479dbe0b59b2d6" + integrity sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ== + dependencies: + es-errors "^1.3.0" + function-bind "^1.1.2" + +call-bind@^1.0.8: + version "1.0.8" + resolved "https://registry.yarnpkg.com/call-bind/-/call-bind-1.0.8.tgz#0736a9660f537e3388826f440d5ec45f744eaa4c" + integrity sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww== + dependencies: + call-bind-apply-helpers "^1.0.0" + es-define-property "^1.0.0" + get-intrinsic "^1.2.4" + set-function-length "^1.2.2" + +call-bound@^1.0.3, call-bound@^1.0.4: + version "1.0.4" + resolved "https://registry.yarnpkg.com/call-bound/-/call-bound-1.0.4.tgz#238de935d2a2a692928c538c7ccfa91067fd062a" + integrity sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg== + dependencies: + call-bind-apply-helpers "^1.0.2" + get-intrinsic "^1.3.0" + +callsites@^3.0.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/callsites/-/callsites-3.1.0.tgz#b3630abd8943432f54b3f0519238e33cd7df2f73" + integrity sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ== + +cbor@^10.0.0: + version "10.0.12" + resolved "https://registry.yarnpkg.com/cbor/-/cbor-10.0.12.tgz#2ae291481ef6f28127ba39a9f8ee22f29677e04f" + integrity sha512-exQDevYd7ZQLP4moMQcZkKCVZsXLAtUSflObr3xTh4xzFIv/xBCdvCd6L259kQOUP2kcTC0jvC6PpZIf/WmRXA== + dependencies: + nofilter "^3.0.2" + +chalk@^4.1.0, chalk@^4.1.2: + version "4.1.2" + resolved "https://registry.yarnpkg.com/chalk/-/chalk-4.1.2.tgz#aac4e2b7734a740867aeb16bf02aad556a1e7a01" + integrity sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA== + dependencies: + ansi-styles "^4.1.0" + supports-color "^7.1.0" + +cipher-base@^1.0.0, cipher-base@^1.0.1, cipher-base@^1.0.3: + version "1.0.7" + resolved "https://registry.yarnpkg.com/cipher-base/-/cipher-base-1.0.7.tgz#bd094bfef42634ccfd9e13b9fc73274997111e39" + integrity sha512-Mz9QMT5fJe7bKI7MH31UilT5cEK5EHHRCccw/YRFsRY47AuNgaV6HY3rscp0/I4Q+tTW/5zoqpSeRRI54TkDWA== + dependencies: + inherits "^2.0.4" + safe-buffer "^5.2.1" + to-buffer "^1.2.2" + +color-convert@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/color-convert/-/color-convert-2.0.1.tgz#72d3a68d598c9bdb3af2ad1e84f21d896abd4de3" + integrity sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ== + dependencies: + color-name "~1.1.4" + +color-name@~1.1.4: + version "1.1.4" + resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.4.tgz#c2a09a87acbde69543de6f63fa3995c826c536a2" + integrity sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA== + +commander@^10.0.0: + version "10.0.1" + resolved "https://registry.yarnpkg.com/commander/-/commander-10.0.1.tgz#881ee46b4f77d1c1dccc5823433aa39b022cbe06" + integrity sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug== + +compare-versions@^6.0.0: + version "6.1.1" + resolved "https://registry.yarnpkg.com/compare-versions/-/compare-versions-6.1.1.tgz#7af3cc1099ba37d244b3145a9af5201b629148a9" + integrity sha512-4hm4VPpIecmlg59CHXnRDnqGplJFrbLG4aFEl5vl6cK1u76ws3LLvX7ikFnTDl5vo39sjWD6AaDPYodJp/NNHg== + +config-chain@^1.1.11: + version "1.1.13" + resolved "https://registry.yarnpkg.com/config-chain/-/config-chain-1.1.13.tgz#fad0795aa6a6cdaff9ed1b68e9dff94372c232f4" + integrity sha512-qj+f8APARXHrM0hraqXYb2/bOVSV4PvJQlNZ/DVj0QrmNM2q2euizkeuVckQ57J+W0mRH6Hvi+k50M4Jul2VRQ== + dependencies: + ini "^1.3.4" + proto-list "~1.2.1" + +core-util-is@~1.0.0: + version "1.0.3" + resolved "https://registry.yarnpkg.com/core-util-is/-/core-util-is-1.0.3.tgz#a6042d3634c2b27e9328f837b965fac83808db85" + integrity sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ== + +cosmiconfig@^8.0.0: + version "8.3.6" + resolved "https://registry.yarnpkg.com/cosmiconfig/-/cosmiconfig-8.3.6.tgz#060a2b871d66dba6c8538ea1118ba1ac16f5fae3" + integrity sha512-kcZ6+W5QzcJ3P1Mt+83OUv/oHFqZHIx8DuxG6eZ5RGMERoLqp4BuGjhHLYGK+Kf5XVkQvqBSmAy/nGWN3qDgEA== + dependencies: + import-fresh "^3.3.0" + js-yaml "^4.1.0" + parse-json "^5.2.0" + path-type "^4.0.0" + +create-hash@^1.1.0, create-hash@^1.1.2, create-hash@^1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/create-hash/-/create-hash-1.2.0.tgz#889078af11a63756bcfb59bd221996be3a9ef196" + integrity sha512-z00bCGNHDG8mHAkP7CtT1qVu+bFQUPjYq/4Iv3C3kWjTFV10zIjfSoeqXo9Asws8gwSHDGj/hl2u4OGIjapeCg== + dependencies: + cipher-base "^1.0.1" + inherits "^2.0.1" + md5.js "^1.3.4" + ripemd160 "^2.0.1" + sha.js "^2.4.0" + +create-hmac@^1.1.7: + version "1.1.7" + resolved "https://registry.yarnpkg.com/create-hmac/-/create-hmac-1.1.7.tgz#69170c78b3ab957147b2b8b04572e47ead2243ff" + integrity sha512-MJG9liiZ+ogc4TzUwuvbER1JRdgvUFSB5+VR/g5h82fGaIRWMWddtKBHi7/sVhfjQZ6SehlyhvQYrcYkaUIpLg== + dependencies: + cipher-base "^1.0.3" + create-hash "^1.1.0" + inherits "^2.0.1" + ripemd160 "^2.0.0" + safe-buffer "^5.0.1" + sha.js "^2.4.8" + +debug@^4.1.1: + version "4.4.3" + resolved "https://registry.yarnpkg.com/debug/-/debug-4.4.3.tgz#c6ae432d9bd9662582fce08709b038c58e9e3d6a" + integrity sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA== + dependencies: + ms "^2.1.3" + +decompress-response@^6.0.0: + version "6.0.0" + resolved "https://registry.yarnpkg.com/decompress-response/-/decompress-response-6.0.0.tgz#ca387612ddb7e104bd16d85aab00d5ecf09c66fc" + integrity sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ== + dependencies: + mimic-response "^3.1.0" + +deep-extend@^0.6.0: + version "0.6.0" + resolved "https://registry.yarnpkg.com/deep-extend/-/deep-extend-0.6.0.tgz#c4fa7c95404a17a9c3e8ca7e1537312b736330ac" + integrity sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA== + +defer-to-connect@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/defer-to-connect/-/defer-to-connect-2.0.1.tgz#8016bdb4143e4632b77a3449c6236277de520587" + integrity sha512-4tvttepXG1VaYGrRibk5EwJd1t4udunSOVMdLSAL6mId1ix438oPwPZMALY41FCijukO1L0twNcGsdzS7dHgDg== + +define-data-property@^1.1.4: + version "1.1.4" + resolved "https://registry.yarnpkg.com/define-data-property/-/define-data-property-1.1.4.tgz#894dc141bb7d3060ae4366f6a0107e68fbe48c5e" + integrity sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A== + dependencies: + es-define-property "^1.0.0" + es-errors "^1.3.0" + gopd "^1.0.1" + +dunder-proto@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/dunder-proto/-/dunder-proto-1.0.1.tgz#d7ae667e1dc83482f8b70fd0f6eefc50da30f58a" + integrity sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A== + dependencies: + call-bind-apply-helpers "^1.0.1" + es-errors "^1.3.0" + gopd "^1.2.0" + +elliptic@^6.5.7: + version "6.6.1" + resolved "https://registry.yarnpkg.com/elliptic/-/elliptic-6.6.1.tgz#3b8ffb02670bf69e382c7f65bf524c97c5405c06" + integrity sha512-RaddvvMatK2LJHqFJ+YA4WysVN5Ita9E35botqIYspQ4TkRAlCicdzKOjlyv/1Za5RyTNn7di//eEV0uTAfe3g== + dependencies: + bn.js "^4.11.9" + brorand "^1.1.0" + hash.js "^1.0.0" + hmac-drbg "^1.0.1" + inherits "^2.0.4" + minimalistic-assert "^1.0.1" + minimalistic-crypto-utils "^1.0.1" + +emoji-regex@^8.0.0: + version "8.0.0" + resolved "https://registry.yarnpkg.com/emoji-regex/-/emoji-regex-8.0.0.tgz#e818fd69ce5ccfcb404594f842963bf53164cc37" + integrity sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A== + +error-ex@^1.3.1: + version "1.3.4" + resolved "https://registry.yarnpkg.com/error-ex/-/error-ex-1.3.4.tgz#b3a8d8bb6f92eecc1629e3e27d3c8607a8a32414" + integrity sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ== + dependencies: + is-arrayish "^0.2.1" + +es-define-property@^1.0.0, es-define-property@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/es-define-property/-/es-define-property-1.0.1.tgz#983eb2f9a6724e9303f61addf011c72e09e0b0fa" + integrity sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g== + +es-errors@^1.3.0: + version "1.3.0" + resolved "https://registry.yarnpkg.com/es-errors/-/es-errors-1.3.0.tgz#05f75a25dab98e4fb1dcd5e1472c0546d5057c8f" + integrity sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw== + +es-object-atoms@^1.0.0, es-object-atoms@^1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/es-object-atoms/-/es-object-atoms-1.1.1.tgz#1c4f2c4837327597ce69d2ca190a7fdd172338c1" + integrity sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA== + dependencies: + es-errors "^1.3.0" + +ethereum-cryptography@^0.1.3: + version "0.1.3" + resolved "https://registry.yarnpkg.com/ethereum-cryptography/-/ethereum-cryptography-0.1.3.tgz#8d6143cfc3d74bf79bbd8edecdf29e4ae20dd191" + integrity sha512-w8/4x1SGGzc+tO97TASLja6SLd3fRIK2tLVcV2Gx4IB21hE19atll5Cq9o3d0ZmAYC/8aw0ipieTSiekAea4SQ== + dependencies: + "@types/pbkdf2" "^3.0.0" + "@types/secp256k1" "^4.0.1" + blakejs "^1.1.0" + browserify-aes "^1.2.0" + bs58check "^2.1.2" + create-hash "^1.2.0" + create-hmac "^1.1.7" + hash.js "^1.1.7" + keccak "^3.0.0" + pbkdf2 "^3.0.17" + randombytes "^2.1.0" + safe-buffer "^5.1.2" + scrypt-js "^3.0.0" + secp256k1 "^4.0.1" + setimmediate "^1.0.5" + +ethereumjs-util@^7.0.3: + version "7.1.5" + resolved "https://registry.yarnpkg.com/ethereumjs-util/-/ethereumjs-util-7.1.5.tgz#9ecf04861e4fbbeed7465ece5f23317ad1129181" + integrity sha512-SDl5kKrQAudFBUe5OJM9Ac6WmMyYmXX/6sTmLZ3ffG2eY6ZIGBes3pEDxNN6V72WyOw4CPD5RomKdsa8DAAwLg== + dependencies: + "@types/bn.js" "^5.1.0" + bn.js "^5.1.2" + create-hash "^1.1.2" + ethereum-cryptography "^0.1.3" + rlp "^2.2.4" + +evp_bytestokey@^1.0.3: + version "1.0.3" + resolved "https://registry.yarnpkg.com/evp_bytestokey/-/evp_bytestokey-1.0.3.tgz#7fcbdb198dc71959432efe13842684e0525acb02" + integrity sha512-/f2Go4TognH/KvCISP7OUsHn85hT9nUkxxA9BEWxFn+Oj9o8ZNLm/40hdlgSLyuOimsrTKLUMEorQexp/aPQeA== + dependencies: + md5.js "^1.3.4" + safe-buffer "^5.1.1" + +fast-deep-equal@^3.1.1, fast-deep-equal@^3.1.3: + version "3.1.3" + resolved "https://registry.yarnpkg.com/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz#3a7d56b559d6cbc3eb512325244e619a65c6c525" + integrity sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q== + +fast-diff@^1.2.0: + version "1.3.0" + resolved "https://registry.yarnpkg.com/fast-diff/-/fast-diff-1.3.0.tgz#ece407fa550a64d638536cd727e129c61616e0f0" + integrity sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw== + +fast-json-stable-stringify@^2.0.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz#874bf69c6f404c2b5d99c481341399fd55892633" + integrity sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw== + +fast-uri@^3.0.1: + version "3.1.2" + resolved "https://registry.yarnpkg.com/fast-uri/-/fast-uri-3.1.2.tgz#8af3d4fc9d3e71b11572cc2673b514a7d1a8c8ec" + integrity sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ== + +for-each@^0.3.5: + version "0.3.5" + resolved "https://registry.yarnpkg.com/for-each/-/for-each-0.3.5.tgz#d650688027826920feeb0af747ee7b9421a41d47" + integrity sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg== + dependencies: + is-callable "^1.2.7" + +form-data-encoder@^2.1.2: + version "2.1.4" + resolved "https://registry.yarnpkg.com/form-data-encoder/-/form-data-encoder-2.1.4.tgz#261ea35d2a70d48d30ec7a9603130fa5515e9cd5" + integrity sha512-yDYSgNMraqvnxiEXO4hi88+YZxaHC6QKzb5N84iRCTDeRO7ZALpir/lVmf/uXUhnwUr2O4HU8s/n6x+yNjQkHw== + +fs.realpath@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/fs.realpath/-/fs.realpath-1.0.0.tgz#1504ad2523158caa40db4a2787cb01411994ea4f" + integrity sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw== + +function-bind@^1.1.2: + version "1.1.2" + resolved "https://registry.yarnpkg.com/function-bind/-/function-bind-1.1.2.tgz#2c02d864d97f3ea6c8830c464cbd11ab6eab7a1c" + integrity sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA== + +get-intrinsic@^1.2.4, get-intrinsic@^1.3.0: + version "1.3.0" + resolved "https://registry.yarnpkg.com/get-intrinsic/-/get-intrinsic-1.3.0.tgz#743f0e3b6964a93a5491ed1bffaae054d7f98d01" + integrity sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ== + dependencies: + call-bind-apply-helpers "^1.0.2" + es-define-property "^1.0.1" + es-errors "^1.3.0" + es-object-atoms "^1.1.1" + function-bind "^1.1.2" + get-proto "^1.0.1" + gopd "^1.2.0" + has-symbols "^1.1.0" + hasown "^2.0.2" + math-intrinsics "^1.1.0" + +get-proto@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/get-proto/-/get-proto-1.0.1.tgz#150b3f2743869ef3e851ec0c49d15b1d14d00ee1" + integrity sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g== + dependencies: + dunder-proto "^1.0.1" + es-object-atoms "^1.0.0" + +get-stream@^6.0.1: + version "6.0.1" + resolved "https://registry.yarnpkg.com/get-stream/-/get-stream-6.0.1.tgz#a262d8eef67aced57c2852ad6167526a43cbf7b7" + integrity sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg== + +glob@^8.0.3: + version "8.1.0" + resolved "https://registry.yarnpkg.com/glob/-/glob-8.1.0.tgz#d388f656593ef708ee3e34640fdfb99a9fd1c33e" + integrity sha512-r8hpEjiQEYlF2QU0df3dS+nxxSIreXQS1qRhMJM0Q5NDdR386C7jb7Hwwod8Fgiuex+k0GFjgft18yvxm5XoCQ== + dependencies: + fs.realpath "^1.0.0" + inflight "^1.0.4" + inherits "2" + minimatch "^5.0.1" + once "^1.3.0" + +gopd@^1.0.1, gopd@^1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/gopd/-/gopd-1.2.0.tgz#89f56b8217bdbc8802bd299df6d7f1081d7e51a1" + integrity sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg== + +got@^12.1.0: + version "12.6.1" + resolved "https://registry.yarnpkg.com/got/-/got-12.6.1.tgz#8869560d1383353204b5a9435f782df9c091f549" + integrity sha512-mThBblvlAF1d4O5oqyvN+ZxLAYwIJK7bpMxgYqPD9okW0C3qm5FFn7k811QrcuEBwaogR3ngOFoCfs6mRv7teQ== + dependencies: + "@sindresorhus/is" "^5.2.0" + "@szmarczak/http-timer" "^5.0.1" + cacheable-lookup "^7.0.0" + cacheable-request "^10.2.8" + decompress-response "^6.0.0" + form-data-encoder "^2.1.2" + get-stream "^6.0.1" + http2-wrapper "^2.1.10" + lowercase-keys "^3.0.0" + p-cancelable "^3.0.0" + responselike "^3.0.0" + +graceful-fs@4.2.10: + version "4.2.10" + resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.2.10.tgz#147d3a006da4ca3ce14728c7aefc287c367d7a6c" + integrity sha512-9ByhssR2fPVsNZj478qUUbKfmL0+t5BDVyjShtyZZLiK7ZDAArFFfopyOTj0M05wE2tJPisA4iTnnXl2YoPvOA== + +graceful-fs@^4.2.4: + version "4.2.11" + resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.2.11.tgz#4183e4e8bf08bb6e05bbb2f7d2e0c8f712ca40e3" + integrity sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ== + +has-flag@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/has-flag/-/has-flag-4.0.0.tgz#944771fd9c81c81265c4d6941860da06bb59479b" + integrity sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ== + +has-property-descriptors@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz#963ed7d071dc7bf5f084c5bfbe0d1b6222586854" + integrity sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg== + dependencies: + es-define-property "^1.0.0" + +has-symbols@^1.0.3, has-symbols@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/has-symbols/-/has-symbols-1.1.0.tgz#fc9c6a783a084951d0b971fe1018de813707a338" + integrity sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ== + +has-tostringtag@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/has-tostringtag/-/has-tostringtag-1.0.2.tgz#2cdc42d40bef2e5b4eeab7c01a73c54ce7ab5abc" + integrity sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw== + dependencies: + has-symbols "^1.0.3" + +hash-base@^3.0.0, hash-base@^3.1.2: + version "3.1.2" + resolved "https://registry.yarnpkg.com/hash-base/-/hash-base-3.1.2.tgz#79d72def7611c3f6e3c3b5730652638001b10a74" + integrity sha512-Bb33KbowVTIj5s7Ked1OsqHUeCpz//tPwR+E2zJgJKo9Z5XolZ9b6bdUgjmYlwnWhoOQKoTd1TYToZGn5mAYOg== + dependencies: + inherits "^2.0.4" + readable-stream "^2.3.8" + safe-buffer "^5.2.1" + to-buffer "^1.2.1" + +hash.js@^1.0.0, hash.js@^1.0.3, hash.js@^1.1.7: + version "1.1.7" + resolved "https://registry.yarnpkg.com/hash.js/-/hash.js-1.1.7.tgz#0babca538e8d4ee4a0f8988d68866537a003cf42" + integrity sha512-taOaskGt4z4SOANNseOviYDvjEJinIkRgmp7LbKP2YTTmVxWBl87s/uzK9r+44BclBSp2X7K1hqeNfz9JbBeXA== + dependencies: + inherits "^2.0.3" + minimalistic-assert "^1.0.1" + +hasown@^2.0.2: + version "2.0.2" + resolved "https://registry.yarnpkg.com/hasown/-/hasown-2.0.2.tgz#003eaf91be7adc372e84ec59dc37252cedb80003" + integrity sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ== + dependencies: + function-bind "^1.1.2" + +hmac-drbg@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/hmac-drbg/-/hmac-drbg-1.0.1.tgz#d2745701025a6c775a6c545793ed502fc0c649a1" + integrity sha512-Tti3gMqLdZfhOQY1Mzf/AanLiqh1WTiJgEj26ZuYQ9fbkLomzGchCws4FyrSd4VkpBfiNhaE1On+lOz894jvXg== + dependencies: + hash.js "^1.0.3" + minimalistic-assert "^1.0.0" + minimalistic-crypto-utils "^1.0.1" + +http-cache-semantics@^4.1.1: + version "4.2.0" + resolved "https://registry.yarnpkg.com/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz#205f4db64f8562b76a4ff9235aa5279839a09dd5" + integrity sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ== + +http2-wrapper@^2.1.10: + version "2.2.1" + resolved "https://registry.yarnpkg.com/http2-wrapper/-/http2-wrapper-2.2.1.tgz#310968153dcdedb160d8b72114363ef5fce1f64a" + integrity sha512-V5nVw1PAOgfI3Lmeaj2Exmeg7fenjhRUgz1lPSezy1CuhPYbgQtbQj4jZfEAEMlaL+vupsvhjqCyjzob0yxsmQ== + dependencies: + quick-lru "^5.1.1" + resolve-alpn "^1.2.0" + +ignore@^5.2.4: + version "5.3.2" + resolved "https://registry.yarnpkg.com/ignore/-/ignore-5.3.2.tgz#3cd40e729f3643fd87cb04e50bf0eb722bc596f5" + integrity sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g== + +import-fresh@^3.3.0: + version "3.3.1" + resolved "https://registry.yarnpkg.com/import-fresh/-/import-fresh-3.3.1.tgz#9cecb56503c0ada1f2741dbbd6546e4b13b57ccf" + integrity sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ== + dependencies: + parent-module "^1.0.0" + resolve-from "^4.0.0" + +inflight@^1.0.4: + version "1.0.6" + resolved "https://registry.yarnpkg.com/inflight/-/inflight-1.0.6.tgz#49bd6331d7d02d0c09bc910a1075ba8165b56df9" + integrity sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA== + dependencies: + once "^1.3.0" + wrappy "1" + +inherits@2, inherits@^2.0.1, inherits@^2.0.3, inherits@^2.0.4, inherits@~2.0.3: + version "2.0.4" + resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.4.tgz#0fa2c64f932917c3433a0ded55363aae37416b7c" + integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ== + +ini@^1.3.4, ini@~1.3.0: + version "1.3.8" + resolved "https://registry.yarnpkg.com/ini/-/ini-1.3.8.tgz#a29da425b48806f34767a4efce397269af28432c" + integrity sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew== + +is-arrayish@^0.2.1: + version "0.2.1" + resolved "https://registry.yarnpkg.com/is-arrayish/-/is-arrayish-0.2.1.tgz#77c99840527aa8ecb1a8ba697b80645a7a926a9d" + integrity sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg== + +is-callable@^1.2.7: + version "1.2.7" + resolved "https://registry.yarnpkg.com/is-callable/-/is-callable-1.2.7.tgz#3bc2a85ea742d9e36205dcacdd72ca1fdc51b055" + integrity sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA== + +is-fullwidth-code-point@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz#f116f8064fe90b3f7844a38997c0b75051269f1d" + integrity sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg== + +is-typed-array@^1.1.14: + version "1.1.15" + resolved "https://registry.yarnpkg.com/is-typed-array/-/is-typed-array-1.1.15.tgz#4bfb4a45b61cee83a5a46fba778e4e8d59c0ce0b" + integrity sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ== + dependencies: + which-typed-array "^1.1.16" + +isarray@^2.0.5: + version "2.0.5" + resolved "https://registry.yarnpkg.com/isarray/-/isarray-2.0.5.tgz#8af1e4c1221244cc62459faf38940d4e644a5723" + integrity sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw== + +isarray@~1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/isarray/-/isarray-1.0.0.tgz#bb935d48582cba168c06834957a54a3e07124f11" + integrity sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ== + +js-tokens@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/js-tokens/-/js-tokens-4.0.0.tgz#19203fb59991df98e3a287050d4647cdeaf32499" + integrity sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ== + +js-yaml@^4.1.0: + version "4.1.1" + resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-4.1.1.tgz#854c292467705b699476e1a2decc0c8a3458806b" + integrity sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA== + dependencies: + argparse "^2.0.1" + +json-buffer@3.0.1: + version "3.0.1" + resolved "https://registry.yarnpkg.com/json-buffer/-/json-buffer-3.0.1.tgz#9338802a30d3b6605fbe0613e094008ca8c05a13" + integrity sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ== + +json-parse-even-better-errors@^2.3.0: + version "2.3.1" + resolved "https://registry.yarnpkg.com/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz#7c47805a94319928e05777405dc12e1f7a4ee02d" + integrity sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w== + +json-schema-traverse@^0.4.1: + version "0.4.1" + resolved "https://registry.yarnpkg.com/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz#69f6a87d9513ab8bb8fe63bdb0979c448e684660" + integrity sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg== + +json-schema-traverse@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz#ae7bcb3656ab77a73ba5c49bf654f38e6b6860e2" + integrity sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug== + +jsonpointer@^5.0.1: + version "5.0.1" + resolved "https://registry.yarnpkg.com/jsonpointer/-/jsonpointer-5.0.1.tgz#2110e0af0900fd37467b5907ecd13a7884a1b559" + integrity sha512-p/nXbhSEcu3pZRdkW1OfJhpsVtW1gd4Wa1fnQc9YLiTfAjn0312eMKimbdIQzuZl9aa9xUGaRlP9T/CJE/ditQ== + +keccak@^3.0.0: + version "3.0.4" + resolved "https://registry.yarnpkg.com/keccak/-/keccak-3.0.4.tgz#edc09b89e633c0549da444432ecf062ffadee86d" + integrity sha512-3vKuW0jV8J3XNTzvfyicFR5qvxrSAGl7KIhvgOu5cmWwM7tZRj3fMbj/pfIf4be7aznbc+prBWGjywox/g2Y6Q== + dependencies: + node-addon-api "^2.0.0" + node-gyp-build "^4.2.0" + readable-stream "^3.6.0" + +keyv@^4.5.3: + version "4.5.4" + resolved "https://registry.yarnpkg.com/keyv/-/keyv-4.5.4.tgz#a879a99e29452f942439f2a405e3af8b31d4de93" + integrity sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw== + dependencies: + json-buffer "3.0.1" + +latest-version@^7.0.0: + version "7.0.0" + resolved "https://registry.yarnpkg.com/latest-version/-/latest-version-7.0.0.tgz#843201591ea81a4d404932eeb61240fe04e9e5da" + integrity sha512-KvNT4XqAMzdcL6ka6Tl3i2lYeFDgXNCuIX+xNx6ZMVR1dFq+idXd9FLKNMOIx0t9mJ9/HudyX4oZWXZQ0UJHeg== + dependencies: + package-json "^8.1.0" + +"leven@^3.1.0 < 4": + version "3.1.0" + resolved "https://registry.yarnpkg.com/leven/-/leven-3.1.0.tgz#77891de834064cccba82ae7842bb6b14a13ed7f2" + integrity sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A== + +lines-and-columns@^1.1.6: + version "1.2.4" + resolved "https://registry.yarnpkg.com/lines-and-columns/-/lines-and-columns-1.2.4.tgz#eca284f75d2965079309dc0ad9255abb2ebc1632" + integrity sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg== + +lodash.truncate@^4.4.2: + version "4.4.2" + resolved "https://registry.yarnpkg.com/lodash.truncate/-/lodash.truncate-4.4.2.tgz#5a350da0b1113b837ecfffd5812cbe58d6eae193" + integrity sha512-jttmRe7bRse52OsWIMDLaXxWqRAmtIUccAQ3garviCqJjafXOfNMO0yMfNpdD6zbGaTU0P5Nz7e7gAT6cKmJRw== + +lodash@4.18.1, lodash@^4.17.21: + version "4.18.1" + resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.18.1.tgz#ff2b66c1f6326d59513de2407bf881439812771c" + integrity sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q== + +lowercase-keys@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/lowercase-keys/-/lowercase-keys-3.0.0.tgz#c5e7d442e37ead247ae9db117a9d0a467c89d4f2" + integrity sha512-ozCC6gdQ+glXOQsveKD0YsDy8DSQFjDTz4zyzEHNV5+JP5D62LmfDZ6o1cycFx9ouG940M5dE8C8CTewdj2YWQ== + +math-intrinsics@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/math-intrinsics/-/math-intrinsics-1.1.0.tgz#a0dd74be81e2aa5c2f27e65ce283605ee4e2b7f9" + integrity sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g== + +md5.js@^1.3.4: + version "1.3.5" + resolved "https://registry.yarnpkg.com/md5.js/-/md5.js-1.3.5.tgz#b5d07b8e3216e3e27cd728d72f70d1e6a342005f" + integrity sha512-xitP+WxNPcTTOgnTJcrhM0xvdPepipPSf3I8EIpGKeFLjt3PlJLIDG3u8EX53ZIubkb+5U2+3rELYpEhHhzdkg== + dependencies: + hash-base "^3.0.0" + inherits "^2.0.1" + safe-buffer "^5.1.2" + +mimic-response@^3.1.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/mimic-response/-/mimic-response-3.1.0.tgz#2d1d59af9c1b129815accc2c46a022a5ce1fa3c9" + integrity sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ== + +mimic-response@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/mimic-response/-/mimic-response-4.0.0.tgz#35468b19e7c75d10f5165ea25e75a5ceea7cf70f" + integrity sha512-e5ISH9xMYU0DzrT+jl8q2ze9D6eWBto+I8CNpe+VI+K2J/F/k3PdkdTdz4wvGVH4NTpo+NRYTVIuMQEMMcsLqg== + +minimalistic-assert@^1.0.0, minimalistic-assert@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz#2e194de044626d4a10e7f7fbc00ce73e83e4d5c7" + integrity sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A== + +minimalistic-crypto-utils@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/minimalistic-crypto-utils/-/minimalistic-crypto-utils-1.0.1.tgz#f6c00c1c0b082246e5c4d99dfb8c7c083b2b582a" + integrity sha512-JIYlbt6g8i5jKfJ3xz7rF0LXmv2TkDxBLUkiBeZ7bAx4GnnNMr8xFpGnOxn6GhTEHx3SjRrZEoU+j04prX1ktg== + +minimatch@10.2.3, minimatch@^5.0.1, minimatch@^9.0.5: + version "10.2.3" + resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-10.2.3.tgz#c0ef582f21071b0123a5bbf275252ebda921fbf6" + integrity sha512-Rwi3pnapEqirPSbWbrZaa6N3nmqq4Xer/2XooiOKyV3q12ML06f7MOuc5DVH8ONZIFhwIYQ3yzPH4nt7iWHaTg== + dependencies: + brace-expansion "^5.0.2" + +minimist@^1.2.0, minimist@^1.2.7: + version "1.2.8" + resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.8.tgz#c1a464e7693302e082a075cee0c057741ac4772c" + integrity sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA== + +ms@^2.1.3: + version "2.1.3" + resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.3.tgz#574c8138ce1d2b5861f0b44579dbadd60c6615b2" + integrity sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA== + +node-addon-api@^2.0.0: + version "2.0.2" + resolved "https://registry.yarnpkg.com/node-addon-api/-/node-addon-api-2.0.2.tgz#432cfa82962ce494b132e9d72a15b29f71ff5d32" + integrity sha512-Ntyt4AIXyaLIuMHF6IOoTakB3K+RWxwtsHNRxllEoA6vPwP9o4866g6YWDLUdnucilZhmkxiHwHr11gAENw+QA== + +node-addon-api@^5.0.0: + version "5.1.0" + resolved "https://registry.yarnpkg.com/node-addon-api/-/node-addon-api-5.1.0.tgz#49da1ca055e109a23d537e9de43c09cca21eb762" + integrity sha512-eh0GgfEkpnoWDq+VY8OyvYhFEzBk6jIYbRKdIlyTiAXIVJ8PyBaKb0rp7oDtoddbdoHWhq8wwr+XZ81F1rpNdA== + +node-gyp-build@^4.2.0: + version "4.8.4" + resolved "https://registry.yarnpkg.com/node-gyp-build/-/node-gyp-build-4.8.4.tgz#8a70ee85464ae52327772a90d66c6077a900cfc8" + integrity sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ== + +nofilter@^3.0.2: + version "3.1.0" + resolved "https://registry.yarnpkg.com/nofilter/-/nofilter-3.1.0.tgz#c757ba68801d41ff930ba2ec55bab52ca184aa66" + integrity sha512-l2NNj07e9afPnhAhvgVrCD/oy2Ai1yfLpuo3EpiO1jFTsB4sFz6oIfAfSZyQzVpkZQ9xS8ZS5g1jCBgq4Hwo0g== + +normalize-url@^8.0.0: + version "8.1.1" + resolved "https://registry.yarnpkg.com/normalize-url/-/normalize-url-8.1.1.tgz#751a20c8520e5725404c06015fea21d7567f25ef" + integrity sha512-JYc0DPlpGWB40kH5g07gGTrYuMqV653k3uBKY6uITPWds3M0ov3GaWGp9lbE3Bzngx8+XkfzgvASb9vk9JDFXQ== + +once@^1.3.0: + version "1.4.0" + resolved "https://registry.yarnpkg.com/once/-/once-1.4.0.tgz#583b1aa775961d4b113ac17d9c50baef9dd76bd1" + integrity sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w== + dependencies: + wrappy "1" + +p-cancelable@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/p-cancelable/-/p-cancelable-3.0.0.tgz#63826694b54d61ca1c20ebcb6d3ecf5e14cd8050" + integrity sha512-mlVgR3PGuzlo0MmTdk4cXqXWlwQDLnONTAg6sm62XkMJEiRxN3GL3SffkYvqwonbkJBcrI7Uvv5Zh9yjvn2iUw== + +package-json@^8.1.0: + version "8.1.1" + resolved "https://registry.yarnpkg.com/package-json/-/package-json-8.1.1.tgz#3e9948e43df40d1e8e78a85485f1070bf8f03dc8" + integrity sha512-cbH9IAIJHNj9uXi196JVsRlt7cHKak6u/e6AkL/bkRelZ7rlL3X1YKxsZwa36xipOEKAsdtmaG6aAJoM1fx2zA== + dependencies: + got "^12.1.0" + registry-auth-token "^5.0.1" + registry-url "^6.0.0" + semver "^7.3.7" + +parent-module@^1.0.0: + version "1.0.1" + resolved "https://registry.yarnpkg.com/parent-module/-/parent-module-1.0.1.tgz#691d2709e78c79fae3a156622452d00762caaaa2" + integrity sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g== + dependencies: + callsites "^3.0.0" + +parse-json@^5.2.0: + version "5.2.0" + resolved "https://registry.yarnpkg.com/parse-json/-/parse-json-5.2.0.tgz#c76fc66dee54231c962b22bcc8a72cf2f99753cd" + integrity sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg== + dependencies: + "@babel/code-frame" "^7.0.0" + error-ex "^1.3.1" + json-parse-even-better-errors "^2.3.0" + lines-and-columns "^1.1.6" + +path-type@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/path-type/-/path-type-4.0.0.tgz#84ed01c0a7ba380afe09d90a8c180dcd9d03043b" + integrity sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw== + +pbkdf2@^3.0.17: + version "3.1.5" + resolved "https://registry.yarnpkg.com/pbkdf2/-/pbkdf2-3.1.5.tgz#444a59d7a259a95536c56e80c89de31cc01ed366" + integrity sha512-Q3CG/cYvCO1ye4QKkuH7EXxs3VC/rI1/trd+qX2+PolbaKG0H+bgcZzrTt96mMyRtejk+JMCiLUn3y29W8qmFQ== + dependencies: + create-hash "^1.2.0" + create-hmac "^1.1.7" + ripemd160 "^2.0.3" + safe-buffer "^5.2.1" + sha.js "^2.4.12" + to-buffer "^1.2.1" + +picocolors@^1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/picocolors/-/picocolors-1.1.1.tgz#3d321af3eab939b083c8f929a1d12cda81c26b6b" + integrity sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA== + +pluralize@^8.0.0: + version "8.0.0" + resolved "https://registry.yarnpkg.com/pluralize/-/pluralize-8.0.0.tgz#1a6fa16a38d12a1901e0320fa017051c539ce3b1" + integrity sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA== + +possible-typed-array-names@^1.0.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz#93e3582bc0e5426586d9d07b79ee40fc841de4ae" + integrity sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg== + +prettier@^2.8.3: + version "2.8.8" + resolved "https://registry.yarnpkg.com/prettier/-/prettier-2.8.8.tgz#e8c5d7e98a4305ffe3de2e1fc4aca1a71c28b1da" + integrity sha512-tdN8qQGvNjw4CHbY+XXk0JgCXn9QiF21a55rBe5LJAU+kDyC4WQn4+awm2Xfk2lQMk5fKup9XgzTZtGkjBdP9Q== + +process-nextick-args@~2.0.0: + version "2.0.1" + resolved "https://registry.yarnpkg.com/process-nextick-args/-/process-nextick-args-2.0.1.tgz#7820d9b16120cc55ca9ae7792680ae7dba6d7fe2" + integrity sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag== + +proper-lockfile@^4.1.1: + version "4.1.2" + resolved "https://registry.yarnpkg.com/proper-lockfile/-/proper-lockfile-4.1.2.tgz#c8b9de2af6b2f1601067f98e01ac66baa223141f" + integrity sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA== + dependencies: + graceful-fs "^4.2.4" + retry "^0.12.0" + signal-exit "^3.0.2" + +proto-list@~1.2.1: + version "1.2.4" + resolved "https://registry.yarnpkg.com/proto-list/-/proto-list-1.2.4.tgz#212d5bfe1318306a420f6402b8e26ff39647a849" + integrity sha512-vtK/94akxsTMhe0/cbfpR+syPuszcuwhqVjJq26CuNDgFGj682oRBXOP5MJpv2r7JtE8MsiepGIqvvOTBwn2vA== + +punycode@^2.1.0: + version "2.3.1" + resolved "https://registry.yarnpkg.com/punycode/-/punycode-2.3.1.tgz#027422e2faec0b25e1549c3e1bd8309b9133b6e5" + integrity sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg== + +quick-lru@^5.1.1: + version "5.1.1" + resolved "https://registry.yarnpkg.com/quick-lru/-/quick-lru-5.1.1.tgz#366493e6b3e42a3a6885e2e99d18f80fb7a8c932" + integrity sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA== + +randombytes@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/randombytes/-/randombytes-2.1.0.tgz#df6f84372f0270dc65cdf6291349ab7a473d4f2a" + integrity sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ== + dependencies: + safe-buffer "^5.1.0" + +rc@1.2.8: + version "1.2.8" + resolved "https://registry.yarnpkg.com/rc/-/rc-1.2.8.tgz#cd924bf5200a075b83c188cd6b9e211b7fc0d3ed" + integrity sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw== + dependencies: + deep-extend "^0.6.0" + ini "~1.3.0" + minimist "^1.2.0" + strip-json-comments "~2.0.1" + +readable-stream@^2.3.8: + version "2.3.8" + resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-2.3.8.tgz#91125e8042bba1b9887f49345f6277027ce8be9b" + integrity sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA== + dependencies: + core-util-is "~1.0.0" + inherits "~2.0.3" + isarray "~1.0.0" + process-nextick-args "~2.0.0" + safe-buffer "~5.1.1" + string_decoder "~1.1.1" + util-deprecate "~1.0.1" + +readable-stream@^3.6.0: + version "3.6.2" + resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-3.6.2.tgz#56a9b36ea965c00c5a93ef31eb111a0f11056967" + integrity sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA== + dependencies: + inherits "^2.0.3" + string_decoder "^1.1.1" + util-deprecate "^1.0.1" + +registry-auth-token@^5.0.1: + version "5.1.1" + resolved "https://registry.yarnpkg.com/registry-auth-token/-/registry-auth-token-5.1.1.tgz#f1ff69c8e492e7edee07110b4752dd0a8aa82853" + integrity sha512-P7B4+jq8DeD2nMsAcdfaqHbssgHtZ7Z5+++a5ask90fvmJ8p5je4mOa+wzu+DB4vQ5tdJV/xywY+UnVFeQLV5Q== + dependencies: + "@pnpm/npm-conf" "^3.0.2" + +registry-url@^6.0.0: + version "6.0.1" + resolved "https://registry.yarnpkg.com/registry-url/-/registry-url-6.0.1.tgz#056d9343680f2f64400032b1e199faa692286c58" + integrity sha512-+crtS5QjFRqFCoQmvGduwYWEBng99ZvmFvF+cUJkGYF1L1BfU8C6Zp9T7f5vPAwyLkUExpvK+ANVZmGU49qi4Q== + dependencies: + rc "1.2.8" + +require-from-string@^2.0.2: + version "2.0.2" + resolved "https://registry.yarnpkg.com/require-from-string/-/require-from-string-2.0.2.tgz#89a7fdd938261267318eafe14f9c32e598c36909" + integrity sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw== + +resolve-alpn@^1.2.0: + version "1.2.1" + resolved "https://registry.yarnpkg.com/resolve-alpn/-/resolve-alpn-1.2.1.tgz#b7adbdac3546aaaec20b45e7d8265927072726f9" + integrity sha512-0a1F4l73/ZFZOakJnQ3FvkJ2+gSTQWz/r2KE5OdDY0TxPm5h4GkqkWWfM47T7HsbnOtcJVEF4epCVy6u7Q3K+g== + +resolve-from@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/resolve-from/-/resolve-from-4.0.0.tgz#4abcd852ad32dd7baabfe9b40e00a36db5f392e6" + integrity sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g== + +responselike@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/responselike/-/responselike-3.0.0.tgz#20decb6c298aff0dbee1c355ca95461d42823626" + integrity sha512-40yHxbNcl2+rzXvZuVkrYohathsSJlMTXKryG5y8uciHv1+xDLHQpgjG64JUO9nrEq2jGLH6IZ8BcZyw3wrweg== + dependencies: + lowercase-keys "^3.0.0" + +retry@^0.12.0: + version "0.12.0" + resolved "https://registry.yarnpkg.com/retry/-/retry-0.12.0.tgz#1b42a6266a21f07421d1b0b54b7dc167b01c013b" + integrity sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow== + +ripemd160@^2.0.0, ripemd160@^2.0.1, ripemd160@^2.0.3: + version "2.0.3" + resolved "https://registry.yarnpkg.com/ripemd160/-/ripemd160-2.0.3.tgz#9be54e4ba5e3559c8eee06a25cd7648bbccdf5a8" + integrity sha512-5Di9UC0+8h1L6ZD2d7awM7E/T4uA1fJRlx6zk/NvdCCVEoAnFqvHmCuNeIKoCeIixBX/q8uM+6ycDvF8woqosA== + dependencies: + hash-base "^3.1.2" + inherits "^2.0.4" + +rlp@^2.2.4: + version "2.2.7" + resolved "https://registry.yarnpkg.com/rlp/-/rlp-2.2.7.tgz#33f31c4afac81124ac4b283e2bd4d9720b30beaf" + integrity sha512-d5gdPmgQ0Z+AklL2NVXr/IoSjNZFfTVvQWzL/AM2AOcSzYP2xjlb0AC8YyCLc41MSNf6P6QVtjgPdmVtzb+4lQ== + dependencies: + bn.js "^5.2.0" + +safe-buffer@^5.0.1, safe-buffer@^5.1.0, safe-buffer@^5.1.1, safe-buffer@^5.1.2, safe-buffer@^5.2.1, safe-buffer@~5.2.0: + version "5.2.1" + resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.2.1.tgz#1eaf9fa9bdb1fdd4ec75f58f9cdb4e6b7827eec6" + integrity sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ== + +safe-buffer@~5.1.0, safe-buffer@~5.1.1: + version "5.1.2" + resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.1.2.tgz#991ec69d296e0313747d59bdfd2b745c35f8828d" + integrity sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g== + +scrypt-js@^3.0.0: + version "3.0.1" + resolved "https://registry.yarnpkg.com/scrypt-js/-/scrypt-js-3.0.1.tgz#d314a57c2aef69d1ad98a138a21fe9eafa9ee312" + integrity sha512-cdwTTnqPu0Hyvf5in5asVdZocVDTNRmR7XEcJuIzMjJeSHybHl7vpB66AzwTaIg6CLSbtjcxc8fqcySfnTkccA== + +secp256k1@^4.0.1: + version "4.0.4" + resolved "https://registry.yarnpkg.com/secp256k1/-/secp256k1-4.0.4.tgz#58f0bfe1830fe777d9ca1ffc7574962a8189f8ab" + integrity sha512-6JfvwvjUOn8F/jUoBY2Q1v5WY5XS+rj8qSe0v8Y4ezH4InLgTEeOOPQsRll9OV429Pvo6BCHGavIyJfr3TAhsw== + dependencies: + elliptic "^6.5.7" + node-addon-api "^5.0.0" + node-gyp-build "^4.2.0" + +semver@^7.3.7, semver@^7.5.2: + version "7.7.4" + resolved "https://registry.yarnpkg.com/semver/-/semver-7.7.4.tgz#28464e36060e991fa7a11d0279d2d3f3b57a7e8a" + integrity sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA== + +set-function-length@^1.2.2: + version "1.2.2" + resolved "https://registry.yarnpkg.com/set-function-length/-/set-function-length-1.2.2.tgz#aac72314198eaed975cf77b2c3b6b880695e5449" + integrity sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg== + dependencies: + define-data-property "^1.1.4" + es-errors "^1.3.0" + function-bind "^1.1.2" + get-intrinsic "^1.2.4" + gopd "^1.0.1" + has-property-descriptors "^1.0.2" + +setimmediate@^1.0.5: + version "1.0.5" + resolved "https://registry.yarnpkg.com/setimmediate/-/setimmediate-1.0.5.tgz#290cbb232e306942d7d7ea9b83732ab7856f8285" + integrity sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA== + +sha.js@^2.4.0, sha.js@^2.4.12, sha.js@^2.4.8: + version "2.4.12" + resolved "https://registry.yarnpkg.com/sha.js/-/sha.js-2.4.12.tgz#eb8b568bf383dfd1867a32c3f2b74eb52bdbf23f" + integrity sha512-8LzC5+bvI45BjpfXU8V5fdU2mfeKiQe1D1gIMn7XUlF3OTUrpdJpPPH4EMAnF0DsHHdSZqCdSss5qCmJKuiO3w== + dependencies: + inherits "^2.0.4" + safe-buffer "^5.2.1" + to-buffer "^1.2.0" + +signal-exit@^3.0.2: + version "3.0.7" + resolved "https://registry.yarnpkg.com/signal-exit/-/signal-exit-3.0.7.tgz#a9a1767f8af84155114eaabd73f99273c8f59ad9" + integrity sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ== + +slice-ansi@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/slice-ansi/-/slice-ansi-4.0.0.tgz#500e8dd0fd55b05815086255b3195adf2a45fe6b" + integrity sha512-qMCMfhY040cVHT43K9BFygqYbUPFZKHOg7K73mtTWJRb8pyP3fzf4Ixd5SzdEJQ6MRUg/WBnOLxghZtKKurENQ== + dependencies: + ansi-styles "^4.0.0" + astral-regex "^2.0.0" + is-fullwidth-code-point "^3.0.0" + +solady@^0.1.26: + version "0.1.26" + resolved "https://registry.yarnpkg.com/solady/-/solady-0.1.26.tgz#80b4d621cfdbd82b772c5c701877d2f0ca12736f" + integrity sha512-dhGr/ptJFdea/1KE6xgqgwEdbMhUiSfRc6A+jLeltPe16zyt9qtED3PElIBVRnzEmUO5aZTjKVAr6SlqXBBcIw== + +solhint@^6.0.3: + version "6.0.3" + resolved "https://registry.yarnpkg.com/solhint/-/solhint-6.0.3.tgz#467a65bc479a1d249fef881d0f44ecb9864cc34c" + integrity sha512-LYiy1bN8X9eUsti13mbS4fY6ILVxhP6VoOgqbHxCsHl5VPnxOWf7U1V9ZvgizxdInKBMW82D1FNJO+daAcWHbA== + dependencies: + "@solidity-parser/parser" "^0.20.2" + ajv "^6.12.6" + ajv-errors "^1.0.1" + ast-parents "^0.0.1" + better-ajv-errors "^2.0.2" + chalk "^4.1.2" + commander "^10.0.0" + cosmiconfig "^8.0.0" + fast-diff "^1.2.0" + glob "^8.0.3" + ignore "^5.2.4" + js-yaml "^4.1.0" + latest-version "^7.0.0" + lodash "^4.17.21" + pluralize "^8.0.0" + semver "^7.5.2" + table "^6.8.1" + text-table "^0.2.0" + optionalDependencies: + prettier "^2.8.3" + +solidity-ast@0.4.61, solidity-ast@^0.4.60: + version "0.4.61" + resolved "https://registry.yarnpkg.com/solidity-ast/-/solidity-ast-0.4.61.tgz#b51720ece553a2c7d84551ee5a7a2306080d23d0" + integrity sha512-OYBJYcYyG7gLV0VuXl9CUrvgJXjV/v0XnR4+1YomVe3q+QyENQXJJxAEASUz4vN6lMAl+C8RSRSr5MBAz09f6w== + +string-width@^4.2.3: + version "4.2.3" + resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" + integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== + dependencies: + emoji-regex "^8.0.0" + is-fullwidth-code-point "^3.0.0" + strip-ansi "^6.0.1" + +string_decoder@^1.1.1: + version "1.3.0" + resolved "https://registry.yarnpkg.com/string_decoder/-/string_decoder-1.3.0.tgz#42f114594a46cf1a8e30b0a84f56c78c3edac21e" + integrity sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA== + dependencies: + safe-buffer "~5.2.0" + +string_decoder@~1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/string_decoder/-/string_decoder-1.1.1.tgz#9cf1611ba62685d7030ae9e4ba34149c3af03fc8" + integrity sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg== + dependencies: + safe-buffer "~5.1.0" + +strip-ansi@^6.0.1: + version "6.0.1" + resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" + integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== + dependencies: + ansi-regex "^5.0.1" + +strip-json-comments@~2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/strip-json-comments/-/strip-json-comments-2.0.1.tgz#3c531942e908c2697c0ec344858c286c7ca0a60a" + integrity sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ== + +supports-color@^7.1.0: + version "7.2.0" + resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-7.2.0.tgz#1b7dcdcb32b8138801b3e478ba6a51caa89648da" + integrity sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw== + dependencies: + has-flag "^4.0.0" + +table@^6.8.1: + version "6.9.0" + resolved "https://registry.yarnpkg.com/table/-/table-6.9.0.tgz#50040afa6264141c7566b3b81d4d82c47a8668f5" + integrity sha512-9kY+CygyYM6j02t5YFHbNz2FN5QmYGv9zAjVp4lCDjlCw7amdckXlEt/bjMhUIfj4ThGRE4gCUH5+yGnNuPo5A== + dependencies: + ajv "^8.0.1" + lodash.truncate "^4.4.2" + slice-ansi "^4.0.0" + string-width "^4.2.3" + strip-ansi "^6.0.1" + +text-table@^0.2.0: + version "0.2.0" + resolved "https://registry.yarnpkg.com/text-table/-/text-table-0.2.0.tgz#7f5ee823ae805207c00af2df4a84ec3fcfa570b4" + integrity sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw== + +to-buffer@^1.2.0, to-buffer@^1.2.1, to-buffer@^1.2.2: + version "1.2.2" + resolved "https://registry.yarnpkg.com/to-buffer/-/to-buffer-1.2.2.tgz#ffe59ef7522ada0a2d1cb5dfe03bb8abc3cdc133" + integrity sha512-db0E3UJjcFhpDhAF4tLo03oli3pwl3dbnzXOUIlRKrp+ldk/VUxzpWYZENsw2SZiuBjHAk7DfB0VU7NKdpb6sw== + dependencies: + isarray "^2.0.5" + safe-buffer "^5.2.1" + typed-array-buffer "^1.0.3" + +typed-array-buffer@^1.0.3: + version "1.0.3" + resolved "https://registry.yarnpkg.com/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz#a72395450a4869ec033fd549371b47af3a2ee536" + integrity sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw== + dependencies: + call-bound "^1.0.3" + es-errors "^1.3.0" + is-typed-array "^1.1.14" + +undici-types@~7.18.0: + version "7.18.2" + resolved "https://registry.yarnpkg.com/undici-types/-/undici-types-7.18.2.tgz#29357a89e7b7ca4aef3bf0fd3fd0cd73884229e9" + integrity sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w== + +uri-js@^4.2.2: + version "4.4.1" + resolved "https://registry.yarnpkg.com/uri-js/-/uri-js-4.4.1.tgz#9b1a52595225859e55f669d928f88c6c57f2a77e" + integrity sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg== + dependencies: + punycode "^2.1.0" + +util-deprecate@^1.0.1, util-deprecate@~1.0.1: + version "1.0.2" + resolved "https://registry.yarnpkg.com/util-deprecate/-/util-deprecate-1.0.2.tgz#450d4dc9fa70de732762fbd2d4a28981419a0ccf" + integrity sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw== + +which-typed-array@^1.1.16: + version "1.1.20" + resolved "https://registry.yarnpkg.com/which-typed-array/-/which-typed-array-1.1.20.tgz#3fdb7adfafe0ea69157b1509f3a1cd892bd1d122" + integrity sha512-LYfpUkmqwl0h9A2HL09Mms427Q1RZWuOHsukfVcKRq9q95iQxdw0ix1JQrqbcDR9PH1QDwf5Qo8OZb5lksZ8Xg== + dependencies: + available-typed-arrays "^1.0.7" + call-bind "^1.0.8" + call-bound "^1.0.4" + for-each "^0.3.5" + get-proto "^1.0.1" + gopd "^1.2.0" + has-tostringtag "^1.0.2" + +wrappy@1: + version "1.0.2" + resolved "https://registry.yarnpkg.com/wrappy/-/wrappy-1.0.2.tgz#b5243d8f3ec1aa35f1364605bc0d1036e30ab69f" + integrity sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ== From d60455fcdbbc2c862320436a9211346cd888f794 Mon Sep 17 00:00:00 2001 From: undefined Date: Mon, 19 Oct 2026 03:42:38 +0000 Subject: [PATCH 16/21] =?UTF-8?q?=F0=9F=94=84=20created=20local=20'.github?= =?UTF-8?q?/workflows/supply-chain-security.yml'=20from=20remote=20'.githu?= =?UTF-8?q?b/workflows/supply-chain-security.yml'?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/supply-chain-security.yml | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 .github/workflows/supply-chain-security.yml diff --git a/.github/workflows/supply-chain-security.yml b/.github/workflows/supply-chain-security.yml new file mode 100644 index 0000000..3cf16b2 --- /dev/null +++ b/.github/workflows/supply-chain-security.yml @@ -0,0 +1,14 @@ +name: Supply Chain Security +on: + pull_request: + branches: [master] + push: + branches: [master] +jobs: + scan: + if: github.event_name == 'pull_request' + uses: circlefin/circle-public-github-workflows/.github/workflows/pr-scan.yaml@v1 + + release-sbom: + if: github.event_name == 'push' + uses: circlefin/circle-public-github-workflows/.github/workflows/attach-release-assets.yaml@v1 From 6c8612d9a966d6ff19666e9eae10ebd8c91d8cf3 Mon Sep 17 00:00:00 2001 From: undefined Date: Mon, 19 Oct 2026 03:42:38 +0000 Subject: [PATCH 17/21] =?UTF-8?q?=F0=9F=94=84=20synced=20local=20'README.m?= =?UTF-8?q?d'=20with=20remote=20'README.md'?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 583 +++++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 582 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index d8649e3..d069d7e 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,583 @@ # fee-orchestrator-contracts -Circle Fee Orchestrator contracts + +Repository for Circle Fee Orchestrator smart contracts. + +## Prerequisites + +- [Foundry](https://book.getfoundry.sh/getting-started/installation) (v1.2.3+) +- [Node.js](https://nodejs.org/) (v20+) +- [Yarn](https://classic.yarnpkg.com/) (v1.22+) + +## Setup + +```bash +# Install Foundry dependencies (forge-std, pinned in foundry.lock). +# Keep the default lib/forge-std location: the remapping is part of every +# contract's compiler metadata, so installing elsewhere changes the bytecode. +forge install --no-git foundry-rs/forge-std@v1.15.0 + +# Install Node dependencies (solhint, OpenZeppelin, etc.) +yarn install + +# Build contracts +yarn build +``` + +## Development + +### Build + +```bash +yarn build +``` + +### Test + +```bash +yarn test +``` + +`yarn test` uses `forge test -j 1` because deploy script tests set `vm.setEnv`; Foundry's default multi-threaded run shares process environment across tests. + +### Lint + +```bash +# Solhint (Solidity linting) +yarn lint + +# Forge fmt (formatting check) +yarn format:check + +# Auto-fix formatting +yarn format:write +``` + +### Coverage + +```bash +yarn coverage +``` + +Coverage enforces a minimum of 90% across lines, statements, branches, and functions. +Exclusions can be configured in `script/coverage/excludedFiles.txt` and `script/coverage/excludedFunctions.txt`. + +### Pre-commit hooks + +This repo uses [pre-commit](https://pre-commit.com/) to verify that every Solidity +source file carries the Apache-2.0 copyright header +(`script/pre-commit/check-copyright-header.sh`). If a staged `.sol` file is missing +the header, the hook prepends it and fails the commit so you can re-stage the change. + +`pre-commit` is a Python tool and is **required** — install it before running +`yarn install`, which activates the git hooks (via the `prepare` script) and +will fail if `pre-commit` is missing: + +```bash +brew install pre-commit # macOS +pipx install pre-commit # or: pip install pre-commit +``` + +`yarn install` runs `pre-commit install` for you. To activate manually or run +against all files: + +```bash +pre-commit install + +# Optional: run against all files +pre-commit run --all-files +``` + +## Project Structure + +``` +├── src/ +│ ├── access/ # Reusable abstract contracts (WithConfigurator, WithAllowedCallers, WithTrustedQuoteSigners, WithFeeCollectionAddress) +│ ├── constants/ # Shared constant definitions +│ │ └── QuoteTypes.sol # Canonical quote-type identifiers (FORWARD, PRE_FINALITY) +│ ├── contracts/ # Concrete contract implementations +│ │ ├── Create3Factory.sol # Deterministic deployment factory (CREATE3) +│ │ ├── FeeManager.sol # Fee collection against server-signed quotes +│ │ └── TokenMessengerWithFees.sol # CCTP wrapper that collects fees via FeeManager +│ ├── interfaces/ # Solidity interfaces +│ ├── libraries/ # Utility libraries (AddressUtils, BurnMessageV2Lib, HookDataLib, MessageV2Lib, QuoteLib, SignedQuoteLib) +│ └── storage/ # EIP-7201 namespaced storage libraries +├── test/ # Foundry test files (.t.sol) +├── lib/ # Foundry dependencies (forge-std) +├── script/ +│ ├── deploy/ # Forge deployment scripts (.s.sol) +│ │ ├── Addresses.sol # Predicts deterministic CREATE3 addresses from factory + salt +│ │ ├── PrintAddresses.s.sol # Log predicted addresses (no gas, no transactions) +│ │ ├── DeployCreate3Factory.s.sol +│ │ ├── DeployFeeManager.s.sol +│ │ ├── DeployTokenMessengerWithFees.s.sol +│ │ ├── UpgradeFeeManager.s.sol # New impl via CREATE3 + optional UUPS upgrade +│ │ ├── UpgradeTokenMessengerWithFees.s.sol # Same for TokenMessengerWithFees +│ │ └── Salts.sol # Derives impl/proxy salts from per-contract env vars +│ ├── test/ # Forge helper/test scripts (.s.sol) +│ │ ├── QuoteScriptUtils.sol # Shared quote signing helpers + QUOTE_TYPES parsing +│ │ ├── GenerateSignedTestQuote.s.sol # Print a signed test quote (console output) +│ │ ├── DepositForBurnWithGeneratedQuote.s.sol # Burn via TokenMessengerWithFees with a generated quote +│ │ ├── DepositForBurnWithSignedQuote.s.sol # Burn via TokenMessengerWithFees using a pre-signed quote supplied via env +│ │ ├── cast-deposit-for-burn.sh # cast send alternative for chains with native precompiles (generates the quote) +│ │ └── cast-deposit-for-burn-with-quote.sh # cast send alternative for chains with native precompiles (takes a pre-signed quote) +│ └── coverage/ # Coverage enforcement tooling +├── configs/ # Environment config templates +├── foundry.toml # Foundry configuration +├── package.json # Node dependencies & scripts +├── remappings.txt # Solidity import remappings +├── .solhint-src.json # Solhint config for src/ +├── .solhint-test.json # Solhint config for test/ +└── .solhint-script.json # Solhint config for script/ +``` + +## Signed quote wire format + +Integrations pass server-signed fee quotes as a **versioned ABI blob**: + +`[uint8 version][abi.encode(IFeeManager.Quote)]` + +The current `version` byte is `1` (`SignedQuoteLib.QUOTE_WIRE_FORMAT_VERSION`). The `Quote` struct is ABI-encoded as produced by Solidity; ECDSA signatures are **65 bytes** (`r ‖ s ‖ v`), matching OpenZeppelin `ECDSA.recover`. + +**Decode behavior** (shared by `FeeManager.decodeQuote`, in-process `SignedQuoteLib` in `FeeManager` and `TokenMessengerWithFees`, and the `signedQuote` parameters on those flows): + +- Obvious garbage (empty input, tail shorter than the minimum for a valid encoding, or decoded `signature` length not 65) reverts with custom errors such as `SignedQuoteDecodeFailed` or `UnsupportedQuoteVersion`. +- If the tail is long enough to pass cheap checks but still not valid ABI for `Quote`, the compiler may revert with a Solidity **Panic(0x21)** while decoding. Off-chain tooling should treat **any** revert during decode as failure, not only `SignedQuoteDecodeFailed`. +- Successful decode does **not** prove a valid EIP-712 signature; `FeeManager.collectFeesWithQuote` verifies the signer separately. + +## Cross-Chain Address Determinism + +All contracts are deployed via `Create3Factory` for deterministic addresses across chains. The CREATE3 address depends only on `hash(factory_address, salt)` — it is independent of the creation bytecode. Constructor arguments, compiler settings, or minor code changes do not affect the deployed address. + +Each contract has its own proxy and implementation salt env vars: **`FEE_MANAGER_PROXY_SALT`** / **`FEE_MANAGER_IMPLEMENTATION_SALT`** for FeeManager and **`TOKEN_MESSENGER_WITH_FEES_PROXY_SALT`** / **`TOKEN_MESSENGER_WITH_FEES_IMPLEMENTATION_SALT`** for TokenMessengerWithFees. Keep proxy salts stable so predicted proxy addresses stay the same across implementation upgrades. Bump the implementation salt when deploying a new implementation so CREATE3 does not collide with the previous impl. Use the same proxy salts on every chain where you want identical proxy addresses. + +To print the predicted addresses for the current env (no transaction, no `--broadcast`): `forge script script/deploy/PrintAddresses.s.sol --rpc-url $BLOCKCHAIN_RPC_URL` (same factory and salt env vars as deploy; the RPC must be the chain where `CREATE3_FACTORY_ADDRESS` is deployed — `getAddress` is a staticcall to the factory). + +## Deployment + +`FeeManager` and `TokenMessengerWithFees` are deployed behind UUPS (ERC1967) proxies via `Create3Factory`. Use the **proxy addresses** for integrations; implementations can be upgraded by the owner without changing the proxy address or losing state. + +### Downstream dependency + +Downstream deployment tooling reuses the Foundry deployment scripts under `script/deploy/` in this repo. Avoid renaming or relocating those scripts, changing `forge script` entry points or signatures, or altering required environment variables and deployment flow without coordinating with the owners of that tooling; otherwise downstream pipelines may break. + +### Deploy script invocation + +All deploy scripts follow the same pattern. For brevity, subsequent steps show only the script path. + +**Local (Anvil)** — start `anvil` in a separate terminal, then: + +```bash +forge script --rpc-url http://127.0.0.1:8545 --broadcast +``` + +**Testnet / Mainnet:** + +```bash +forge script \ + --rpc-url $BLOCKCHAIN_RPC_URL \ + --broadcast \ + --verify --etherscan-api-key $ETHERSCAN_API_KEY +``` + +For mainnet, consider adding `--slow` and using a hardware wallet (`--ledger`). + +### 1. Create and source the env file + +```bash +cp configs/.env.dev.template configs/.env.dev +``` + +Edit `configs/.env.dev` and fill in at minimum: + +| Variable | Required for | Description | +|---|---|---| +| `DEPLOYER_PRIVATE_KEY` | all deploys | Private key used to broadcast transactions | +| `BLOCKCHAIN_RPC_URL` | all deploys | RPC endpoint | +| `CREATE3_FACTORY_ADDRESS` | FeeManager, TMWF | Address of the pre-deployed `Create3Factory` (set after step 2) | +| `FEE_MANAGER_PROXY_SALT` | FeeManager | Proxy CREATE3 salt; keep stable across impl upgrades; same value across chains for identical proxy addresses | +| `FEE_MANAGER_IMPLEMENTATION_SALT` | FeeManager | Implementation CREATE3 salt; bump when deploying a new impl (avoids collision) | +| `TOKEN_MESSENGER_WITH_FEES_PROXY_SALT` | TMWF (+ FeeManager deploy) | Proxy CREATE3 salt for TokenMessengerWithFees; FeeManager deploy reads it to predict the TMWF proxy for the allowed caller list | +| `TOKEN_MESSENGER_WITH_FEES_IMPLEMENTATION_SALT` | TMWF | Implementation CREATE3 salt for TokenMessengerWithFees | +| `FEE_TAKER_ADDRESS` | FeeManager only | Deployed `FeeTaker` proxy on the target chain (immutable constructor arg) | +| `USDC_ADDRESS` | FeeManager only | USDC token contract; must match `FeeTaker`’s configured USDC (immutable constructor arg for `FeeManager.isDenylisted`) | +| `TOKEN_MESSENGER_V2_ADDRESS` | TMWF only | Address of the existing `TokenMessengerV2` on the target chain | + +Optional: + +| Variable | Description | +|---|---| +| `FEE_MANAGER_CALLER_ADMIN` | If set and non-zero, granted `CALLER_ADMIN_ROLE`; omit to leave unassigned (owner grants via `grantRole` later) | +| `FEE_MANAGER_SIGNER_ADMIN` | If set and non-zero, granted `SIGNER_ADMIN_ROLE`; omit to leave unassigned (owner grants via `grantRole` later) | +| `FEE_MANAGER_FEE_COLLECTION_ADMIN` | If set and non-zero, granted `FEE_COLLECTION_ADMIN_ROLE`; omit to leave unassigned (owner grants via `grantRole` later) | +| `FEE_MANAGER_OWNER` | Final owner address for FeeManager; defaults to the deployer if unset | +| `FEE_MANAGER_TRUSTED_SIGNER` | If non-zero, registered as a trusted quote signer during deploy | +| `FEE_MANAGER_PAUSER` | If non-zero, granted `PAUSER_ROLE` during deploy | +| `FEE_MANAGER_PROXY_ADDRESS` | Deployed FeeManager proxy (quote test scripts, verification) | +| `TOKEN_MESSENGER_WITH_FEES_OWNER` | Final owner address for TokenMessengerWithFees; defaults to the deployer if unset | +| `ETHERSCAN_API_KEY` | Required for `--verify` on testnet/mainnet | + +Source the env file in every terminal session before deploying: + +```bash +set -o allexport && source configs/.env.dev && set +o allexport +``` + +### 2. Deploy Create3Factory (once per chain) + +``` +script/deploy/DeployCreate3Factory.s.sol +``` + +Record the printed address and set `CREATE3_FACTORY_ADDRESS` in `.env.dev`, then re-source. + +### 3. Deploy FeeManager + +``` +script/deploy/DeployFeeManager.s.sol +``` + +The script: + +1. Deploys the `FeeManager` implementation via `Create3Factory.deploy()` with immutables `feeTaker` (`FEE_TAKER_ADDRESS`) and `usdcBlacklist` (`USDC_ADDRESS`). +2. Deploys an `ERC1967Proxy` via `Create3Factory.deployAndMultiCall()`, which atomically: + - Initializes the proxy. + - Pre-registers the predicted `TokenMessengerWithFees` proxy as an allowed caller. + - Optionally registers `FEE_MANAGER_TRUSTED_SIGNER` as a trusted quote signer. + - Optionally grants `FEE_MANAGER_PAUSER` the `PAUSER_ROLE` if non-zero. + - If `FEE_MANAGER_CALLER_ADMIN` / `FEE_MANAGER_SIGNER_ADMIN` are set and non-zero, grants those roles; otherwise skips (minimal privilege — owner assigns later). + - Initiates ownership transfer to `FEE_MANAGER_OWNER` (defaults to deployer). + +The owner must call `acceptOwnership()` on the proxy separately after deployment to complete the two-step transfer. + +Use the printed **FeeManager proxy** address for integrations. + +**Roles.** The **owner** authorizes upgrades and may **`grantRole`** `CALLER_ADMIN_ROLE` / `SIGNER_ADMIN_ROLE` / `FEE_COLLECTION_ADMIN_ROLE` when those env vars were omitted at deploy. Holders of **`CALLER_ADMIN_ROLE`** and **`SIGNER_ADMIN_ROLE`** manage allowed callers and trusted quote signers respectively. Holders of **`FEE_COLLECTION_ADMIN_ROLE`** can override the address `FeeManager` forwards to `IFeeTaker` on `takeFee*` calls via `setFeeCollectionAddress`; when unset (zero), `FeeManager` falls back to `IFeeTaker.protocolFeeAddress()` (default behavior). These roles are separate from ownership to limit blast radius if any admin key is compromised. + +### 4. Deploy TokenMessengerWithFees + +Requires `TOKEN_MESSENGER_V2_ADDRESS`. The `FeeManager` proxy address is resolved via `FEE_MANAGER_PROXY_ADDRESS` if set, otherwise predicted deterministically from `FEE_MANAGER_PROXY_SALT`. + +``` +script/deploy/DeployTokenMessengerWithFees.s.sol +``` + +The script: + +1. Deploys the `TokenMessengerWithFees` implementation via `Create3Factory.deploy()` with `tokenMessenger` and `feeManager` set as immutables. +2. Deploys an `ERC1967Proxy` via `Create3Factory.deployAndMultiCall()`, which atomically initializes and initiates ownership transfer to `TOKEN_MESSENGER_WITH_FEES_OWNER` (defaults to deployer). + +The owner must call `acceptOwnership()` on the proxy separately after deployment to complete the two-step transfer. + +The proxy is already pre-registered as an allowed caller on `FeeManager` from step 3. + +Use the printed **TokenMessengerWithFees proxy** address for integrations. + +## Test utility scripts + +These scripts share quote construction and EIP-712 signing via `script/test/QuoteScriptUtils.sol`. + +### Generate signed quote (console only) + +Use `script/test/GenerateSignedTestQuote.s.sol` to print a signed quote for manual/local testing (no on-chain transaction). + +The script produces a quote with: +- `FORWARD` and `PRE_FINALITY` items by default (see `QUOTE_TYPES` below to narrow the selection) +- 1-hour validity (`base + 1 hours`; see **Quote time** below) +- `FORWARD` args hash binds `[address(TokenMessengerWithFees), destinationDomain, burnToken, destinationCaller, hookData]` and uses the same default `cctp-forward` hook bytes as `HookDataLib.defaultForwardHookData()` / `TokenMessengerWithFees` when you call `depositForBurnWithFees` with no caller hook data (not empty-string hook — that would not match `TokenMessengerWithFees` and only `PRE_FINALITY` would collect) +- `PRE_FINALITY` args hash binds `[address(TokenMessengerWithFees), destinationDomain, burnToken, amount]` for `BURN_TOKEN_ADDRESS` and burn amount `500_000` (0.5 USDC, 6 decimals) — same pairing as `DepositForBurnWithGeneratedQuote` +- zero destination caller (`bytes32(0)`) +- native fee token (`address(0)`) +- `FORWARD` amount `0.00001` ether (`10_000_000_000_000` wei) +- `PRE_FINALITY` amount `0.000005` ether (`5_000_000_000_000` wei) + +Required env vars: +- `QUOTE_SIGNER` — private key used to sign the quote +- `FEE_MANAGER_PROXY_ADDRESS` — EIP-712 verifying contract (`FeeManager` proxy) +- `TOKEN_MESSENGER_WITH_FEES_PROXY_ADDRESS` — wrapper address bound into both the `FORWARD` and `PRE_FINALITY` args hashes; must match the `TokenMessengerWithFees` instance that will redeem the quote +- `BURN_TOKEN_ADDRESS` — burn token address bound into both the `FORWARD` and `PRE_FINALITY` args hashes (must match the token you burn if you reuse the quote with `depositForBurnWithFees`) + +Optional env vars: +- `DESTINATION_DOMAIN` — destination domain bound into both the `FORWARD` and `PRE_FINALITY` args hashes (default: `0`) +- `QUOTE_BASE_TIMESTAMP` — Unix seconds used as “now” for expiry (default: `block.timestamp`) +- `QUOTE_TYPES` — comma-separated subset of `{FORWARD, PRE_FINALITY}` selecting which line items the quote contains (default: `FORWARD,PRE_FINALITY`). Order is irrelevant; the bash scripts tolerate surrounding whitespace but the Solidity scripts do not (use `FORWARD,PRE_FINALITY` without spaces). Unknown entries, duplicate entries (`FORWARD,FORWARD`), empty tokens (`FORWARD,`), and an empty selection all revert on both the bash and Solidity sides. + +**Quote time.** Solidity cannot read your machine clock. To get a realistic expiry without setting `QUOTE_BASE_TIMESTAMP` manually: +- Run with `--rpc-url $BLOCKCHAIN_RPC_URL` so `block.timestamp` is the chain’s latest block time. +- Or pass shell time: `QUOTE_BASE_TIMESTAMP=$(date +%s) forge script ...` (same pattern works for the deposit script below). + +Example: + +```bash +set -o allexport && source configs/.env.dev && set +o allexport +export QUOTE_SIGNER= +export FEE_MANAGER_PROXY_ADDRESS= +export TOKEN_MESSENGER_WITH_FEES_PROXY_ADDRESS= +export BURN_TOKEN_ADDRESS= +# optional: DESTINATION_DOMAIN=0 # default +# optional: QUOTE_BASE_TIMESTAMP=$(date +%s) + +forge script script/test/GenerateSignedTestQuote.s.sol:GenerateSignedTestQuote \ + --rpc-url $BLOCKCHAIN_RPC_URL +``` + +The script prints `digest`, `signature`, and the final version-prefixed `signedQuote` bytes to the console. + +### Deposit for burn with generated quote + +Use `script/test/DepositForBurnWithGeneratedQuote.s.sol` to build the same style of signed quote and call `TokenMessengerWithFees.depositForBurnWithFees`. It approves `0.5` USDC (6 decimals) for the burn, attaches native value matching the items present in the quote (`0.00001` ether for FORWARD + `0.000005` ether for PRE\_FINALITY; `0.000015` ether total when both are included), and broadcasts the transaction. + +Required env vars: +- `CALLER_PRIVATE_KEY` — EOA that submits the burn (must hold USDC and native fee value) +- `QUOTE_SIGNER` — private key that signs the quote (must be a trusted signer on `FeeManager`) +- `TOKEN_MESSENGER_WITH_FEES_PROXY_ADDRESS` — `TokenMessengerWithFees` proxy +- `BURN_TOKEN_ADDRESS` — burn token (e.g. USDC on the source chain) + +Optional env vars: +- `DESTINATION_DOMAIN` — CCTP destination domain (default: `0`, Ethereum) +- `MINT_RECIPIENT` — `bytes32` mint recipient on the destination (default: caller address, left-padded) +- `QUOTE_BASE_TIMESTAMP` — same as above (default: `block.timestamp`) +- `QUOTE_TYPES` — same parsing as `GenerateSignedTestQuote` (default: `FORWARD,PRE_FINALITY`; duplicates and empty tokens revert). Drives both the items in the generated quote and the attached `msg.value`; with only `FORWARD` the wrapper picks the slow finality threshold and auto-injects the default forward hook, with only `PRE_FINALITY` the wrapper picks the fast finality threshold and skips the hook. + +Example: + +```bash +set -o allexport && source configs/.env.dev && set +o allexport +export CALLER_PRIVATE_KEY= +export QUOTE_SIGNER= +export TOKEN_MESSENGER_WITH_FEES_PROXY_ADDRESS= +export BURN_TOKEN_ADDRESS=0x1c7D4B196Cb0C7B01d743Fbc6116a902379C7238 +# optional: DESTINATION_DOMAIN=0 +# optional: MINT_RECIPIENT=<0x-padded-32-byte-hex> +# optional: QUOTE_BASE_TIMESTAMP=$(date +%s) +# optional: QUOTE_TYPES=FORWARD # only FORWARD; msg.value = 0.00001 ether +# optional: QUOTE_TYPES=PRE_FINALITY # only PRE_FINALITY; msg.value = 0.000005 ether + +forge script script/test/DepositForBurnWithGeneratedQuote.s.sol:DepositForBurnWithGeneratedQuote \ + --rpc-url $BLOCKCHAIN_RPC_URL \ + --broadcast +``` + +Omit `--broadcast` to simulate only (no transaction sent). + +### Deposit for burn with an externally-signed quote + +Use `script/test/DepositForBurnWithSignedQuote.s.sol` when you already have a version-prefixed signed quote (for example produced by a backend signer service) and just want to relay it on-chain. The script does **not** sign anything — it derives `msg.value` from the wrapper's `getFee(bytes)` view and rejects ERC-20-fee quotes (an ERC-20 path would also need an `approve` step that this script deliberately leaves out of scope). + +Required env vars: +- `CALLER_PRIVATE_KEY` — EOA that submits the burn (must hold the burn token and the native fee) +- `TOKEN_MESSENGER_WITH_FEES_PROXY_ADDRESS` — `TokenMessengerWithFees` proxy that the quote was signed against +- `BURN_TOKEN_ADDRESS` — burn token bound into the quote args +- `SIGNED_QUOTE` — `0x`-prefixed version-prefixed signed quote bytes + +Optional env vars: +- `BURN_AMOUNT` — token units to burn (default: `500000`). Must match the `amount` bound into any `PRE_FINALITY` quote args. +- `DESTINATION_DOMAIN` — CCTP destination domain (default: `0`) +- `MINT_RECIPIENT` — `bytes32` mint recipient on the destination (default: caller, left-padded) +- `DESTINATION_CALLER` — `bytes32` authorized destination caller (default: `bytes32(0)`) +- `REFUND_ADDRESS` — refund attribution address (default: caller). Forwarded to `FeeManager` verbatim — `address(0)` bypasses the USDC blocklist screen on the refund target. +- `HOOK_DATA` — `0x`-prefixed hook bytes. Empty (default) routes through `depositForBurnWithFees`, so the wrapper auto-injects `HookDataLib.defaultForwardHookData()` when the quote contains a `FORWARD` item. Non-empty routes through `depositForBurnWithHookAndFees`; the bytes must match whatever the `FORWARD` args hash was bound to off-chain. + +Example: + +```bash +set -o allexport && source configs/.env.dev && set +o allexport +export CALLER_PRIVATE_KEY= +export TOKEN_MESSENGER_WITH_FEES_PROXY_ADDRESS= +export BURN_TOKEN_ADDRESS=0x1c7D4B196Cb0C7B01d743Fbc6116a902379C7238 +export SIGNED_QUOTE=0x01... # version-prefixed, signed by a trusted FeeManager signer +# optional: BURN_AMOUNT=500000 +# optional: DESTINATION_DOMAIN=1 +# optional: HOOK_DATA=0x... # non-default hook the FORWARD args were bound to + +forge script script/test/DepositForBurnWithSignedQuote.s.sol:DepositForBurnWithSignedQuote \ + --rpc-url $BLOCKCHAIN_RPC_URL \ + --broadcast +``` + +#### Chains with native precompiles (Arc Testnet) + +Arc Testnet (chain id `5042002`) implements USDC balance transfers and blocklist checks via native precompiles (`0x1800…00`, `0x1800…01`). These precompiles execute at the node/protocol level and have no standard EVM bytecode. Forge's local EVM (`revm`) fetches the empty code at those addresses and fails with `StackUnderflow` when it tries to simulate the transaction before broadcasting. + +**`forge script --broadcast` does not work on this chain** for any script that touches USDC (approve, transfer, `depositForBurnWithFees`). Two `cast send` wrappers are provided as drop-in replacements — both submit the approve + deposit transactions directly to the RPC node, bypassing local EVM simulation. + +##### Generated quote (`cast-deposit-for-burn.sh`) + +Generates a signed quote via the view-only `GenerateSignedTestQuote` Forge script (which does not hit precompiles) and then relays it: + +```bash +set -o allexport && source configs/.env.dev && set +o allexport +export CALLER_PRIVATE_KEY=0x +export QUOTE_SIGNER= +export BURN_TOKEN_ADDRESS=$USDC_ADDRESS + +bash script/test/cast-deposit-for-burn.sh +``` + +The script hardcodes `BURN_AMOUNT=500000` (0.5 USDC) and computes `NATIVE_FEE` from `QUOTE_TYPES` using the same per-item amounts as `GenerateSignedTestQuote.s.sol` (FORWARD `0.00001` ether + PRE\_FINALITY `0.000005` ether). To change individual fee amounts, update both the Solidity constants and the `FORWARD_FEE_WEI` / `PRE_FINALITY_FEE_WEI` constants in the bash script. + +Optional env overrides: +- `QUOTE_TYPES` — comma-separated subset of `{FORWARD, PRE_FINALITY}` selecting the items in the generated quote and the fees rolled into `NATIVE_FEE` (default: `FORWARD,PRE_FINALITY`). The value is forwarded to the Forge subprocess so the bash and Solidity sides cannot drift. Both sides reject duplicates (`FORWARD,FORWARD`) and empty tokens (`FORWARD,`) so the per-item `NATIVE_FEE` accumulator stays in sync with the Solidity item builder, which collapses duplicates to a single `QuoteItem`. +- `DESTINATION_DOMAIN` — default `0` +- `MINT_RECIPIENT` — default: caller address, left-padded to `bytes32` +- `REFUND_ADDRESS` — default: `address(0)` (no refund target; bypasses the USDC blocklist screen) + +##### Pre-signed quote (`cast-deposit-for-burn-with-quote.sh`) + +Companion script for when the quote is signed elsewhere (e.g. backend signer) and you just want to relay it. Resolves the native fee value automatically by calling the wrapper's `getFee(bytes)` view through `cast call`: + +```bash +set -o allexport && source configs/.env.dev && set +o allexport +export CALLER_PRIVATE_KEY=0x +export BURN_TOKEN_ADDRESS=$USDC_ADDRESS +export SIGNED_QUOTE=0x01... # version-prefixed, signed by a trusted FeeManager signer + +bash script/test/cast-deposit-for-burn-with-quote.sh +``` + +The script rejects quotes whose `feeToken` is not `address(0)` (an ERC-20 path would need an additional `approve` step that the script intentionally does not perform). + +Optional env overrides: +- `BURN_AMOUNT` — default `500000`; must match the amount bound into any `PRE_FINALITY` quote args +- `DESTINATION_DOMAIN` — default `0` +- `MINT_RECIPIENT` — default: caller address, left-padded to `bytes32` +- `DESTINATION_CALLER` — default `bytes32(0)` +- `REFUND_ADDRESS` — default: `address(0)` +- `HOOK_DATA` — `0x`-prefixed hook bytes; non-empty switches the relay to `depositForBurnWithHookAndFees` and must match whatever the `FORWARD` args hash was bound to off-chain +- `NATIVE_FEE` — explicit `msg.value` (wei); defaults to the `getFee(bytes)` result. `getFee` is always queried regardless (so the script can enforce the native-fee invariant) and a mismatch against the quoted value is logged — the override only changes the value attached to the deposit call (handy for deliberately triggering `IncorrectNativePayment` in smoke tests) + +## Upgrading + +Both `FeeManager` and `TokenMessengerWithFees` use the UUPS (ERC-1822) proxy pattern. The proxy address and all on-chain state are preserved across upgrades — only the implementation logic changes. Only the current `owner()` can call `upgradeToAndCall`. Ownership uses `Ownable2StepUpgradeable`, so transferring the owner requires the new owner to explicitly accept. + +Use **`script/deploy/UpgradeFeeManager.s.sol`** and **`script/deploy/UpgradeTokenMessengerWithFees.s.sol`** so you do not hand-assemble init code or `cast send` the factory. The broadcaster must own both the `Create3Factory` and the proxy (same pattern as initial deploy). + +### Upgrade procedure + +1. **Run `forge clean && forge build` and `yarn validate:upgrades`** (storage / UUPS checks). + +2. **Bump the implementation salt only** — each new implementation needs a fresh impl salt so CREATE3 does not reuse the slot of the previous implementation. **Do not change proxy salts** when upgrading. + +```bash +# FeeManager upgrade: +export FEE_MANAGER_IMPLEMENTATION_SALT="fee-manager-impl-v2" +# FEE_MANAGER_PROXY_SALT unchanged from initial deploy + +# TokenMessengerWithFees upgrade: +export TOKEN_MESSENGER_WITH_FEES_IMPLEMENTATION_SALT="tmwf-impl-v2" +# TOKEN_MESSENGER_WITH_FEES_PROXY_SALT unchanged from initial deploy +``` + +3. **Deploy the new implementation and upgrade the proxy** (default `run()` — deploy then `upgradeToAndCall` in one broadcast): + +**FeeManager** + +```bash +set -o allexport && source configs/.env.dev && set +o allexport +forge script script/deploy/UpgradeFeeManager.s.sol:UpgradeFeeManager \ + --rpc-url $BLOCKCHAIN_RPC_URL \ + --broadcast +``` + +**TokenMessengerWithFees** + +```bash +set -o allexport && source configs/.env.dev && set +o allexport +forge script script/deploy/UpgradeTokenMessengerWithFees.s.sol:UpgradeTokenMessengerWithFees \ + --rpc-url $BLOCKCHAIN_RPC_URL \ + --broadcast +``` + +**Split flows** (optional): + +| Goal | Script | Entry point | +|------|--------|-------------| +| Deploy implementation only | `UpgradeFeeManager` / `UpgradeTokenMessengerWithFees` | `--sig deployImplementationOnly` | +| Point proxy at an impl you already deployed | same | `--sig upgradeProxyOnly` — set `FEE_MANAGER_IMPLEMENTATION_ADDRESS` or `TOKEN_MESSENGER_WITH_FEES_IMPLEMENTATION_ADDRESS` | + +Replace the default `run()` body with a `reinitializer` call only if a future version requires it (today `upgradeToAndCall` uses empty `data`). + +4. **Verify** the new implementation on the block explorer. Use the **implementation** address from the deploy script logs, or read it from the proxy (EIP-1967 implementation slot). Run only the assignment for the contract you are verifying: + +```bash +FEE_MANAGER_IMPLEMENTATION=$(cast parse-bytes32-address $(cast storage $FEE_MANAGER_PROXY_ADDRESS \ + 0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc \ + --rpc-url $BLOCKCHAIN_RPC_URL)) + +TOKEN_MESSENGER_WITH_FEES_IMPLEMENTATION=$(cast parse-bytes32-address $(cast storage $TOKEN_MESSENGER_WITH_FEES_PROXY_ADDRESS \ + 0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc \ + --rpc-url $BLOCKCHAIN_RPC_URL)) +``` + +**FeeManager** — immutables: `feeTaker`, `usdcBlacklist` (same as deploy). + +```bash +forge verify-contract $FEE_MANAGER_IMPLEMENTATION FeeManager \ + --constructor-args $(cast abi-encode "constructor(address,address)" $FEE_TAKER_ADDRESS $USDC_ADDRESS) \ + --chain-id $(cast chain-id --rpc-url $BLOCKCHAIN_RPC_URL) \ + --etherscan-api-key $ETHERSCAN_API_KEY +``` + +**TokenMessengerWithFees** — immutables: `tokenMessenger` (`TOKEN_MESSENGER_V2_ADDRESS`), `feeManager` (the **FeeManager proxy**, same as `FEE_MANAGER_PROXY_ADDRESS`). + +```bash +forge verify-contract $TOKEN_MESSENGER_WITH_FEES_IMPLEMENTATION TokenMessengerWithFees \ + --constructor-args $(cast abi-encode "constructor(address,address)" $TOKEN_MESSENGER_V2_ADDRESS $FEE_MANAGER_PROXY_ADDRESS) \ + --chain-id $(cast chain-id --rpc-url $BLOCKCHAIN_RPC_URL) \ + --etherscan-api-key $ETHERSCAN_API_KEY +``` + +### Manual alternative (cast / `forge inspect`) + +To **preview** addresses without broadcasting, use `script/deploy/PrintAddresses.s.sol` with `--rpc-url` (or `--sig feeManagerProxyAddress`, etc.). + +To **deploy without the Forge scripts**, build init code as **creation bytecode + ABI-encoded constructor args** and call `Create3Factory.deploy` (see NatSpec on `UpgradeFeeManager` / `DeployFeeManager` for the exact encoding). + +### Post-upgrade verification + +EIP-1967 implementation slot (same for both proxies): + +```bash +# FeeManager +cast storage $FEE_MANAGER_PROXY_ADDRESS \ + 0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc \ + --rpc-url $BLOCKCHAIN_RPC_URL + +# TokenMessengerWithFees (after upgrading that proxy) +cast storage $TOKEN_MESSENGER_WITH_FEES_PROXY_ADDRESS \ + 0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc \ + --rpc-url $BLOCKCHAIN_RPC_URL + +cast call $FEE_MANAGER_PROXY_ADDRESS "owner()(address)" --rpc-url $BLOCKCHAIN_RPC_URL +cast call $TOKEN_MESSENGER_WITH_FEES_PROXY_ADDRESS "owner()(address)" --rpc-url $BLOCKCHAIN_RPC_URL +``` + +### Storage layout compatibility + +All contracts use **EIP-7201 namespaced storage**. When adding state in new versions: +- **Append** new fields to the relevant namespaced struct. +- **Never** reorder, remove, or change the type of existing fields. + +Validate upgrade safety before deploying: + +```bash +forge clean && forge build +yarn validate:upgrades +``` + +This checks UUPS conventions and storage layout compatibility. To compare against a specific previous version, use the `@custom:oz-upgrades-from` NatSpec annotation and the `--referenceBuildInfoDirs` flag (see [OpenZeppelin docs](https://docs.openzeppelin.com/upgrades-plugins/1.x/api-core)). + +### Rollback + +The owner can upgrade back to the previous implementation address via `upgradeToAndCall(previousImplAddress, "")`. Previous implementations remain deployed on-chain indefinitely. + +### Emergency pause + +`FeeManager` exposes `pause()` and `unpause()`, callable only by addresses with `PAUSER_ROLE`. While paused, `collectFeesWithQuote` reverts (`whenNotPaused`). Grant `PAUSER_ROLE` during deploy via `FEE_MANAGER_PAUSER`, or later via `grantRole` from the owner. + +## Static Analysis + +To run Slither locally: + +```bash +slither . +``` From d990c24266bb6ebfc2762030ab9296b0ef9a0a7b Mon Sep 17 00:00:00 2001 From: undefined Date: Mon, 19 Oct 2026 03:42:38 +0000 Subject: [PATCH 18/21] =?UTF-8?q?=F0=9F=94=84=20created=20local=20'LICENSE?= =?UTF-8?q?'=20from=20remote=20'LICENSE'?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- LICENSE | 201 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 201 insertions(+) create mode 100644 LICENSE diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..6d4a8d0 --- /dev/null +++ b/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or Derivative + Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and do + not modify the License. You may add Your own attribution notices + within Derivative Works that You distribute, alongside or as an + addendum to the NOTICE text from the Work, provided that such + additional attribution notices cannot be construed as modifying + the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2026 Circle Internet Group, Inc. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. From aff0366b174f843530ecd1a82a1ee847042fc810 Mon Sep 17 00:00:00 2001 From: undefined Date: Mon, 19 Oct 2026 03:42:38 +0000 Subject: [PATCH 19/21] =?UTF-8?q?=F0=9F=94=84=20created=20local=20'COPYRIG?= =?UTF-8?q?HT'=20from=20remote=20'COPYRIGHT'?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- COPYRIGHT | 15 +++++++++++++++ 1 file changed, 15 insertions(+) create mode 100644 COPYRIGHT diff --git a/COPYRIGHT b/COPYRIGHT new file mode 100644 index 0000000..db31142 --- /dev/null +++ b/COPYRIGHT @@ -0,0 +1,15 @@ +Copyright 2026 Circle Internet Group, Inc. All rights reserved. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. + +SPDX-License-Identifier: Apache-2.0 From a3e7e764d34abea391283298d1fdc58faa92a24a Mon Sep 17 00:00:00 2001 From: undefined Date: Mon, 19 Oct 2026 03:42:38 +0000 Subject: [PATCH 20/21] =?UTF-8?q?=F0=9F=94=84=20created=20local=20'SECURIT?= =?UTF-8?q?Y.md'=20from=20remote=20'SECURITY.md'?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- SECURITY.md | 8 ++++++++ 1 file changed, 8 insertions(+) create mode 100644 SECURITY.md diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..73a5672 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,8 @@ +# Security Policy + +## Reporting a Vulnerability + +Please do not file public issues on GitHub for security vulnerabilities. +All security vulnerabilities should be reported to Circle privately, through +Circle's [Vulnerability Disclosure Program](https://hackerone.com/circle-bbp). +Please read through the program policy before submitting a report. From e44757eedd5c2f93bbce666e9c364e549a702d00 Mon Sep 17 00:00:00 2001 From: undefined Date: Mon, 19 Oct 2026 03:42:38 +0000 Subject: [PATCH 21/21] =?UTF-8?q?=F0=9F=94=84=20created=20local=20'CONTRIB?= =?UTF-8?q?UTING.md'=20from=20remote=20'CONTRIBUTING.md'?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CONTRIBUTING.md | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) create mode 100644 CONTRIBUTING.md diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..3f519e6 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,23 @@ +# Contributing to fee-orchestrator-contracts + +Thank you for your interest in contributing to this project! + +## How to Contribute + +All contributions to this project are made subject to the terms of the +[Apache License 2.0](LICENSE). By submitting a contribution, you agree that +your contribution is licensed under those terms. + +## Reporting Issues + +Please open a GitHub issue for bug reports, feature requests, or questions. + +## Security Vulnerabilities + +Please do **not** file public GitHub issues for security vulnerabilities. +Report them privately through Circle's +[Vulnerability Disclosure Program](https://hackerone.com/circle-bbp). + +## Code of Conduct + +Please be respectful and constructive in all interactions.