Repository for Circle Fee Orchestrator smart contracts.
# 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 buildyarn buildyarn testyarn test uses forge test -j 1 because deploy script tests set vm.setEnv; Foundry's default multi-threaded run shares process environment across tests.
# Solhint (Solidity linting)
yarn lint
# Forge fmt (formatting check)
yarn format:check
# Auto-fix formatting
yarn format:writeyarn coverageCoverage 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.
This repo uses pre-commit 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:
brew install pre-commit # macOS
pipx install pre-commit # or: pip install pre-commityarn install runs pre-commit install for you. To activate manually or run
against all files:
pre-commit install
# Optional: run against all files
pre-commit run --all-files├── 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/
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
signaturelength not 65) reverts with custom errors such asSignedQuoteDecodeFailedorUnsupportedQuoteVersion. - 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 onlySignedQuoteDecodeFailed. - Successful decode does not prove a valid EIP-712 signature;
FeeManager.collectFeesWithQuoteverifies the signer separately.
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).
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 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.
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:
forge script <SCRIPT_PATH> --rpc-url http://127.0.0.1:8545 --broadcastTestnet / Mainnet:
forge script <SCRIPT_PATH> \
--rpc-url $BLOCKCHAIN_RPC_URL \
--broadcast \
--verify --etherscan-api-key $ETHERSCAN_API_KEYFor mainnet, consider adding --slow and using a hardware wallet (--ledger).
cp configs/.env.dev.template configs/.env.devEdit 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:
set -o allexport && source configs/.env.dev && set +o allexportscript/deploy/DeployCreate3Factory.s.sol
Record the printed address and set CREATE3_FACTORY_ADDRESS in .env.dev, then re-source.
script/deploy/DeployFeeManager.s.sol
The script:
- Deploys the
FeeManagerimplementation viaCreate3Factory.deploy()with immutablesfeeTaker(FEE_TAKER_ADDRESS) andusdcBlacklist(USDC_ADDRESS). - Deploys an
ERC1967ProxyviaCreate3Factory.deployAndMultiCall(), which atomically:- Initializes the proxy.
- Pre-registers the predicted
TokenMessengerWithFeesproxy as an allowed caller. - Optionally registers
FEE_MANAGER_TRUSTED_SIGNERas a trusted quote signer. - Optionally grants
FEE_MANAGER_PAUSERthePAUSER_ROLEif non-zero. - If
FEE_MANAGER_CALLER_ADMIN/FEE_MANAGER_SIGNER_ADMINare 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.
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:
- Deploys the
TokenMessengerWithFeesimplementation viaCreate3Factory.deploy()withtokenMessengerandfeeManagerset as immutables. - Deploys an
ERC1967ProxyviaCreate3Factory.deployAndMultiCall(), which atomically initializes and initiates ownership transfer toTOKEN_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.
These scripts share quote construction and EIP-712 signing via script/test/QuoteScriptUtils.sol.
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:
FORWARDandPRE_FINALITYitems by default (seeQUOTE_TYPESbelow to narrow the selection)- 1-hour validity (
base + 1 hours; see Quote time below) FORWARDargs hash binds[address(TokenMessengerWithFees), destinationDomain, burnToken, destinationCaller, hookData]and uses the same defaultcctp-forwardhook bytes asHookDataLib.defaultForwardHookData()/TokenMessengerWithFeeswhen you calldepositForBurnWithFeeswith no caller hook data (not empty-string hook — that would not matchTokenMessengerWithFeesand onlyPRE_FINALITYwould collect)PRE_FINALITYargs hash binds[address(TokenMessengerWithFees), destinationDomain, burnToken, amount]forBURN_TOKEN_ADDRESSand burn amount500_000(0.5 USDC, 6 decimals) — same pairing asDepositForBurnWithGeneratedQuote- zero destination caller (
bytes32(0)) - native fee token (
address(0)) FORWARDamount0.00001ether (10_000_000_000_000wei)PRE_FINALITYamount0.000005ether (5_000_000_000_000wei)
Required env vars:
QUOTE_SIGNER— private key used to sign the quoteFEE_MANAGER_PROXY_ADDRESS— EIP-712 verifying contract (FeeManagerproxy)TOKEN_MESSENGER_WITH_FEES_PROXY_ADDRESS— wrapper address bound into both theFORWARDandPRE_FINALITYargs hashes; must match theTokenMessengerWithFeesinstance that will redeem the quoteBURN_TOKEN_ADDRESS— burn token address bound into both theFORWARDandPRE_FINALITYargs hashes (must match the token you burn if you reuse the quote withdepositForBurnWithFees)
Optional env vars:
DESTINATION_DOMAIN— destination domain bound into both theFORWARDandPRE_FINALITYargs 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 (useFORWARD,PRE_FINALITYwithout 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_URLsoblock.timestampis 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:
set -o allexport && source configs/.env.dev && set +o allexport
export QUOTE_SIGNER=<private_key_uint>
export FEE_MANAGER_PROXY_ADDRESS=<fee_manager_proxy_address>
export TOKEN_MESSENGER_WITH_FEES_PROXY_ADDRESS=<tmwf_proxy>
export BURN_TOKEN_ADDRESS=<usdc_or_test_token>
# optional: DESTINATION_DOMAIN=0 # default
# optional: QUOTE_BASE_TIMESTAMP=$(date +%s)
forge script script/test/GenerateSignedTestQuote.s.sol:GenerateSignedTestQuote \
--rpc-url $BLOCKCHAIN_RPC_URLThe script prints digest, signature, and the final version-prefixed signedQuote bytes to the console.
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 onFeeManager)TOKEN_MESSENGER_WITH_FEES_PROXY_ADDRESS—TokenMessengerWithFeesproxyBURN_TOKEN_ADDRESS— burn token (e.g. USDC on the source chain)
Optional env vars:
DESTINATION_DOMAIN— CCTP destination domain (default:0, Ethereum)MINT_RECIPIENT—bytes32mint recipient on the destination (default: caller address, left-padded)QUOTE_BASE_TIMESTAMP— same as above (default:block.timestamp)QUOTE_TYPES— same parsing asGenerateSignedTestQuote(default:FORWARD,PRE_FINALITY; duplicates and empty tokens revert). Drives both the items in the generated quote and the attachedmsg.value; with onlyFORWARDthe wrapper picks the slow finality threshold and auto-injects the default forward hook, with onlyPRE_FINALITYthe wrapper picks the fast finality threshold and skips the hook.
Example:
set -o allexport && source configs/.env.dev && set +o allexport
export CALLER_PRIVATE_KEY=<private_key_uint>
export QUOTE_SIGNER=<private_key_uint>
export TOKEN_MESSENGER_WITH_FEES_PROXY_ADDRESS=<tmwf_proxy>
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 \
--broadcastOmit --broadcast to simulate only (no transaction sent).
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—TokenMessengerWithFeesproxy that the quote was signed againstBURN_TOKEN_ADDRESS— burn token bound into the quote argsSIGNED_QUOTE—0x-prefixed version-prefixed signed quote bytes
Optional env vars:
BURN_AMOUNT— token units to burn (default:500000). Must match theamountbound into anyPRE_FINALITYquote args.DESTINATION_DOMAIN— CCTP destination domain (default:0)MINT_RECIPIENT—bytes32mint recipient on the destination (default: caller, left-padded)DESTINATION_CALLER—bytes32authorized destination caller (default:bytes32(0))REFUND_ADDRESS— refund attribution address (default: caller). Forwarded toFeeManagerverbatim —address(0)bypasses the USDC blocklist screen on the refund target.HOOK_DATA—0x-prefixed hook bytes. Empty (default) routes throughdepositForBurnWithFees, so the wrapper auto-injectsHookDataLib.defaultForwardHookData()when the quote contains aFORWARDitem. Non-empty routes throughdepositForBurnWithHookAndFees; the bytes must match whatever theFORWARDargs hash was bound to off-chain.
Example:
set -o allexport && source configs/.env.dev && set +o allexport
export CALLER_PRIVATE_KEY=<private_key_uint>
export TOKEN_MESSENGER_WITH_FEES_PROXY_ADDRESS=<tmwf_proxy>
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 \
--broadcastArc 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.
Generates a signed quote via the view-only GenerateSignedTestQuote Forge script (which does not hit precompiles) and then relays it:
set -o allexport && source configs/.env.dev && set +o allexport
export CALLER_PRIVATE_KEY=0x<hex_private_key>
export QUOTE_SIGNER=<uint256_private_key>
export BURN_TOKEN_ADDRESS=$USDC_ADDRESS
bash script/test/cast-deposit-for-burn.shThe 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 intoNATIVE_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-itemNATIVE_FEEaccumulator stays in sync with the Solidity item builder, which collapses duplicates to a singleQuoteItem.DESTINATION_DOMAIN— default0MINT_RECIPIENT— default: caller address, left-padded tobytes32REFUND_ADDRESS— default:address(0)(no refund target; bypasses the USDC blocklist screen)
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:
set -o allexport && source configs/.env.dev && set +o allexport
export CALLER_PRIVATE_KEY=0x<hex_private_key>
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.shThe 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— default500000; must match the amount bound into anyPRE_FINALITYquote argsDESTINATION_DOMAIN— default0MINT_RECIPIENT— default: caller address, left-padded tobytes32DESTINATION_CALLER— defaultbytes32(0)REFUND_ADDRESS— default:address(0)HOOK_DATA—0x-prefixed hook bytes; non-empty switches the relay todepositForBurnWithHookAndFeesand must match whatever theFORWARDargs hash was bound to off-chainNATIVE_FEE— explicitmsg.value(wei); defaults to thegetFee(bytes)result.getFeeis 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 triggeringIncorrectNativePaymentin smoke tests)
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).
-
Run
forge clean && forge buildandyarn validate:upgrades(storage / UUPS checks). -
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.
# 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- Deploy the new implementation and upgrade the proxy (default
run()— deploy thenupgradeToAndCallin one broadcast):
FeeManager
set -o allexport && source configs/.env.dev && set +o allexport
forge script script/deploy/UpgradeFeeManager.s.sol:UpgradeFeeManager \
--rpc-url $BLOCKCHAIN_RPC_URL \
--broadcastTokenMessengerWithFees
set -o allexport && source configs/.env.dev && set +o allexport
forge script script/deploy/UpgradeTokenMessengerWithFees.s.sol:UpgradeTokenMessengerWithFees \
--rpc-url $BLOCKCHAIN_RPC_URL \
--broadcastSplit 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).
- 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:
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).
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_KEYTokenMessengerWithFees — immutables: tokenMessenger (TOKEN_MESSENGER_V2_ADDRESS), feeManager (the FeeManager proxy, same as FEE_MANAGER_PROXY_ADDRESS).
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_KEYTo 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).
EIP-1967 implementation slot (same for both proxies):
# 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_URLAll 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:
forge clean && forge build
yarn validate:upgradesThis 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).
The owner can upgrade back to the previous implementation address via upgradeToAndCall(previousImplAddress, ""). Previous implementations remain deployed on-chain indefinitely.
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.
To run Slither locally:
slither .