Guardian's review of Stablecoin Bridge for Citrea, published September 2025. The report records 20 findings across 2 review rounds, including 8 low and 12 informational.
- Published
- Review window
- August 29 to September 20, 2025
- Rounds
- Main Review, Remediation Review
- Language
- Solidity
- Chains
- Bitcoin, Ethereum
- Sector
- Infrastructure
- 0 Critical
- 0 High
- 0 Medium
- 8 Low
- 12 Informational
Scope
35 files in scope · 1,098 nSLOC
| File | nSLOC | Lines |
|---|---|---|
src/DestinationOUSDC.sol | 35 | 69 |
src/DestinationOUSDT.sol | 35 | 71 |
src/SourceOFTAdapter.sol | 11 | 15 |
src/USDCRolesHolder.sol | 25 | 33 |
script/ConfigSetup.s.sol | 179 | 217 |
src/for_circle_takeover/DestinationOUSDCForTakeover.sol | 43 | 79 |
src/for_circle_takeover/SourceOFTAdapterForTakeover.sol | 42 | 55 |
script/usdt/deploy/01_USDTDeploy.s.sol | 33 | 40 |
script/usdt/deploy/02_USDTBridgeDeploy.s.sol | 46 | 54 |
script/usdt/deploy/03_USDTSrcBridgeSetLzConfig.s.sol | 37 | 51 |
script/usdt/deploy/04_USDTDestBridgeSetLzConfig.s.sol | 37 | 50 |
script/usdt/deploy/05_USDTSrcBridgeSetPeer.s.sol | 21 | 28 |
script/usdt/deploy/06_USDTDestBridgeSetPeer.s.sol | 21 | 28 |
script/usdt/deploy/07_USDTSetBridgeAsMinter.s.sol | 18 | 25 |
script/usdt/deploy/08_USDTAndBridgeAssignRoles.s.sol | 33 | 40 |
script/usdc/deploy/02_USDCBridgeDeploy.s.sol | 45 | 53 |
script/usdc/deploy/03_USDCSrcBridgeSetLzConfig.s.sol | 37 | 50 |
script/usdc/deploy/04_USDCDestBridgeSetLzConfig.s.sol | 37 | 50 |
script/usdc/deploy/05_USDCSrcBridgeSetPeer.s.sol | 21 | 28 |
script/usdc/deploy/06_USDCDestBridgeSetPeer.s.sol | 21 | 28 |
script/usdc/deploy/07_USDCSetBridgeAsMinter.s.sol | 19 | 25 |
script/usdc/deploy/08_USDCAndBridgeAssignRoles.s.sol | 34 | 41 |
script/usdc/for_circle_takeover/01_USDCSrcBridgePrepareTakeover.s.sol | 25 | 31 |
script/usdc/for_circle_takeover/02_USDCDestBridgePrepareTakeover.s.sol | 25 | 31 |
script/usdc/for_circle_takeover/03_USDCSrcBridgeSetBlockedMsgLib.s.sol | 20 | 26 |
script/usdc/for_circle_takeover/04_USDCDestBridgeSetBlockedMsgLib.s.sol | 20 | 26 |
script/usdc/for_circle_takeover/05_USDCSrcBridgePause.s.sol | 20 | 26 |
script/usdc/for_circle_takeover/06_USDCDestBridgePause.s.sol | 20 | 26 |
script/usdc/for_circle_takeover/07_USDCRemoveBridgeAsMinter.s.sol | 18 | 27 |
script/usdc/for_circle_takeover/08_USDCSrcBridgeSetCircle.s.sol | 20 | 26 |
script/usdc/for_circle_takeover/09_USDCProxyAdminTransfer.s.sol | 18 | 25 |
script/usdc/for_circle_takeover/10_USDCTransferOwner.s.sol | 22 | 29 |
script/usdc/for_circle_takeover/11_USDCRolesHolderSetCircle.s.sol | 20 | 26 |
script/usdc/for_circle_takeover/unpause/USDCDestBridgeUnpause.s.sol | 20 | 26 |
script/usdc/for_circle_takeover/unpause/USDCSrcBridgeUnpause.s.sol | 20 | 26 |
Findings 20
Main Review
19 findings · August 29 to September 3, 2025-
L-01 Low Missing ProxyAdmin Ownership Transfer Configuration Acknowledged
Description
The bridge deployment scripts import
openzeppelin-contracts/contracts/proxy/transparent/TransparentUpgradeableProxy.sol, which resolves via your remappings to OpenZeppelin v5.2.0. In OZ v5,TransparentUpgradeableProxyalways deploys a freshProxyAdmininside its constructor and stores thatProxyAdmin’s address as the proxy admin (immutable). The constructor argument you pass is not the proxy admin itself, but the owner of the newly createdProxyAdmin.In your repo, both the USDC and USDT bridge proxies are created with the v5 TUP:
- script/usdc/deploy/02_USDCBridgeDeploy.s.sol
- script/usdt/deploy/02_USDTBridgeDeploy.s.sol
Each call like:
new TransparentUpgradeableProxy( address(impl), _<src|dest><USDC|USDT>BridgeProxyAdminOwner, // initialOwner of a new ProxyAdmin abi.encodeWithSignature("initialize(address)", _bridgeOwner) );creates a new
ProxyAdmincontract per proxy whoseowner()is the EOA from config (e.g.,.bridge.init.proxyAdminOwner). From that point on, all upgrades of the bridge must go throughProxyAdmin.upgrade/upgradeAndCall, controlled by theProxyAdmin.owner().Your takeover scripts for USDC correctly assume a ProxyAdmin is present and call it:
- script/usdc/for_circle_takeover/01_USDCSrcBridgePrepareTakeover.s.sol
- script/usdc/for_circle_takeover/02_USDCDestBridgePrepareTakeover.s.sol
However, nowhere in the codebase is the ownership of these bridge
ProxyAdmincontracts transferred to the intended final controller (e.g., a multisig or Circle). The role handover script for USDC (script/usdc/deploy/08_USDCAndBridgeAssignRoles.s.sol) sets bridgeOwnableowners/delegates and master-minter ownership, but does not transfer ownership of the bridgeProxyAdmin. The only ownership transfer you have is for the USDC token proxy (Circle’sAdminUpgradeabilityProxy) inscript/usdc/for_circle_takeover/09_USDCProxyAdminTransfer.s.sol.This is a problem because the
ProxyAdmin.owner()for the bridge proxies remains with the original EOA, an attacker who compromises that key (or a malicious insider) can front‑run Circle’sburnLockedUSDCcall. For example:- Right before Circle calls
burnLockedUSDC()on the source adapter, the attacker, in this case theProxyAdmin’s owner upgrades the SourceAdapter(SourceOFTAdapterForTakeover), drains all the USDC it holds, and then upgrades it again to its previous implementation. - Circle’s
burnLockedUSDCis executed. No USDC is burnt, but they think they have burnt the full amount. - At this point, all the USDC tokens were stolen by the
ProxyAdmin’s owner, but Circle thinks they were burnt.
Recommendation
As part of the handover process to Circle, consider transferring the ownerships of the source and destination Adapter’s ProxyAdmin to an address controlled by Circle.
-
L-02 Low Enabling Peers Before Granting Mint Authorization Configuration Resolved
Description
In the current deployment flow for both USDC and USDT, the destination peer is set in
06_*DestBridgeSetPeer.s.solbefore the destination bridge is authorized to mint (USDC) or recognized as the oftContract (USDT), which happens in07_*SetBridgeAsMinter.s.sol. As soon as both peers are configured, users can send messages from the source. If the final peer is set while the destination bridge still lacks authorization, any arriving message will revert during_credit().For USDC (
src/DestinationOUSDC.sol),_creditcallsFiatTokenV2_2.mint, which reverts until the destination bridge proxy is a minter:token_.mint(_to, _amountLD); // reverts until bridge is an authorized minterFor USDT (
src/DestinationOUSDT.sol),_creditcallscrosschainMint, which is guarded byonlyAuthorizedSenderinOFTExtension. UntiloftContractis set to the destination bridge, calls revert:token_.crosschainMint(_to, _amountLD); // reverts until oftContract == dest bridgeThe impact is that users send succeed on source (tokens are locked/charged) but deliveries fail on destination until ops finish step 07 and retry.
Recommendation
Reorder or merge steps so the destination is fully authorized before enabling traffic. The safest pattern is: run
07_*SetBridgeAsMinter.s.sol first, then06_*DestBridgeSetPeer.s.soland set the source peer last (05_*SrcBridgeSetPeer.s.sol) to act as the “master switch”. If you keep scripts separate, add a precondition guard to 06_*DestBridgeSetPeer.s.sol, e.g., for USDC:require(FiatTokenV2_2(destUSDC).isMinter(destUSDCBridgeProxy), "dest bridge not minter");and for USDT:
require(TetherTokenOFTExtension(destUSDT).oftContract() == destUSDTBridgeProxy, "oftContract not set");Alternatively, you can also merge “authorize destination” and “set destination peer” into a single script.
-
L-03 Low Pausing _credit During ULN Confirmation Window Configuration Acknowledged
Description
The LayerZero ULN path uses a confirmations parameter (20 blocks set in the testnet config) so DVNs only pass a message to the destination after sufficient finality to mitigate reorgs. In the current takeover implementation, both
DestinationOUSDCForTakeoverandSourceOFTAdapterForTakeoverapplywhenNotPausedto both_debitand_credit. This creates a following risk:- A user initiates a cross‑chain transfer.
- DVNs wait out the confirmation window on the source chain.
- During that window, the owner pauses the bridge.
- When the packet is finally delivered, the destination’s _credit (called by
lzReceive) hitswhenNotPausedand reverts. LayerZero marks the message failed until an explicit retry after unpause. - Meanwhile, the user’s funds are already locked (or burned in burn‑and‑mint design). In the takeover flow, this amount is later burned as part of supply finalization, so the user’s transfer is effectively stuck until operational recovery. It may be permanently lost to the user if the burn is completed or the
MasterMinterrole is revoked for the bridge, without minting the corresponding credit.
This behaviour contradicts the migration guidance to “pause bridging activity and reconcile in‑flight bridging activity to finalize the total supply of bridged USDC on the destination chain.” Pausing may prevent reconciliation by causing in‑flight packets to fail right before the settlement.
Recommendation
Remove
whenNotPausedfrom_creditin both takeover contracts. Guard only_debitso already verified packets continue to settle while new sends are blocked, what “pause and reconcile in‑flight” requires. -
L-04 Low USDC Recipient Blacklist DoS Acknowledged
Description
USDC token enforces blacklist checks on both the caller and the recipient in mint and transfer functions:
function mint(address _to, uint256 _amount) external whenNotPaused onlyMinters notBlacklisted(msg.sender) notBlacklisted(_to) returns (bool)function transfer(address to, uint256 value) external override whenNotPaused notBlacklisted(msg.sender) notBlacklisted(to) returns (bool)In the bridge, the source _debit path first consumes the user’s value (locks via transferFrom in an Adapter or burns in a Core). The packet reaches the destination _credit, which mints the intended amount of tokens or transfer tokens locked beforehand, to deliver funds. If _to is blacklisted on the destination chain at execution time, mint reverts, causing the LayerZero receive to fail. The message remains failed pending manual retry after unblacklist, while the user’s funds are already locked/burned on the source.
This failure mode occurs on any path where _credit mints USDC (e.g. if the source chain uses a Core minter or after takeover when native USDC is the destination token). Thus, any _credit that mints/transfer USDC is susceptible to recipient‑blacklist reverts. This is particularly risky during the takeover - if supply is finalising (locked source balance is burned) before resolving failed credits, affected users can end up without funds on the destination, even though total supply is conserved. This also contradicts the intended “lossless delivery” property for cross‑chain messages: the application reverts at the destination and forces manual intervention, despite the source having already consumed value.
Recommendation
Make _credit non‑reverting. Wrap mint and transfer logic implemented by _credit functions in try/catch blocks. On failure, queue a pending credit to be claimable after unblacklist. For bridge wrapped by the adapter, override the _credit function to introduce the mentioned logic.
-
L-05 Low Zero‑Address Recipient DoS Resolved
Description
Both
DestinationOUSDCandDestinationOUSDCForTakeovercalltoken.mint(_to, _amountLD)in_creditwithout remapping a zero recipient. In USDC implementations, minting tokens to address(0) reverts. The bridge also performs no preflight check to reject to == 0x0. As a result:- The source
_debitfirst consumes value (locks viatransferFromin Adapter or burns in Core). - The LZ packet arrives. Destination
_credittriesmint(0x0, amount)and reverts. - The LayerZero message is marked failed until manual retry with a corrected recipient, while the source value remains locked/burned.
This is inconsistent with LayerZero’s OFT defaults and symmetrical implementation for USDT, which remap 0x0 to 0xdead inside
_creditto preserve “lossless delivery” and avoid receive‑time reverts.function _credit( address _to, uint256 _amountLD, uint32 /*_srcEid*/ ) internal virtual override returns (uint256 amountReceivedLD) { if (_to == address(0x0)) _to = address(0xdead); // _mint(...) does not support address(0x0) // @dev Default OFT mints on dst. _mint(_to, _amountLD); // @dev In the case of NON-default OFT, the _amountLD MIGHT not be == amountReceivedLD. return _amountLD; }Recommendation
In
_credit, remap zero-address recipient to a burn sink (0xdead) before minting. Consider disallowing address-zero transfers preflight. - The source
-
L-06 Low Reconciled Bridged Supply Unexpected Behavior Resolved
Description
Circle’s standard states: “Circle and the third-party team will jointly coordinate to burn an amount of native USDC locked in the bridge contract on the origin chain that equals the supply of bridged USDC.”
In the current implementation of
SourceOFTAdapterForTakeover, theburnLockedUSDCburns the entire USDC balance held by the adapter, not the reconciled amount that corresponds to the bridged token supply at the takeover.function burnLockedUSDC() external onlyCircle { uint256 balance = innerToken.balanceOf(address(this)); FiatTokenV2_2(address(innerToken)).burn(balance); emit BurnedLockedUSDC(msg.sender, balance); }Because it uses the raw balance as the burn amount, any extra USDC held by the adapter (e.g. due to incorrect direct transfers) will be burned along with the actual locked collateral that backs the bridged supply. This violates the standard’s requirement to burn exactly the amount equal to the outstanding bridged supply, as more native USDC may be burned than the supply represented on the destination.
Recommendation
Implement explicit internal accounting and burn only the reconciled amount. Increase a counter on each
_debitcall by the actual received delta and decrease it on every reverse unlock/credit by the amount transferred out. -
L-07 Low Fee-On-Transfer USDT Validation Acknowledged
Description
On the source chain, the OFT Adapter’s
_debitlocks USDT by pulling it from the user.function _debit( address _from, uint256 _amountLD, uint256 _minAmountLD, uint32 _dstEid ) internal virtual override returns (uint256 amountSentLD, uint256 amountReceivedLD) { (amountSentLD, amountReceivedLD) = _debitView(_amountLD, _minAmountLD, _dstEid); // @dev Lock tokens by moving them into this contract from the caller. innerToken.safeTransferFrom(_from, address(this), amountSentLD); }The default implementation assumes lossless ERC-20 and sets
amountReceivedLDequal toamountSentLDfrom_debitView, without verifying what was actually received.function _debitView( uint256 _amountLD, uint256 _minAmountLD, uint32 /*_dstEid*/ ) internal view virtual returns (uint256 amountSentLD, uint256 amountReceivedLD) { // @dev Remove the dust so nothing is lost on the conversion between chains with different decimals for the token. amountSentLD = _removeDust(_amountLD); // @dev The amount to send is the same as amount received in the default implementation. amountReceivedLD = amountSentLD; // @dev Check for slippage. if (amountReceivedLD < _minAmountLD) { revert SlippageExceeded(amountReceivedLD, _minAmountLD); } }If the underlying USDT token has fee-on-transfer enabled, the adapter on the source chain receives only the requested amount minus the token’s transfer fee, but the message still carries an amount equal to the originally requested value. The destination
_creditthen mints the full requested amount, meaning the tokens minted on the destination exceed the collateral actually locked on the source by the size of the fee. Repeated transfers accumulate a solvency gap on the adapter. Over time, this gap can leave the adapter under-collateralized, causing return path redemptions to fail.Recommendation
In the adapter’s
_debitfunction, calculate the balance delta and use it as the amount to encode into the message. This ensures the destination mints exactly what the adapter actually received. -
L-08 Low Missing Contract Bytecode Verification Warning Resolved
Description
The deployment flow broadcasts implementations and proxies and then persists the resulting addresses to config, but it never verifies the deployed bytecode on explorers nor asserts that the on‑chain code matches the expected build. For example,
script/usdc/deploy/02_USDCBridgeDeploy.s.soldeploysSourceOFTAdapterand aTransparentUpgradeableProxyand then writes the proxy address into the TOML, but performs no explorer/source verification (--verify,forge verify-contract).The USDT script similarly deploys an implementation via raw bytecode and
assembly { create(...) }without any follow‑up attestation.Recommendation
Implement explorer verification in the runbook for each network (implementation + proxy + admin path): For example, run with
--verify(and the per‑chain API key) immediately after/bundled withbroadcast. For the scripts that use multichain deployments likescript/usdc/deploy/02_USDCBridgeDeploy.s.solconsider following Foundry’s multichain deployment guide: https://getfoundry.sh/forge/deploying/#multi-chain-deployments -
I-01 Informational USDC.e Proxy Initialization Can Be Front‑Run Frontrunning Acknowledged
Description
The USDC deployment script
deploy-fiat-token.s.sol, invoked by01_USDCDeploy.shfrom Circle's stablecoin-evm repository at commitc8c31b2, performs the proxy deployment and subsequent multi-version initializations as separate transactions undervm.startBroadcast(deployerPrivateKey), enabling an attacker to frontrun the unpermissionedinitializecalls on the newly deployedFiatTokenProxyafter its transaction executes but before the deployer's init transactions are run, allowing the attacker to hijack ownership by callinginitializefirst with malicious parameters.Specifically, the script deploys or reuses the implementation via
FiatTokenV2_2 fiatTokenV2_2 = getOrDeployImpl(_impl)which safely initializes the implementation using a temporary upgrader to disable its own reinitialization, then deploys the proxy withFiatTokenProxy proxy = new FiatTokenProxy(address(fiatTokenV2_2))in one transaction, followed by separate transactions forproxy.changeAdmin(proxyAdmin)and the casts toFiatTokenV2_2 proxyAsV2_2 = FiatTokenV2_2(address(proxy))to invokeproxyAsV2_2.initialize()which delegates to the implementation's V1initializersetting the owner and other roles and then individual calls toproxyAsV2_2.initializeV2(),proxyAsV2_2.initializeV2_1()andproxyAsV2_2.initializeV2_2().Since Foundry broadcasts each of these operations as distinct transactions and the initializers are not permissioned as per Circle's design to allow one-time setup, an attacker monitoring the public mempool can detect the proxy deployment transaction, predict its address using the deployer's nonce and the
CREATEopcode determinism and submit a competing transaction with higher gas fees to execute first, casting the proxy toFiatTokenV2_2and callinginitializewith the attacker's address as owner to setinitialized = truein the proxy's storage and claim all roles before the deployer's transactions process.If successful, the deployer's init calls should revert with "already initialized" errors and would require a new deployment/re-run of the script.
Recommendation
Be aware of this risk. Consider also refactoring the
deploy-fiat-token.s.solscript to deploy a temporary upgrader contract that atomically deploys the proxy, changes the admin, and calls all initializers in a single transaction, eliminating the multi-tx window. -
I-02 Informational Missing Scripts Configuration Acknowledged
Description
The repository implements the takeover mechanics but does not provide scripts to execute the critical on-chain calls at upgrade time, as required by the project’s own
auditors_guide.md(which states that every on-chain action in the Circle standard must have a corresponding script).-
No script calls
burnLockedUSDC()on the source bridge: The takeover implementationsrc/for_circle_takeover/SourceOFTAdapterForTakeover.solexposesburnLockedUSDC. This is the exact entrypoint Circle requires on the source chain to burn the locked USDC that backs the finalized bridged supply. However, underscript/usdc/for_circle_takeover/there is no script that invokes this function from the configuredcircleaddress. Existing takeover scripts cover preparing new impls, pausing/unpausing, removing minter, settingcircleand transferringProxyAdmin, but they never perform the actual burn. -
No script triggers
USDCRolesHolder.transferUSDCRoles(address)(Circle-side finalization).src/USDCRolesHolder.solcorrectly exposestransferUSDCRoles. You deploy the roles holder and set Circle with:script/usdc/for_circle_takeover/10_USDCTransferOwner.s.solscript/usdc/for_circle_takeover/11_USDCRolesHolderSetCircle.s.sol
But there is no script that calls
transferUSDCRoles(<Circle owner>)to actually hand over the Implementation Owner to Circle.
Recommendation
Add two minimal, purpose-built takeover scripts and wire them into the documented runbook:
12_USDCSrcBridgeBurnLockedUSDC.s.sol(run on source chain, from the configuredcircleaddress):- Fork
srcRPC. - Call
SourceOFTAdapter(srcUSDCBridgeProxy).burnLockedUSDC(). - Recommended pre-checks: ensure the bridge is paused, log
innerToken.balanceOf(proxy).
- Fork
13_USDCRolesHolderTransferUSDCRoles.s.sol(run on destination chain, from the configuredcircleaddress):- Fork
destRPC. - Obtain roles holder from
FiatTokenV2_2(destUSDC).owner(). - Call
USDCRolesHolder(rolesHolder).transferUSDCRoles(<CIRCLE_IMPLEMENTATION_OWNER>). - Ensure your separate
ProxyAdmintransfer (09_USDCProxyAdminTransfer.s.sol) has already been executed.
- Fork
Also update the guide to include these steps in the exact sequence (pause → remove minter → set Circle → burnLockedUSDC → transfer ProxyAdmin → transferUSDCRoles → unpause if applicable).
-
-
I-03 Informational Consider Declaring USDC As Immutable Gas Optimization Resolved
Description
In the
USDCRolesHoldercontract, theusdcstate variable is assigned in the constructor to reference theFiatTokenV2_2interface at the providedusdcProxyaddress and is never modified thereafter, as there are no setter functions or internal assignments that alter its value post-deployment. This variable is used solely for read operations, such as in thetransferUSDCRolesfunction where it callsusdc.transferOwnership(_owner), making it a prime candidate for immutability. Declaring it as a regular public variable incurs unnecessary storage reads during runtime, which consume gas, whereas an immutable variable is embedded directly into the bytecode at deployment time, eliminating storage access costs.Recommendation
Modify the
usdcdeclaration toFiatTokenV2_2 public immutable usdc;and assign it directly in the constructor without changes to the existing logic, ensuring the value is set at deployment and remains constant thereafter. -
I-04 Informational Unlicensed Contracts Best Practices Resolved
Description
Several in‑scope contracts are marked with
// SPDX-License-Identifier: UNLICENSEDwhich grants no rights to use, modify, or distribute the code. This ambiguity can deter integrators and developers from using or contributing to the project.
Additionally, mixed or missing license headers across contract (e.g. some files unlicensed, others permissively licensed) create inconsistency, making it unclear which terms apply to which components and how the code may be combined or redistributed.
Recommendation
Choose and apply a clear open‑source license. Update every Solidity file’s SPDX header to the chosen identifier (keeping third‑party code under its original license). Common options:
- MIT – permissive, minimal restrictions.
- Apache‑2.0 – permissive, explicit patent grant.
- GPL‑3.0 – copyleft, derivatives must remain open‑source.
-
I-05 Informational Destination USDC Blacklist Initialized Empty Warning Acknowledged
Description
The USDC deployment script (
script/usdc/deploy/01_USDCDeploy.sh) force‑sets an empty blacklist before running Circle’s deployer by writing[]intoblacklist.remote.json:echo "[]" > blacklist.remote.jsonCircle’s FiatToken contracts actively maintain a blacklist on production chains (e.g., Arbitrum shows a designated
Blacklisterat 0x13F2A44FaD26c2cc25d3e3b869364142ce5995Bb and USDC runs at 0xaf88d065e77c8cC2239327C5EDb3A432268e5831). Initializing the destination chain with an empty blacklist means addresses currently blocked on other chains will not be blocked on the new destination at launch. During this divergence window, a recipient blacklisted on Arbitrum (or Ethereum) can receive mints and transfer on the destination chain until the list is manually synced, which could be a policy/compliance gap.Recommendation
Consider checking with the Circle’s team how to proceed with the blacklist. Blacklisting all the addresses in the destination chain would imply very high gas costs.
-
I-06 Informational Manual In‑Flight Message Check Is Error‑Prone Warning Resolved
Description
The takeover runbook instructs operators to “wait and check LayerZero Scan” to ensure no in‑flight messages exist before pausing the bridges. This off‑chain, manual verification is easy to skip or perform inconsistently. If operators pause while messages are still in flight, subsequent deliveries will hit
whenNotPausedguards in the takeover implementations (e.g.,DestinationOUSDCForTakeover._credit) and revert, creating avoidable failed executions that require retries/unpausing.The LayerZero Scan API already exposes a per‑OApp feed, GET
/messages/oapp/{eid}/{address}with optional limit, start, end and nextToken, that returns statuses such asINFLIGHT,CONFIRMING,PAYLOAD_STORED,BLOCKED,FAILED, andDELIVERED. Relying solely on human diligence instead of this endpoint risks pausing with unresolved messages.Recommendation
Before executing pause scripts, add an automated pre‑check that queries
GET /messages/oapp/{eid}/{address}for both destination and source bridge proxies and aborts if anynon‑DELIVEREDmessages are returned. -
I-07 Informational Missing __Pausable_init Invocation Best Practices Resolved
Description
SourceOFTAdapterForTakeoverinheritsPausableUpgradeablebut itsinitializefunction does not call__Pausable_init.function initialize(address _delegate) public initializer { __OFTCore_init(_delegate); __Ownable_init(_delegate); }In OpenZeppelin’s upgradeable contracts pattern, every inherited module’s initializer should be invoked from the proxy initializer (e.g.
__Pausable_initwhen inheritingPausableUpgradeable). Omitting it deviates from the recommended initialization procedure for upgradeable contracts.Recommendation
Call
__Pausable_initinside theinitializefunction. -
I-08 Informational Missing Storage Gaps In Upgradeable Contracts Best Practices Acknowledged
Description
Upgradeable contracts in scope do not declare a storage gap. OpenZeppelin recommends reserving unused storage slots at the end of upgradeable contracts to reduce the risk of storage layout conflicts in future versions (e.g. when adding new state variables, changing inheritance or upgrading dependencies storage). Without a gap, adding variables later may shift storage and corrupt state in existing proxies.
Recommendation
Add a storage gap to each upgradeable contract even if it currently uses only immutable variables. When adding state later, consume slots from the gap and keep prior layout preserved.
-
I-09 Informational Single Step Ownable Module Best Practices Resolved
Description
The bridge and adapter contracts rely on an owner account to perform sensitive lifecycle actions (configure peers, pause/unpause, set Circle takeover roles, assign/revoke mint/burn permissions). This design allows operators to react quickly and coordinate the Bridged USDC takeover. However, the contracts use single-step
Ownablemodule, where ownership transfer finalize immediately and privileged functions execute without delay.This creates a potential risk, where a compromised key, mistaken transfer, or rushed action could instantly allow to:
- set peer to a malicious OApp (granting mint authority on the destination bridge),
- change takeover addresses (
setCircle) or callburnLockedUSDCat the wrong time, - push unreviewed LayerZero security/library/DVN config changes,
- pause/unpause contracts in an undesirable time.
Because ownership changes take effect immediately, there is no review window for monitoring to catch bad actions and no explicit acceptance step by the new owner.
Recommendation
- Consider implementing
Ownable2Stepmodule (if feasible and compatible with the Bridged USDC Standard), so that ownership transfers require explicit acceptance by the new owner. - Process non‑emergency privileged calls (
setPeer, DVN/library config,setCircle, minter role changes) through the timelock. - Set the owner to a multisig behind a timelock.
- Override the
renounceOwnershipfunction to prevent accidental loss of ownership. - Implement a timelock on the
setPeerfunction and monitor pending changes.
-
I-10 Informational Discrepancy In The TransparentUpgradeableProxy/ProxyAdmin Warning Acknowledged
Description
The
TransparentUpgradeableProxycontracts deployed in all the scripts exceptscript/usdc/deploy/01_USDCDeploy.share a v5+ Openzeppelin version:import "openzeppelin-contracts/contracts/proxy/transparent/TransparentUpgradeableProxy.sol";. TransparentUpgradeableProxy v5+ use an immutable admin and auto‑deploys aProxyAdminin its constructor.However, USDC uses Circle’s
FiatTokenProxy/ Openzeppelin’s v4AdminUpgradeabilityProxy: stablecoin-evm/contracts/upgradeability/AdminUpgradeabilityProxy.sol at master · circlefin/stablecoin-evm that uses a mutable admin slot with constructor allowing direct admin swaps viachangeAdmin(newAdmin)only callable by the current admin.Recommendation
Merely informative. Be aware that the USDC token address in the source chain is the only contract that is not behind a TUP.
-
I-11 Informational setCircle Allows Circle Address To Be Re Set Warning Acknowledged
Description
The
setCirclefunction allows the owner to set the address forcircle.However the function does not prohibit the ability to callsetCircleagain once the contract is given the zero allowanceMinterrole.According to the standard,
Be only callable by an address that Circle specifies closer to the time of the upgrade. Note that this address will not necessarily be the same address that is specified to call transferUSDCRoles.
The above describes restrictions according to the
burnLockedUSDCfunction. This function is callable by the address that is set ascirclein thesetCirclefunction. Therefore the code does not fully comply with the standard since the owner is allowed to change thecircleaddress after it was already set.Recommendation
Consider changing the logic of
setCircletofunction setCircle(address _circle) external onlyOwner { require(circle == address(0), "circle already set"); require(_circle != address(0), "zero address"); circle = _circle; emit CircleSet(_circle); }
Remediation Review
1 finding · September 20, 2025-
I-01 Informational InflightMsgCheckLzScan.sh Can Silently Succeed Configuration Resolved
Description
The takeover helper
script/usdc/for_circle_takeover/InflightMsgCheckLzScan.shrelies oncurlandjqto ensure there are no LayerZero messages in flight before pausing the bridge. When the HTTP call fails or returns malformed JSON,jqexits with a non‑zero status and nothing is written to stdout, yet the following if:if echo "$response" | jq -e '.data[]? | select(.status.name == "INFLIGHT" or .status.name == "CONFIRMING")' > /dev/null; thentreats that failure exactly like “no inflight messages” and the function returns 0, so the script prints “SUCCESS: All checks passed.” This creates a false sense of safety as subsequent pause scripts will continue even though the bridge’s state is unknown, making it possible that real inflight transfers get frozen mid‑upgrade.
Recommendation
Fail fast on tooling errors. Add
set -euo pipefailto the shell prologue, capture the exit status of bothcurlandjqand abort unless both succeed. If either command fails, emit a clear error and return a non‑zero exit code so the pausing scripts refuse to continue.
No findings match.
More from Citrea
Put your code through the same review.
This review started with a conversation about scope. Tell us what you are building and we will plan yours with you.
